Query Store payload stages its aggregate — the fixed cost big catalogs couldn't pay - #2134
Conversation
…s couldn't pay (#2133) The collector joined its slice aggregate straight into the query_store_plan/query/text TVFs. With only fixed-guess cardinalities to plan from, the optimizer re-materialized a TVF per probe — a fixed ≥30s cost on an 82k-plan catalog that no catch-up window width could reduce, which is why the fleet's big databases pinned at the 15-minute shrink floor while smaller neighbors on the same servers stayed current. Bisected live on the wedged store: aggregate alone 81 ms, each TVF bare ~300 ms, aggregate-JOIN-plan >30s hinted or not. Staged through a temp table: 524 ms core; the full 55-column batch with plan capture completed a one-hour backlog in 21.3 s where the old shape never finished in 60. - SELECT INTO #pm_qs_slice, joins run FROM the temp with real row counts - LOOP JOIN hint removed for good — looping from the temp into the TVFs is the same per-probe re-materialization by another name (pinned) - interval pre-filter resolves ids from the interval catalog (20 ms) instead of scanning runtime_stats (426 ms), same superset semantics - one result set per batch; TIES/watermark/byte-budget unchanged; leading DROP covers Azure pooled reuse, sp_executesql scope self-cleans - shape pins updated: staging, catalog pre-filter, no-LOOP-JOIN Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
| qsrs.plan_id, | ||
| qsrs.runtime_stats_interval_id, | ||
| qsrs.execution_type_desc{replicaGroupKey}, | ||
| first_execution_time = MIN(qsrs.first_execution_time), | ||
| last_execution_time = MAX(qsrs.last_execution_time), | ||
| count_executions = SUM(qsrs.count_executions), | ||
| {WeightedAverage("avg_duration")}, | ||
| min_duration = MIN(qsrs.min_duration), | ||
| max_duration = MAX(qsrs.max_duration), | ||
| {WeightedAverage("avg_cpu_time")}, | ||
| min_cpu_time = MIN(qsrs.min_cpu_time), | ||
| max_cpu_time = MAX(qsrs.max_cpu_time), | ||
| {WeightedAverage("avg_logical_io_reads")}, | ||
| min_logical_io_reads = MIN(qsrs.min_logical_io_reads), | ||
| max_logical_io_reads = MAX(qsrs.max_logical_io_reads), | ||
| {WeightedAverage("avg_logical_io_writes")}, | ||
| min_logical_io_writes = MIN(qsrs.min_logical_io_writes), | ||
| max_logical_io_writes = MAX(qsrs.max_logical_io_writes), | ||
| {WeightedAverage("avg_physical_io_reads")}, | ||
| min_physical_io_reads = MIN(qsrs.min_physical_io_reads), | ||
| max_physical_io_reads = MAX(qsrs.max_physical_io_reads), | ||
| {WeightedAverage("avg_clr_time")}, | ||
| min_clr_time = MIN(qsrs.min_clr_time), | ||
| max_clr_time = MAX(qsrs.max_clr_time), | ||
| min_dop = MIN(qsrs.min_dop), | ||
| max_dop = MAX(qsrs.max_dop), | ||
| {WeightedAverage("avg_query_max_used_memory")}, | ||
| min_query_max_used_memory = MIN(qsrs.min_query_max_used_memory), | ||
| max_query_max_used_memory = MAX(qsrs.max_query_max_used_memory), | ||
| {WeightedAverage("avg_rowcount")}, | ||
| min_rowcount = MIN(qsrs.min_rowcount), | ||
| max_rowcount = MAX(qsrs.max_rowcount){numPhysIoReadsAgg}{logBytesAgg}{tempdbAgg} |
There was a problem hiding this comment.
Minor style nit: this column list is inconsistently indented now that it's a top-level SELECT ... INTO instead of a nested derived table. plan_id, runtime_stats_interval_id, and execution_type_desc sit at 4 spaces (correct per the style guide), but every aggregate column below them (first_execution_time = MIN(...) through max_rowcount = MAX(...)) is still indented 8 spaces — a leftover from when this block was one level deeper inside FROM (...). Worth dedenting the aggregate lines to 4 spaces to match the rest of the list and the file's "4 spaces indentation" convention.
|
Reviewed the staged-join fix ( Correctness — the core change checks out:
Parity — no drift. Test coverage — the One minor style nit left as an inline comment: the No security, missing-index-DMV, or performance-regression concerns — the change is a well-documented, benchmarked improvement. |
Four tests parsed the old derived-table structure (GROUP BY indent, the aggregate's bounds, the list-tail-to-FROM seam) — updated to the staged shape. While at it, the staging statement's spliced fragments and aggregate lines are normalized to a consistent 4-space indent so the emitted SQL doesn't carry the derived table's extra level, and the pins stay clean instead of encoding mixed indentation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing indent" This reverts commit 82dea99.
The four tests parsed the old derived-table structure: the aggregate's bounds (FROM-paren to ') AS qsrs' — now the staging SELECT to INTO), the GROUP BY indent (one level shallower as a top-level statement), and the list-tail-to-FROM seam (INTO #pm_qs_slice now sits between). Pins updated to the emitted shape; no source-file rewriting involved. (The previous attempt at this — 82dea99, reverted in 503a087 — used a python block-dedent whose start anchor matched the FIRST marker comment in the file, 700 lines early, and quietly reindented the enumeration query and everything after it. Reverted rather than rewritten; the pins below are Edit-tool surgical.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| qsrs.runtime_stats_interval_id, | ||
| qsrs.execution_type_desc{replicaGroupKey} | ||
| HAVING | ||
| {intervalHaving}; |
There was a problem hiding this comment.
The staging SELECT ... INTO #pm_qs_slice statement has no OPTION(RECOMPILE), so it's now exposed to exactly the parameter-sniffing problem this PR set out to fix.
Before this change, the aggregate (this GROUP BY/HAVING block) lived inside the single query-plan-plan-text join that carried OPTION(RECOMPILE, LOOP JOIN) — so the whole statement, including this aggregate, was recompiled fresh on every execution against the actual @cutoff_time/@floor_time/@ceiling_time values.
Now that the aggregate is its own statement, it has no query hint at all. It's invoked through sp_executesql (on-prem) with the same parameterized text every cycle — only the parameter values change — so SQL Server will cache a plan for this statement the first time it runs and reuse it on later executions. Live cutoff windows and backfill floor/ceiling windows (which per the PR description can span from a live catch-up window up to a 24h AdaptiveSpan slice) have wildly different selectivity against sys.query_store_runtime_stats_interval, so a plan cached from one shape can easily misestimate for the other — the same "fixed-guess cardinality" failure mode called out as the root cause here, just relocated one statement earlier.
Suggest adding OPTION(RECOMPILE) to this SELECT ... INTO statement as well, so both statements in the batch get fresh estimates from the actual parameter values every cycle.
| SELECT /* PerformanceMonitorLite */ | ||
| qsrs.plan_id, | ||
| qsrs.runtime_stats_interval_id, | ||
| qsrs.execution_type_desc{replicaGroupKey}, | ||
| first_execution_time = MIN(qsrs.first_execution_time), | ||
| last_execution_time = MAX(qsrs.last_execution_time), | ||
| count_executions = SUM(qsrs.count_executions), | ||
| {WeightedAverage("avg_duration")}, | ||
| min_duration = MIN(qsrs.min_duration), | ||
| max_duration = MAX(qsrs.max_duration), | ||
| {WeightedAverage("avg_cpu_time")}, | ||
| min_cpu_time = MIN(qsrs.min_cpu_time), | ||
| max_cpu_time = MAX(qsrs.max_cpu_time), | ||
| {WeightedAverage("avg_logical_io_reads")}, | ||
| min_logical_io_reads = MIN(qsrs.min_logical_io_reads), | ||
| max_logical_io_reads = MAX(qsrs.max_logical_io_reads), | ||
| {WeightedAverage("avg_logical_io_writes")}, | ||
| min_logical_io_writes = MIN(qsrs.min_logical_io_writes), | ||
| max_logical_io_writes = MAX(qsrs.max_logical_io_writes), | ||
| {WeightedAverage("avg_physical_io_reads")}, | ||
| min_physical_io_reads = MIN(qsrs.min_physical_io_reads), | ||
| max_physical_io_reads = MAX(qsrs.max_physical_io_reads), | ||
| {WeightedAverage("avg_clr_time")}, | ||
| min_clr_time = MIN(qsrs.min_clr_time), | ||
| max_clr_time = MAX(qsrs.max_clr_time), | ||
| min_dop = MIN(qsrs.min_dop), | ||
| max_dop = MAX(qsrs.max_dop), | ||
| {WeightedAverage("avg_query_max_used_memory")}, | ||
| min_query_max_used_memory = MIN(qsrs.min_query_max_used_memory), | ||
| max_query_max_used_memory = MAX(qsrs.max_query_max_used_memory), | ||
| {WeightedAverage("avg_rowcount")}, | ||
| min_rowcount = MIN(qsrs.min_rowcount), | ||
| max_rowcount = MAX(qsrs.max_rowcount){numPhysIoReadsAgg}{logBytesAgg}{tempdbAgg} | ||
| INTO #pm_qs_slice | ||
| FROM sys.query_store_runtime_stats AS qsrs | ||
| WHERE qsrs.runtime_stats_interval_id IN | ||
| ( | ||
| SELECT | ||
| i.runtime_stats_interval_id | ||
| FROM sys.query_store_runtime_stats_interval AS i | ||
| WHERE {intervalPreFilter} | ||
| ) | ||
| GROUP BY | ||
| qsrs.plan_id, | ||
| qsrs.runtime_stats_interval_id, | ||
| qsrs.execution_type_desc{replicaGroupKey} |
There was a problem hiding this comment.
Indentation is inconsistent in the staged SELECT ... INTO block, and it looks like leftover formatting from when this list lived one level deeper inside FROM (\n SELECT ...\n) AS qsrs.
- SELECT list:
qsrs.plan_id,qsrs.runtime_stats_interval_id,qsrs.execution_type_desc(lines 829-831) are now at 4-space indent, but every column starting withfirst_execution_time = MIN(...)at line 832 throughmax_rowcountat line 860 is still at 8-space indent. GROUP BY(lines 871-873): the first three items are 4-space indented, butreplicaGroupKey(PerformanceMonitor.Collectors/QueryStoreCollector.cs:684,",\n qsrs.replica_group_id") still hardcodes an 8-space continuation, so the emittedGROUP BYrenders with the last item mis-indented relative to its siblings.- The backfill form of
intervalPreFilter(PerformanceMonitor.Collectors/QueryStoreCollector.cs:799," AND i.start_time < @ceiling_time") is also still indented for the old nesting level; against the new 4-spaceWHERE {intervalPreFilter}at line 868, theANDcontinuation no longer column-aligns withWHERE's predicate the way the style guide'sWHERE ... / AND ...alignment convention expects.
None of this is a behavioral bug, but the style guide calls for consistent 4-space indentation, and the tests were updated to pin the mixed indentation as-is (e.g. the GROUP BY assertion in Lite.Tests/QueryStoreCollectorDefinitionTests.cs) rather than catching it. Worth flattening the whole staged statement to one consistent indent level, including replicaGroupKey and the backfill intervalPreFilter/intervalHaving continuations, and updating the pinned test strings to match.
ReviewReviewed the staged-aggregate rewrite of Two inline findings, left on the diff:
Otherwise the staging approach itself is sound: |
…s to 4-space Both review catches: - The staging SELECT INTO, split out on its own, would be cached via sp_executesql's parameterized text and sniffed across live vs backfill windows of wildly different selectivity — the fixed-guess failure mode this PR removes, reintroduced one statement earlier. Both statements now carry OPTION(RECOMPILE), documented in the template comment. - The staged block's leftover derived-table indentation (aggregate lines, spliced fragments, backfill window continuations) dedents to the file's consistent 4-space convention, Edit-tool surgical, and the pins assert the clean shape instead of encoding the mixed one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both taken, in 9289614:
|
|
Reviewed the diff (CHANGELOG.md, Parity — Correctness checks I ran down explicitly:
Style — indentation dedent (8→4 spaces) brings the touched blocks in line with the project's 4-space T-SQL convention; comments stay Didn't flag any missing-index DMV suggestions per the review brief. Nothing else stood out as a correctness, security, or performance regression. |
Closes #2133 — and this is the actual root cause under the whole catch-up saga: #2102's death spiral, #2111's yield-to-live, and #2125's adaptive shrink were all mitigating a cost none of them touched.
The bug
The payload joined its slice aggregate straight into the
query_store_plan/query/textcatalog TVFs. The optimizer only has fixed-guess cardinalities for TVFs, and the shape it picked re-materialized a TVF per probe — a fixed ≥30-second cost on an 82k-plan catalog that no catch-up window width could reduce. That's why the fleet's big databases (echo, oak, Surge, spruce, insa…) pinned at the 15-minute shrink floor and never converged while smaller neighbors on the same servers stayed current.Bisected live on the wedged store (echo, SQL 2022, 3 GB QS catalog)
sys.query_store_planbare scanThe fix
SELECT ... INTO #pm_qs_slice, then the plan/query/text joins run FROM the temp — real row counts instead of TVF guesses, each TVF scanned exactly once. sp_QuickieStore stages for exactly this reason.LOOP JOINhint is gone for good and pinned absent: looping from the temp into the TVFs is the same per-probe re-materialization by another name. The 524 ms join is unhinted, chosen from true cardinalities.runtime_statsitself (426 ms) — same superset bound, the exact HAVING unchanged.SELECT INTOemits no result set so the batch still returns exactly ONE (the reader/byte-budget contract);TOP … WITH TIES, ship order, and derived-watermark semantics live on the final SELECT unchanged; the leadingDROP TABLE IF EXISTScovers Azure's pooled direct connections while the on-premsp_executesqlscope self-cleans. One shared body, so both SKUs and both engine arms (live + backfill) get the fix by construction.Validation
All timings above are from read-only probes against the wedged field store, run today. Shape pins updated: staging present, catalog pre-filter, no-LOOP-JOIN (both live and backfill arms), HAVING indentation.
After merge: nightly → dogfood box → the eight wedged members should converge and the adaptive shrink demote to a rarely-needed safety net.
🤖 Generated with Claude Code