Skip to content

Decompress each Query Store plan/text once in the by-ids fetch (~4.5x) (#2675) - #2676

Merged
erikdarlingdata merged 1 commit into
devfrom
perf/2675-qs-decompress-once
Aug 28, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
perf/2675-qs-decompress-once

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Addresses #2675 — the primary, measured performance fix.

What was slow

The by-ids plan/text fetch measured a plan's decompressed size with DATALENGTH and emitted the plan from the same inlined CTE. Because a CTE inlines rather than materialises, CONVERT(nvarchar(max), qsp.query_plan) — the decompression, which sys.query_store_plan performs on every access — re-ran for each of its three references (the byte measure, the running-total SUM, and the passthrough). Every plan was decompressed ~3×.

Measured (local SQL Server 2025 rig, 1,500 plans × ~68 KB plan XML, warm CPU)

form CPU
current inline CTE 2,440 ms
decompress once (this PR) ~540 ms

~4.5× on the single most expensive operation in the heaviest collector. The exact sp_executesql-wrapped form was verified end-to-end on the rig (3 ids → 3 rows, correct columns, no error).

The change

Both by-ids fetches now materialise the payload to a #temp once (SET NOCOUNT ON so the SELECT INTO returns no result set to the reader), then run the unchanged byte-budget cut over the stored column. DATALENGTH over the temp reads a stored LOB length — no further decompression. Query text got the same shape (not compressed, but the repeated nvarchar(max) reads scale with real text size, where it's the larger of the two phases in the field).

No behaviour change: same missing-only set, same watermark, same byte budget, same rows, same order. All existing SQL-pinning assertions hold except one alias (c→p); added pins assert the decompression CONVERT appears exactly once and the fetch materialises to #temp, so a revert to the inline form fails the build.

Still open in #2675 (follow-ups)

  • Count-based cut for guaranteed forward progress (so a per-cycle cap can't become a permanent throttle).
  • Drop the residual sys.query_store_query_text join from the main runtime-stats query when text is fetched separately.

🤖 Generated with Claude Code

The by-ids plan and text fetches measured the decompressed size with
DATALENGTH and emitted the payload from the same inlined CTE. Because a
CTE inlines rather than materialises, CONVERT(nvarchar(max), query_plan)
-- the decompression, which sys.query_store_plan performs on every access
-- re-ran for each of the three references (byte measure, running total,
passthrough), decompressing every plan ~3x.

Measured on a SQL Server 2025 rig, 1,500 plans x ~68 KB plan XML:
2,440 ms CPU the old way vs ~540 ms decompressing once -- ~4.5x, on the
single most expensive operation in the heaviest collector. The wrapped
sp_executesql form was verified end to end on the rig (3 ids -> 3 rows,
correct columns, no error).

Both fetches now materialise the payload to a #temp once (SET NOCOUNT ON
so the SELECT INTO yields no result set to the reader), then run the
UNCHANGED byte-budget cut over the stored column. Query text got the same
shape -- not compressed, but the repeated nvarchar(max) reads scale with
real text size, where it is the larger of the two phases in the field.
No behaviour change: same missing-only set, watermark, budget, rows, order.

Tests updated: the alias moved c -> p, plus pins that the decompression
CONVERT appears exactly once and the fetch materialises to #temp, so a
revert to the inline triple-reference form fails the build.

Addresses #2675 (primary perf fix; the count-based cut for forward
progress and dropping the residual runtime-query text join remain in the
issue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
query_plan_text = CONVERT(nvarchar(max), qsp.query_plan)
INTO #plan_fetch
FROM sys.query_store_plan AS qsp
WHERE qsp.plan_id IN ({idList});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new staging statement (SELECT ... INTO #plan_fetch ...) is missing OPTION(RECOMPILE). Only the second SELECT in the batch (line ~1257) carries the hint.

Per CONTRIBUTING.md: "OPTION(RECOMPILE) on collector queries... A statement added to an existing batch needs its own hint — one on a neighbouring statement does not cover it." This exact scenario (splitting a batch into a staging SELECT INTO + final SELECT) was already caught in review once in this same file — see the comment at line ~904: "BOTH statements carry OPTION(RECOMPILE) (review catch): split out on its own, the staging statement would otherwise be cached via sp_executesql's parameterized text and sniffed across live vs backfill windows of wildly different selectivity."

Suggest adding OPTION(RECOMPILE) to this SELECT INTO as well. Also worth noting neither the Darling nor Lite test suites check for more than one occurrence of "OPTION(RECOMPILE)", so this regression wasn't caught by the pinned tests.

FROM sys.query_store_query AS qsq
JOIN sys.query_store_query_text AS qst
ON qst.query_text_id = qsq.query_text_id
WHERE qsq.query_id IN ({idList});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the plan-fetch staging statement: this SELECT ... INTO #text_fetch ... lacks its own OPTION(RECOMPILE). Only the final SELECT (line ~1366) has the hint. Per CONTRIBUTING.md, each statement in a collector batch needs its own hint — see the same finding on the plan-fetch method above.

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed the decompress-once change. The approach is sound (materializing the decompressed plan/text to a #temp before the byte-budget cut is the right fix for the CTE re-evaluation problem), and the query shape, aliasing, and style all match CONTRIBUTING.md conventions. Since PerformanceMonitor.Collectors is a shared project referenced by both Lite and Darling, there's no Lite/Darling parity drift here — the fix applies to both automatically. No SQL injection concerns; idList/budget are host-computed longs formatted with InvariantCulture, consistent with the rest of the file.

One issue found, posted as inline comments: both new staging SELECT ... INTO #plan_fetch/#text_fetch statements are missing their own OPTION(RECOMPILE). Only the final SELECT in each batch has the hint. CONTRIBUTING.md is explicit that "a statement added to an existing batch needs its own hint — one on a neighbouring statement does not cover it," and this exact pattern (staging SELECT INTO + final SELECT) was already caught in review once before in this same file (see the comment near line 904 in QueryStoreCollector.cs, on BuildQuery). Neither the Darling nor Lite test suites check for more than one occurrence of the OPTION(RECOMPILE) string, so the regression slipped through the pinned tests.

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