From 5a0ed100fadec26c5bacc2a660f9a8d69091331a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:00:19 -0400 Subject: [PATCH] Fix #286: apply Query Store filters before TOP N selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchTopPlansAsync applied user filters (--query-id, --plan-id, --query-hash, --query-plan-hash, --module) only in Phase 4, AFTER Phase 3 had already selected TOP N plans by CPU across the entire database. If the user's target query wasn't in the top N by CPU during the time window, it was cut from #top_plans before the filter ever ran — even though Query Store / SSMS / sp_QuickieStore could see it fine. Move the filter into Phase 3's CTE so TOP N is selected from the filtered universe. Conditionally JOIN sys.query_store_query when the filter touches query_hash or object_id (otherwise the join is skipped to avoid penalty in the unfiltered path). Phase 4 filter clause is now redundant and removed. The hash-tree and module-tree methods (FetchByQueryHashTree, FetchByPlanHashTree) already applied filters pre-TOP-N — they're unaffected. Closes #286. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/QueryStoreService.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index 9427c1e..5fd4b69 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -86,23 +86,29 @@ public static async Task> FetchTopPlansAsync( }; // Build optional WHERE clauses from filter (parameterized for safety). + // Filters are applied in Phase 3 (BEFORE TOP N) so the user's target + // query isn't excluded just because it's not in the top N by CPU. + // Aliases here reference Phase 3's CTE: ps (#plan_stats), + // p (sys.query_store_plan), q (sys.query_store_query). var filterClauses = new List(); var parameters = new List(); + var needsQueryJoin = false; if (filter?.QueryId != null) { - filterClauses.Add("AND q.query_id = @filterQueryId"); + filterClauses.Add("AND p.query_id = @filterQueryId"); parameters.Add(new SqlParameter("@filterQueryId", filter.QueryId.Value)); } if (filter?.PlanId != null) { - filterClauses.Add("AND tp.plan_id = @filterPlanId"); + filterClauses.Add("AND ps.plan_id = @filterPlanId"); parameters.Add(new SqlParameter("@filterPlanId", filter.PlanId.Value)); } if (!string.IsNullOrWhiteSpace(filter?.QueryHash)) { filterClauses.Add("AND q.query_hash = CONVERT(binary(8), @filterQueryHash, 1)"); parameters.Add(new SqlParameter("@filterQueryHash", filter.QueryHash.Trim())); + needsQueryJoin = true; } if (!string.IsNullOrWhiteSpace(filter?.QueryPlanHash)) { @@ -122,11 +128,15 @@ public static async Task> FetchTopPlansAsync( filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) = @filterModule"); } parameters.Add(new SqlParameter("@filterModule", moduleVal)); + needsQueryJoin = true; } var rnClause = filter?.PlanId != null ? "" : "AND r.rn = 1"; var filterSql = filterClauses.Count > 0 - ? "\n" + string.Join("\n", filterClauses) + ? "\n " + string.Join("\n ", filterClauses) + : ""; + var phase3QueryJoin = needsQueryJoin + ? " JOIN sys.query_store_query AS q ON p.query_id = q.query_id\n" : ""; // Time-range filter: always filter on interval start_time (indexed). @@ -241,6 +251,7 @@ CASE WHEN ps.total_executions > 0 ROW_NUMBER() OVER (PARTITION BY p.query_id ORDER BY {orderClause} DESC) AS rn FROM #plan_stats AS ps JOIN sys.query_store_plan AS p ON ps.plan_id = p.plan_id +{phase3QueryJoin} WHERE 1 = 1{filterSql} ) SELECT TOP ({topN}) r.query_id, @@ -295,7 +306,6 @@ ELSE N'' JOIN sys.query_store_plan AS p ON tp.plan_id = p.plan_id JOIN sys.query_store_query AS q ON p.query_id = q.query_id JOIN sys.query_store_query_text AS qt ON q.query_text_id = qt.query_text_id -WHERE 1 = 1{filterSql} ORDER BY {outerOrder} DESC;"; var plans = new List();