Skip to content

plan_correction: stage the Query Store views to #temps instead of joining them live - #2765

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/plan-correction-qs-materialize
Sep 1, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
fix/plan-correction-qs-materialize

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2764.

Problem

[#2673] applied half of the sp_QuickieStore rule to this collector: it staged sys.dm_db_tuning_recommendations into #pm_plan_correction_recs, so the statistics-free DMV stopped driving a plan-store scan. But the shipping SELECT in PayloadBodyText went right on joining the catalog views live:

LEFT JOIN sys.query_store_query AS qsq
  ON qsq.query_id = r.query_id
LEFT JOIN sys.query_store_query_text AS qsqt
  ON qsqt.query_text_id = qsq.query_text_id
LEFT JOIN sys.query_store_plan AS qsp
  ON qsp.query_id = r.query_id
  AND qsp.plan_id = r.last_good_plan_id

That is the naive-catalog-view join the pattern exists to avoid, paid per database, per server, every cycle. [#2762] removed a pointless OPTION(RECOMPILE) from the same query — cleanup, not this.

Fix

Stage the Query Store side the way sp_QuickieStore does (#query_store_plan -> #query_store_query -> #query_store_query_text). Three temps pull only the rows the already-small recommendation set names:

temp source keyed by
#pm_plan_correction_plans sys.query_store_plan (query_id, last_good_plan_id) from the recs — the exact pair the final join needs
#pm_plan_correction_queries sys.query_store_query query_id from the recs
#pm_plan_correction_query_text sys.query_store_query_text query_text_id from the queries temp

The shipping SELECT then LEFT JOINs the three temps. Each catalog view is read exactly once, by a seek keyed off a materialised row count, and never appears on the JOIN side of the shipping query.

What did not change

  • Output columns and their order are byte-identical to dev — verified by extracting the shipping SELECT list from both 1ca4f299 and this branch and comparing: identical, 38 columns. The reader ordinals in Read follow that list, so nothing downstream shifts.
  • LEFT semantics preserved. A recommendation whose plan has aged out of Query Store still lands with NULL Query Store columns instead of being dropped — QueryStoreJoins_AreLeft_SoAnAgedOutPlanCannotDropTheRecommendation still pins this, now following the joins to the temps.
  • No OPTION(RECOMPILE) reintroduced.

Tests

New QueryStoreViews_AreStagedToTemps_NeverJoinedLive pins the shape: no JOIN sys.query_store_* anywhere in the body, each view read exactly once into its temp, the staging keys, and every temp dropped (guard + tail).

Verification scope — stated plainly

  • PerformanceMonitor.Collectors and Lite.Tests both build clean on macOS (0 errors; no new warnings from the touched files).
  • The Windows-only test suites cannot execute on macOS, so the new pins were run against the real built assembly with a throwaway net10.0 harness: 27/27 PASS, covering all of the above plus statement ordering (recs staged before the QS temps; queries before text; all three before the shipping SELECT).
  • The pins were then run against 1ca4f299's pre-fix SQL text: 7/7 RED. They fail without this change, which is what makes them worth having.
  • No before/after against a live target. The Mac has no route to the monitored servers and the MCP is read-only over collected rows, so there is no honest execution-time number to quote here. The structural change is the deliverable; the fleet-side effect should be read off get_collector_cost(collector_name=plan_correction) once this ships.

…ning them live

#2673 applied half the sp_QuickieStore rule here -- it staged
sys.dm_db_tuning_recommendations into #pm_plan_correction_recs so the
statistics-free DMV stopped driving a plan-store scan -- but the shipping
SELECT went on LEFT JOINing sys.query_store_query, sys.query_store_query_text
and sys.query_store_plan directly, which is the naive-catalog-view join the
pattern exists to avoid, paid per database, per server, every cycle.

Stage the Query Store side the same way sp_QuickieStore does (plan -> query ->
text): #pm_plan_correction_plans keyed by the (query_id, last_good_plan_id)
pair the final join needs, #pm_plan_correction_queries by query_id, and
#pm_plan_correction_query_text by the queries temp's query_text_id. The
shipping SELECT joins the three temps, so each catalog view is read exactly
once, by a seek keyed off a materialised row count, and never appears on the
JOIN side of the shipping query.

Output columns and their order are byte-identical to before (the reader
ordinals follow the SELECT list), and the LEFT semantics are preserved: a
recommendation whose plan has aged out of Query Store still lands with NULL
Query Store columns rather than being dropped.

Fixes #2764.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Reviewed the diff against CONTRIBUTING.md's T-SQL style section and the shared-library parity rules. No issues found.

  • Correctness: The three new #temp staging queries key correctly to what the shipping SELECT actually joins on — #pm_plan_correction_plans on (query_id, last_good_plan_id), #pm_plan_correction_queries on query_id, #pm_plan_correction_query_text chained off the queries temp's query_text_id — mirroring sp_QuickieStore's plan → query → text walk. LEFT JOIN/NULL semantics for aged-out plans are preserved (confirmed by the existing QueryStoreJoins_AreLeft_... test, updated to follow the joins to their new targets). All four temps get both an IF EXISTS guard at the top and an explicit DROP at the tail.
  • Style: AS on every alias, column_name = expression aliasing, AND-predicate alignment, and SELECT 1/0 inside EXISTS(...) all match established idioms elsewhere in this file and others (e.g. LongQueryCompletionsCollector.cs). No OPTION(RECOMPILE) added to the new statements, consistent with the already-documented rationale a few lines up (static literal SQL, no runtime parameters, stats-free DMV already materialized) — correct not to add it here either.
  • Parity: PlanCorrectionCollector lives in the shared PerformanceMonitor.Collectors project, so Lite and Darling consume the identical query text — no drift risk, and the new Lite.Tests coverage exercises the shared definition both editions use.
  • Tests: QueryStoreViews_AreStagedToTemps_NeverJoinedLive is a solid regression pin — it asserts no live JOIN sys.query_store_*, each catalog view read exactly once, and every temp both guarded and dropped.

No security, missing-index-DMV, or performance concerns.

…onitor into fix/plan-correction-qs-materialize

# Conflicts:
#	CHANGELOG.md
@claude

claude Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Review

Traced the join-semantics claim by hand: for every staged temp (#pm_plan_correction_plans/_queries/_query_text), the WHERE EXISTS filter used to populate it is identical to (or a superset covering) the predicate the final LEFT JOIN re-applies against r row-by-row, so the result set is provably unchanged from the live-view version -- no fan-out, no dropped rows, LEFT semantics preserved for aged-out plans. Column list, order, and PayloadColumns/ReadRow ordinals are untouched (still 38), so nothing downstream shifts. Confirmed no Darling-side code references this collector's SQL text or column layout directly (DarlingPlanCorrectionReader, DarlingMcpPlanCorrectionTools, ViewerDataService.PlanCorrection.cs all key off PayloadColumns), so there's no parity drift to flag -- this is a shared PerformanceMonitor.Collectors change and both apps pick it up identically.

Style checks against CONTRIBUTING.md: AS on every new table alias, column_name = expression aliasing, aligned AND, the SELECT 1/0 EXISTS idiom (consistent with ~40 other uses across the codebase, e.g. the upgrades/* scripts), explicit DROP TABLE IF EXISTS guards plus unconditional drops at the tail -- all match.

One thing I checked rather than flagged: none of the three new SELECT ... INTO statements carry OPTION(RECOMPILE), and CONTRIBUTING.md is explicit that a statement added to an existing batch needs its own hint. But this batch's existing precedent (the #pm_plan_correction_recs staging SELECT, and the shipping SELECT itself per #2759/#2760) already omits it on the same reasoning: static literal SQL with no runtime parameters, and since the batch references #temp tables that are dropped/recreated fresh every single call, SQL Server can't reuse a cached plan across executions anyway -- the object dependency forces an effective recompile every time. The new statements are consistent with that existing rationale, so I don't think this is a real gap, just flagging that I considered it.

No correctness, security, or performance regressions found. The 1/0 EXISTS pattern, temp-table lifecycle, and staging keys all check out against the shipping join.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant