Repository navigation
query_store: force HASH JOIN on the plan and text fetch statements - #2792
Conversation
sys.query_store_plan's view definition unions the on-disk table with the in-memory TVF QUERY_STORE_PLAN_IN_MEM. The optimizer has no statistics for that TVF and uses a fixed guess - on AYR, 1,000 estimated against 14,633 actual, 1,463% off. That guess makes Nested Loops look cheap, so the TVF lands on the INNER side and is re-executed once per candidate plan_id, up to the 512 MaxCandidatePlans cap, each execution scanning the whole in-memory Query Store before a single plan is decompressed. sp_QuickieStore against AYR ordered by average CPU puts these statements at the top of the entire instance, 55,000-61,000ms CPU each. The TOP (50000) runtime-stats payload that this investigation spent weeks suspecting averages ~2s and was never the cost. The joins are inside the view definition and cannot be hinted individually, so the lever is the query-level hint. Measured on AYR with actual plans: ~60,000ms CPU to 0.508s, the hinted plan being a Hash Match fed by the TVF executed ONCE. The risk of a query-level hint is that it applies to every join in the statement, including the 512-row Clustered Index Seek into plan_persist_plan at 91% of estimated cost - forcing hash there could have turned that seek into a scan of a large table. Measured, it does not: the hinted statement finishes in half a second on an 85%-full Query Store. The text fetch joins sys.query_store_query to sys.query_store_query_text, both TVF-backed unions, driven by the same kind of IN list. Same defect, same fix. The AYR measurement is the plan fetch's; it is not claimed for the text fetch. Correctness: rowsets are byte-identical with and without the hint (SHA256 over every column of every row) at 16/128/512 candidates, for both fetches, on a 40,882-plan Query Store, under ARITHABORT OFF - the collector's setting, not SSMS's. Actual plans confirm the mechanism: Nested Loops present without the hint, absent with it. Also recorded, because it is the obvious next idea and it does not work: evaluating the byte budget against a compressed length before decompressing is not available through this view. Over 512 ids, plan_id alone is 14ms, DATALENGTH(qsp.query_plan) is 321ms and a full CONVERT is 331ms - DATALENGTH costs what decompression costs, because the view decompresses on any access to the column. Fixes #2791 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
| ## [Unreleased] | ||
|
|
||
| ### Added | ||
| - **query_store plan and text fetch: force HASH JOIN so the in-memory Query Store TVF is read once** ([#2791]) - `sys.query_store_plan` unions the on-disk table with the TVF `QUERY_STORE_PLAN_IN_MEM`, and the optimizer has no statistics for it: on AYR it estimated 1,000 rows against 14,633 actual (1,463% off). That guess put the TVF on the inner side of a Nested Loops join, re-executed once per candidate plan_id up to the 512 `MaxCandidatePlans` cap, each execution scanning the whole in-memory Query Store before any plan was decompressed. sp_QuickieStore put these statements at **55,000-61,000ms CPU each**, top of the whole instance, while the `TOP (50000)` runtime-stats payload this was long assumed to be averaged ~2s. `OPTION(RECOMPILE, HASH JOIN)` reads the TVF once: **~60,000ms CPU to 0.508s** measured on AYR. The same shape and the same fix apply to the text fetch, which joins two TVF-backed views directly. Rowsets verified byte-identical with and without the hint at 16/128/512 candidates under the collector's own ARITHABORT OFF. |
There was a problem hiding this comment.
Nit: this entry describes a performance/correctness fix (wrong join strategy causing 55-61s CPU per fetch), not new functionality, but it's filed under ### Added. Every other entry of this exact shape in the current Unreleased section — the very similar Query Store catalog-view join fixes at #2764, #2766, and #2759 a few lines below — lives under ### Fixed. Suggest moving this bullet there for consistency with the project's own precedent.
There was a problem hiding this comment.
Good catch, fixed in c8ffaaa — moved to ### Fixed. You're right on the precedent: #2764, #2766 and #2759 are the same class of Query Store catalog-view join fix and all sit under Fixed.
Cause was my own insertion logic: it looked for ## [Unreleased] followed immediately by ### Fixed, which never matched because ### Added comes first in that section, so it silently fell through to the ### Added anchor instead of failing. The corrected version asserts the ### Fixed it finds is inside the Unreleased span before inserting.
Review summaryReviewed the diff ( Parity: No drift. T-SQL style: Compliant. Correctness: The change is narrowly scoped — only the two by-ids fetch statements that actually join gain the hint; the sibling statements that read only Nit posted inline: the CHANGELOG entry is filed under No security concerns (id lists are host-computed |
Confirmed: nothing we ship emits the
|
| file | what it is |
|---|---|
Darling/PerformanceMonitor.Darling.Storage/DarlingPgBlockingReader.cs:314 |
PostgreSQL blocking read |
PerformanceMonitor.Collectors/PgIndexBloatCollector.cs:108 |
PostgreSQL index bloat collector |
2. Every file mentioning query_store_plan that also contains a CTE — two candidates, both cleared:
Lite/Services/LocalDataService.QueryStore.cs:849—FetchQueryStorePlanAsync, a single-plan on-demand viewer fetch:SELECT TOP (1) query_plan = CONVERT(nvarchar(max), qsp.query_plan) FROM sys.query_store_plan AS qsp WHERE qsp.plan_id = @plan_id. One parameterised equality predicate, no CTE, noINlist. There is no multi-row outer input for a loop to re-execute a TVF against, so the query_store plan fetch: TVF re-executed per candidate plan_id (55-61s CPU); force HASH JOIN #2791 defect cannot arise.CommandTimeout = 30, fired when a user clicks a plan.Darling/PerformanceMonitor.Darling.Storage/QueryStorePlanMap.cs— the Postgres store tablecollect.query_store_plan_map. It never queries SQL Server's catalog; the only occurrence of the name is a doc comment explaining thatquery_plan_hashreads without decompressing.
3. Every emitter of CONVERT(nvarchar(max), qsp.query_plan): the two in QueryStoreCollector.cs (line 1293, the statement this PR hints; line 694, a doc comment), the Lite single-plan fetch above, and three assertions in QueryStorePlanFetchTests.cs.
Conclusion: no shipping code path emits a CTE over sys.query_store_plan. Those two OMEGA entries are ad-hoc experiments run against that database and captured by its own Query Store — which is exactly what Query Store is for, and harmless. Nothing is uncovered by this PR.
The plan_correction observation is filed separately as #2793, unmeasured and explicitly not folded in here.
Review catch. This is a wrong-join-strategy fix, not new functionality, and the sibling Query Store catalog-view entries in the same Unreleased section (#2764, #2766, #2759) are all under Fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
I had written that the seek->scan trade "never materialises". The actual plan says otherwise: under the hint, plan_persist_plan access DOES become a Clustered Index Scan. The reason it is a non-issue is not that it fails to happen, it is that it costs 0.032s - against 0.373s for the TVF and 0.436s for the Hash Match above it, in a statement that finishes in 0.508s. Worth stating in that direction: the estimate puts the seek at 91% of cost and the actual plan puts the scan at 6% of half a second, so the operator the estimate points at is not the operator that matters. That is the same lesson as the TVF itself showing as 1-3% of estimated cost while carrying the real work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
Two process notes worth keeping, both the same failure shape
|
| operator | estimated | actual |
|---|---|---|
Clustered Index Seek/Scan [plan_persist_plan] |
91% of cost | 0.032 s — 6% of the statement |
Table Valued Function [QUERY_STORE_PLAN_IN_MEM] |
1–3% of cost | 0.373 s — carries the real work, 1,463% cardinality miss |
Read actuals on these views, or do not read the plan at all. Reasoning from the estimate is what kept this defect hidden while the TOP (50000) payload — averaging ~2 s — absorbed weeks of suspicion.
| FROM sys.query_store_plan AS qsp | ||
| WHERE qsp.plan_id IN ({idList}); | ||
| WHERE qsp.plan_id IN ({idList}) | ||
| OPTION(RECOMPILE, HASH JOIN); |
There was a problem hiding this comment.
Nit / worth a sentence in the comment above rather than a blocker: OPTION(RECOMPILE, HASH JOIN) forces a Hash Match, which (unlike the Nested Loops it replaces) requires a workspace memory grant. That's clearly the right trade on the OMEGA case this PR fixes (60s CPU → 0.5s), but this collector can run once per database per cycle across a fleet of concurrently-collected databases. On an instance already under memory pressure, an unconditional hash-join grant on every invocation — even ones with only a handful of candidate ids where Nested Loops would've been cheap and grant-free — is a small but real behavior change versus letting the optimizer choose per-statement. Given how carefully everything else here is measured, it'd be worth either a one-line note confirming this was considered (e.g. observed grant size on OMEGA), or confirming it's a non-issue given typical candidate-set sizes. Same applies to the text fetch at line 1417.
There was a problem hiding this comment.
Fair, and you are right on the mechanism — I had not measured it. Now I have, and the answer is "real but bounded". Recorded in the comment on both builders in 3ea41f5.
Grants on a 40,882-plan Query Store, no spill in any case:
| candidate ids | without hint | with hint |
|---|---|---|
| 4 | 0 KB — pure Nested Loops, grant-free | 1,264 KB |
| 512 | 1,760 KB — optimizer already picked a hash | 3,624 KB |
Three things fall out of that:
- Your small-set case is exactly right. At 4 ids the unhinted plan is grant-free and the hint introduces a grant where none existed. That is a genuine behavior change, not a rounding error in the argument.
- The magnitude is ~1.2 MB. And at the top end
MaxCandidatePlanscaps the input at 512, which caps the grant at ~3.6 MB — of which the optimizer was already paying 1,760 KB on its own, because at that size it picks a hash anyway. So at the large end the hint roughly doubles an existing grant rather than introducing one. - The cap is what makes this bounded. The grant is a function of the candidate list, not of Query Store size, so it does not grow with the 85%-full catalogs that motivated the fix.
On fleet concurrency: sweeps are concurrent across servers but the per-item loop is sequential within a server on one connection, so the worst case is a few concurrent sweeps' worth — single-digit MB, against a statement that was burning 55,000–61,000 ms of CPU per invocation.
I also named the symptom in the comment for the case where this is wrong somewhere I have not measured: RESOURCE_SEMAPHORE waits or a hash spill on the monitored instance. Neither appears in anything measured here.
Applied to the text fetch as well, as you asked.
|
Reviewed the diff (CHANGELOG.md, Style / conventions: clean. Both fetch statements get their own Lite/Darling parity: no drift. Correctness: the test suite ( One thing worth a sentence in the PR/comment, not a blocker: left as an inline comment on No missing-index DMV suggestions, no security issues, no performance regressions beyond the note above. |
Review catch: a Hash Match needs a workspace memory grant where the Nested Loops it replaces needs none, and the hint applies unconditionally - including on small candidate sets the optimizer would have served grant-free. Real, and it deserved a number rather than a reassurance. Measured on a 40,882-plan Query Store, no spill in any case: 4 ids : unhinted 0KB (pure loops) -> hinted 1,264KB 512 ids: unhinted 1,760KB (already a hash anyway) -> hinted 3,624KB So a grant IS introduced at the small end, at ~1.2MB. MaxCandidatePlans caps the input at 512 and the grant at ~3.6MB, where the optimizer was already choosing a hash and paying half of that anyway. The cap is what bounds this - the grant is not a function of Query Store size. Sweeps run concurrently across servers but sequentially within one, so fleet-wide worst case is single-digit MB, against a statement that was burning 55,000-61,000ms of CPU. Named the symptom if this is ever wrong somewhere we have not measured: RESOURCE_SEMAPHORE waits or a hash spill on the monitored instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
|
Reviewed the diff, the two edited builder methods, their new tests, and the surrounding doc comments. No correctness, security, or parity issues found. Checks performed:
Nice level of measurement backing the trade-off (seek→scan, memory grant deltas bounded by |
|
Post-merge note, because this PR undersold its own blast radius. Production phase timings that arrived after merge show That
So this PR fixes the first and second largest measured collector costs on the use1 fleet, not query_store alone. They were the same statement reached through two collectors, which is why #2793 closed with that evidence. Its original hypothesis — hinting the three staged |
…pped window (Fixes erikdarlingdata#2794) The display's freshness classifier called a server dark at a bare 15-minute constant while the alert engine had deliberately decided collection is not stopped until 30 - one condition, two definitions, and the tighter one false-alarmed by design: the sweep skips relaunch while a body runs, so one long query_store cycle legitimately leaves 12-19 minutes of silence on a healthy server. Measured first, per the issue's own instruction: post-erikdarlingdata#2792 the worst legitimate fleet gap in 24h is 12m12s and zero real servers cross 15 minutes (the sole >15m series is server_id 0, the service's own bookkeeping rows, breaking exactly at deploy restarts), while issue-day load produced 235 crossings in 12 hours peaking at 19m18s - and genuinely dark servers run hours. 30 minutes separates the populations with margin on both sides and is the number the alert engine already committed to. OfflineThreshold now derives from a shared CollectionStoppedMinutesDefault alongside every other spelling of the claim: DarlingSelfAlertEvaluator.StaleWindow, the AlertsConfig.CollectionStaleMinutes default, Lite's settings twin, AgentStatusRow.StaleWindow (a numerically-equal independently-editable copy - the erikdarlingdata#1562 drift shape), and the PG long-running-query recency bound whose own comment derives it from this convention. CollectionStoppedThresholdAgreementTests pins the agreement by value on both apps; proven red against the pre-change tree (15m != 30m, and the measured 19m18s stretch banding Offline). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
Fixes #2791
The defect
sys.query_store_plan's view definition unions the on-disk table with the in-memory TVFQUERY_STORE_PLAN_IN_MEM. SQL Server has no statistics for that TVF and falls back to a fixed guess — on OMEGA, 1,000 estimated against 14,633 actual, 1,463% off. That guess is what makes Nested Loops look cheap, so the TVF lands on the inner side and is re-executed once per candidateplan_id, up to the 512MaxCandidatePlanscap, each execution scanning the whole in-memory Query Store before a single plan is decompressed.The estimate shows the TVF at 1–3% of cost, which is why this plan looks innocent and why it survived this long. Same class as #2676, one layer deeper.
sp_QuickieStore against OMEGA ordered by average CPU puts these statements at the top of the entire instance, 55,000–61,000 ms CPU each. For contrast, the
TOP (50000)runtime-stats payload this investigation spent weeks suspecting averages ~2 s and was never the cost.The fix
The joins live inside the view definition and cannot be hinted individually, so the lever is the query-level hint. Measured on OMEGA with actual plans:
OPTION(RECOMPILE, HASH JOIN)SELECT ... INTO #plan_fetch FROM sys.query_store_planThe hinted plan is
Hash Match (Right Outer Join)at 0.508 s fed by the TVF at 0.427 s, executed once.The trade-off, measured rather than assumed. A query-level hint applies to every join in the statement, including the Clustered Index Seek into
plan_persist_planat 91% of estimated cost. Forcing hash there risks turning that seek into a full scan — and it does: under the hint that access becomes a Clustered Index Scan. It is a non-issue, measured rather than argued:[plan_persist_plan][QUERY_STORE_PLAN_IN_MEM]Worth stating in that direction rather than as "the risk didn't happen": the estimate puts the seek at 91% of cost and the actual plan puts the scan at 6% of half a second. The operator the estimate points at is not the operator that matters — the same lesson as the TVF showing at 1–3% of estimated cost while carrying the real work.
Why the query is not restructured
Erik also measured a rewrite that materializes candidates into a
#tempwith a unique clustered index onplan_id, so the running-total window streams in index order and its Sort disappears — on OMEGA that Sort was 5.262 s against 0.436 s for the Hash Match feeding it, and the rewrite took that variant from ~6.5 s to ~2.5 s.That rewrite is real and the numbers are his, but it is not shipped here, for a reason that has nothing to do with whether it works: it restructures a shape we do not ship. The
WITH candidatesCTE it improves exists only as an ad-hoc experiment against OMEGA (see the repo-wide search in the comments below — every shipping path was checked). The form we actually ship already stages into#plan_fetch, and with the hint it runs in 0.508 s, which is comfortably better than the 2.5 s the rewrite reaches. So there is nothing to port over.The text fetch joins
sys.query_store_querytosys.query_store_query_text— both TVF-backed unions, driven by the same kind of IN list. Same defect, same fix. The OMEGA measurement is the plan fetch's and is not claimed for the text fetch; what is verified for it here is the join-strategy change and the identical-rowset property.The second statement of each builder reads only the
#tempand joins nothing, so it keeps plainOPTION(RECOMPILE). Checked, not assumed — and pinned by a test, because a query-level hint on a joinless statement is exactly the kind of thing that gets copied forward and later defended as load-bearing.Correctness — the rowset is unchanged
The hint must change the join strategy and nothing else. Verified against a live SQL Server (container,
QSTEST, 40,882-plan Query Store) driving the shipped builders, with the baseline derived from the shipped text by removing only, HASH JOIN:SHA256 over every column of every row in order, not a row count. Note
plan[512]returning 184 rows — the 12 MB budget cut fires there and cuts identically both ways.Mechanism confirmed from actual plans on the same instance:
The container cannot reproduce OMEGA's 512× TVF re-execution (its in-memory Query Store is small —
TVF_ActualExecutions=1both ways), which is why the performance verdict above comes from OMEGA and not from here. What the container establishes is correctness and the join-strategy change.The obvious next idea, and why it does not work
Evaluating the byte budget against a compressed length, so plans the budget discards are never decompressed, is not available through this view. Over 512 candidate ids:
SELECT plan_idaloneDATALENGTH(qsp.query_plan)CONVERT(nvarchar(max), qsp.query_plan)DATALENGTHcosts what full decompression costs, because the view decompresses on any access to the column. The compressed blob lives in the undocumentedsys.plan_persist_plan, which is not a surface to ship against. So the budget bounds what is shipped, not what is decompressed — a property of the catalog, not a shortcoming of this query. Recorded in the doc comment so it is not re-proposed.Not deployed
No deploy — there is an active measurement window on use1.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy