Skip to content

Collect the real Query Store interval identity and use it (#1841 tier 2) - #1853

Merged
erikdarlingdata merged 2 commits into
devfrom
feature/1841-qs-interval-identity
Jul 30, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
feature/1841-qs-interval-identity

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Tier 2 of #1841. Fixes #1841 deliberately NOT used — the CAGG half is spun out to #1849.

What was missing

Query Store rows are CUMULATIVE per-interval snapshots and the collector re-fetches the OPEN interval every cycle, so every aggregate has to collapse an interval to its latest snapshot before summing. Tier 1 (#1845) did that on the first_execution_time proxy, because the schema exposed no interval key — and no interval clock, so bars were keyed on when an interval was last COLLECTED. Both now exist.

Collecting the identity

BuildPayloadBody — the ONE body both execution shapes run — gains two columns:

runtime_stats_interval_id = qsrs.runtime_stats_interval_id,
interval_start_time_utc = CONVERT(datetime2, qsrsi.start_time AT TIME ZONE 'UTC')

LEFT JOIN sys.query_store_runtime_stats_interval, not JOIN, for exactly the reason the replica join beside it is a LEFT JOIN: an interval row that fails to resolve must cost this collector one column, not every runtime-stats row for that database.

Neither column is version-gated, unlike plan_type_desc / the log-bytes family / replica_role around them. Both are original Query Store surface — verified against SQL Server 2016 SP3 (13.0.6300.2), the collector's own AppliesTo floor, along with AT TIME ZONE 'UTC' itself. A gate would be dead weight and would deny the identity to the oldest supported servers.

Lite schema v49, Darling store V41. Both additive, nullable, appended (positional writers), not backfilled — nothing can reconstruct the identity for rows already stored. The Darling viewer gets the matching probe sentinel and map arm, which is the trap that would otherwise lock the viewer out of a fully-migrated store.

The premise tier 1 was blocked on was false

Tier 1 recorded that placing work at the interval's own clock would trade a magnitude bug for a timezone bug, "because first_execution_time is the monitored server's LOCAL wall clock while the axis is UTC."

It is not. Both sys.query_store_runtime_stats_interval.start_time and first_execution_time are datetimeoffset, and ReadRowsAsync already normalized them through DateTimeOffset.UtcDateTime before storing. Verified live on SQL 2022:

runtime_stats_interval_id  start_raw               first_execution_time
3393                       2026-07-30T20:00:00Z    2026-07-30T20:04:19.7170000Z

Z, on a host sitting at UTC-4. The interval clock always shared an axis with collection_time. The honest fix was available the moment the identity was collected.

What changed in the reads

Dedup key — runtime_stats_interval_id joins the partition at all 13 tier-1 sites, plus the 4 analysis-layer dedups that predate tier 1. The proxy stays beside it rather than being COALESCEd into one expression: the id is NULL on exactly the pre-tier-2 generation and non-NULL on exactly the post-tier-2 one, so the two can never collide, and a legacy-only window degrades to precisely tier 1's key. Where the id exists it also fixes what the proxy could not — a row whose first_execution_time is NULL had no identity at all under tier 1 and collapsed with every other such interval of the same plan, which UNDER-counts.

Slicer placement — bars bucket on COALESCE(interval_start_time_utc, collection_time). An interval is drawn in the hour it RAN, not the hour it was last fetched, which on the default 60-minute interval was reliably one bar late.

The duration trend — tier 1's exclusion is deliberately reversed. Two arms, split on interval_start_time_utc IS NULL:

  • rows with the identity: deduped to the interval's final snapshot, placed at its start;
  • rows without: un-deduped at collection_time, byte-for-byte the pre-tier-2 behavior.

The split partitions the rows with no overlap and no gap, so a mixed window counts every row exactly once. A store holding pre-upgrade history does not lie, and the mixture resolves itself as those rows age out of retention. The series resolution becomes Query Store's own interval length, which is the honest resolution of this source.

Both apps, everywhere: Lite's slicer / top queries / comparison / trend, the Viewer's equivalents plus regressions and the slicer overlay, the MCP + REST reader, and the Custom Views raw route.

CAGG verdict: assessed, designed, filed (#1849)

Not a blind rebuild, and not a hand-wave. Five candidate CAGG shapes were built against live PostgreSQL 18.4 + TimescaleDB 2.28.1:

Probe Result
time_bucket('1 hour', interval_start_time_utc) REJECTED — time bucket function must reference the primary hypertable dimension column
last(execution_count, collection_time) grouped by interval identity ACCEPTED
window function inside a CAGG REJECTED (hint names an experimental GUC)
hierarchical re-dedup with last(x, bucket) ACCEPTED
hierarchical collapse to composer dims ACCEPTED

Probe 1 is the constraint everything hangs on: a CAGG on query_store_stats cannot bucket on the interval start; it must bucket on collection_time. So a corrected rollup needs a NEW interval-grain L1 plus a hierarchical L2 — buildable, but it necessarily moves routing (ComposeCaggCatalog, RollupViews, the coverage probe) and retention arming (RawTierCoverage gates the raw purge on the OLD view's min(bucket); move reads without moving the gate and raw can be purged over history the new rollup has not materialized). The existing CAGGs must also be kept, per the #1759/#1793 precedent and this repo's own comment that a rebuild "would permanently destroy the 21-day hourly and indefinite daily history."

That is a staged migration, so it is filed with the complete design, the probe evidence, the capacity caveat, and four rejected alternatives with reasons — rather than half-built here. Deliverables 1-3 stand on their own: every row collected from now on is dedupable by real identity, so the data #1849 needs is already accumulating.

Also filed: #1850 — four analysis-layer dedups carry a narrower key than the read side and silently drop a replica's rows on an AG primary with Query Store for secondary replicas. Fixing it is not mechanical (drop / blend / split is a product call on the fact row shape), so it is written up with all three options rather than guessed at.

Testing

Watched red first, against both real stores:

What Red Green
tier-1 under-count with NULL first_execution_time 5 executions 11
duration trend, 2 intervals collected 4x 4 points 2
slicer one-bucket lag (DuckDB) bar at 18:00 17:00
slicer one-bucket lag (live Postgres) bar at 16:00 15:00
dedup-CTE source guard 0 partitions 5

The live PG test is new coverage: it makes collection time and interval time genuinely disagree, then adds a legacy row to the same window and asserts nothing already counted moves. Gating verified honestly — it SKIPS without DARLING_TEST_PG and PASSES with it, so the store really was exercised.

The #1845 decision-pin tests were read, then deliberately re-pinned to the new behavior including the legacy boundary — both arms present, IS NULL / IS NOT NULL each appearing exactly once so the split cannot silently stop being total.

Suites, against a live PG 18.4 + TimescaleDB 2.28.1 cluster on a dedicated port:

  • Lite 1667 passed / 0 failed
  • Darling 3785 passed / 0 failed (9 skipped: gated tests needing a live SQL Server or the bundled runtime)
  • Full-solution -t:Rebuild: 0 warnings, 0 errors

Installer.Tests deliberately not run.

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits July 30, 2026 16:50
Query Store rows are cumulative per-interval snapshots and the collector
re-fetches the OPEN interval every cycle, so every aggregate has to collapse
an interval to its latest snapshot before summing. Tier 1 (#1845) did that on
the first_execution_time PROXY because the schema exposed no interval key and
no interval clock. Both now exist.

The collector selects runtime_stats_interval_id and interval_start_time_utc
(sys.query_store_runtime_stats_interval.start_time AT TIME ZONE 'UTC') from
the one shared payload body, LEFT JOINed for the same reason the replica join
is: an unresolved interval row must cost one column, not every runtime-stats
row for that database. Neither column is version-gated - both are original
Query Store surface, verified present on SQL Server 2016 SP3, the collector's
own AppliesTo floor. Lite schema v49, Darling store V41, both additive,
nullable, and appended so the positional writers stay aligned; the Darling
viewer gains the matching probe sentinel and map arm.

Every dedup keys on the real id, with the proxy kept beside it rather than
COALESCEd: the id is NULL on exactly the pre-tier-2 generation, so the two can
never collide and a legacy-only window degrades to precisely tier 1's key.
Where the id exists it also fixes what the proxy could not - a row whose
first_execution_time is NULL had no identity at all and collapsed with every
other such interval of the same plan, which under-counts. The four
analysis-layer dedups that predate tier 1 get the same key upgrade.

The slicer bars and the duration trend now place work at the interval's start.
The trend's tier-1 exclusion is deliberately reversed: its reasoning was sound
but rested on a false premise, that the interval clock is the monitored
server's LOCAL wall time. Query Store's interval start_time and
first_execution_time are both datetimeoffset - verified live on a server
reading +00:00 while sitting at UTC-4 - and the collector already normalized
through DateTimeOffset.UtcDateTime before storing, so the interval clock
always shared an axis with collection_time. Rows without the identity keep the
pre-tier-2 treatment exactly; the arms split on IS NULL / IS NOT NULL, which
partitions the rows with no overlap and no gap, so a mixed window counts every
row once and resolves itself as those rows age out.

The decision-pin tests from #1845 are re-pinned to the new behavior including
the legacy boundary. Watched red first against real DuckDB and a real
PostgreSQL 18.4 + TimescaleDB 2.28.1 store: the one-bucket lag (a bar at 16:00
for work that ran at 15:00), the un-deduped trend (4 points where 2 intervals
ran), and the tier-1 under-count (5 executions where 11 ran).

Filed rather than deferred silently: #1849 (the hourly/daily rollups still
bake in the inflation - carries the corrected-rollup design and the live
TimescaleDB probe results showing a CAGG cannot bucket on
interval_start_time_utc) and #1850 (the analysis dedups still drop a replica's
rows on an AG primary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It said 36 (the original 35 plus long_query_completions) while the equivalence
test has asserted 38 since the two Availability Group collector tables landed
in #991. Noticed while appending the #1841 tier 2 columns to the same file.

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

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review: Collect the real Query Store interval identity and use it (#1841 tier 2)

What it does: Adds runtime_stats_interval_id + interval_start_time_utc to the Query Store collector payload (both apps, non-version-gated, LEFT JOIN sys.query_store_runtime_stats_interval), threads the real interval identity into every dedup partition alongside the tier-1 first_execution_time proxy, and switches the slicer/duration-trend bucket placement from collection_time to COALESCE(interval_start_time_utc, collection_time). Lite schema v49 / Darling store V41, both additive/nullable/appended. Extensively tested (including live Postgres + DuckDB red/green pairs).

General assessment

This is careful, well-reasoned work. Highlights that check out on inspection:

  • The dual-key dedup (runtime_stats_interval_id + first_execution_time, not COALESCEd) is correct: the id is NULL on exactly the pre-tier-2 generation and non-NULL on exactly the post-tier-2 one, so the two keys can never collide, and NULL-groups-with-NULL window semantics make the legacy fallback degrade to precisely the old key.
  • The LEFT JOIN (not JOIN) to sys.query_store_runtime_stats_interval is correct — matches the existing replica-join precedent, costs one column on a miss rather than the whole row.
  • Migration/versioning is consistent between Lite (DuckDbInitializer v49) and Darling (PgMigrations V41): same two nullable columns, same append-only positional-writer discipline, matching GoldenCollectorSchema.cs oracle update and MapProbedSchemaVersion sentinel gate.
  • Ordinal bookkeeping in QueryStoreCollector.cs (55 selected / 56 stored columns) is internally consistent across BuildPayloadBody, ReadRowsAsync, PayloadColumns, and WritePayload.
  • Lite/Darling parity looks intact for every site the description claims: slicer, top queries, comparison, duration trend, drilldown dedup, Custom Views raw route (ComposeCompiler), and the MCP/REST reader all got matching updates on both sides. I did not find a leftover PARTITION BY ... first_execution_time site missing the new id (outside deprecated/Dashboard, which is the retired Full edition and out of scope).

Correctness edge case (see inline comment)

The slicer and duration-trend queries still filter their CTE on collection_time BETWEEN $2 AND $3 (i.e., the hoursBack/fromDate..toDate window), but now bucket/place rows on interval_start_time_utc once the identity exists. Before this PR that was safe because bucket == collection_time == the filtered column. Now a row whose closing collection lands just inside the window, but whose interval start was earlier than the window's nominal start (bounded by one Query Store interval length, default 60 min), will be placed in a bucket dated before startTime. That's a plausible, narrow "one bar before the left edge of the chart" artifact on both apps (Lite's LocalDataService.QueryStore.cs and Darling's ViewerDataService.QueryStore.cs/QueryTrends.cs share the identical construction, so it's not a parity gap — just a shared edge case). Flagged inline on the Lite slicer for concreteness.

Minor / non-blocking

Seven test files (Darling/Darling.Tests/DarlingComposeTests.cs, DarlingMcpDataToolsTests.cs, DarlingObservabilityTests.cs, ViewerDataServiceTests.cs, ViewerDrillDownTests.cs, ViewerQueriesTests.cs, and Lite.Tests/QueryStoreCollectorDefinitionTests.cs) picked up a stray UTF-8 BOM at the very start of the file (visible in the diff as -/* / +<BOM>/*). Looks like an editor/tool artifact rather than an intentional change — no other file in the repo carries a BOM. Worth stripping before merge to keep encoding consistent and avoid pure-noise diff lines.

Nothing here blocks the PR from my read — the core correctness fix (interval identity dedup + placement) is sound and thoroughly tested; the window-edge behavior is a pre-existing class of issue this PR's own approach introduces alongside its fix, worth a follow-up rather than a blocker.

-- so this is a placement fix and not a timezone trade. Legacy rows have no interval start and keep
-- collection_time placement, so a window spanning the upgrade renders correctly on both sides and the
-- lag simply disappears as the pre-upgrade rows age out.
date_trunc('hour', COALESCE(interval_start_time_utc, collection_time)) AS bucket,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Edge case: the CTE above filters on collection_time >= $2 AND collection_time <= $3 (the hoursBack/fromDate..toDate window), but this bucket now keys on interval_start_time_utc when it exists. Those aren't the same instant any more — interval_start_time_utc is the interval's start, while the row that survives the window filter is picked by its closing collection_time, which for a 60-minute interval lands up to ~1 interval-length later.

Concretely: an interval that started at startTime - 30min and closed with its final collection at startTime + 2min passes the collection_time >= $2 filter (it's inside the window), but its bucket is date_trunc('hour', startTime - 30min) — dated before the nominal window start. Pre-PR this couldn't happen because bucket == collection_time == the filtered column, so every bucket was guaranteed inside [startTime, endTime]. Now a "last N hours" chart can show one extra bar left of the requested range (bounded by one Query Store interval length, default 60 min).

Not sure this needs fixing before merge — it's a narrow, cosmetic edge case rather than a data-correctness bug (the totals in that bucket are still accurate) — but worth confirming it's intentional, since it wasn't covered by the new live-Postgres test (that test's seeded interval start (h0) is never earlier than the queried start (h0.AddMinutes(-1)), so it doesn't exercise this boundary). Darling's ViewerDataService.QueryStore.cs/QueryTrends.cs have the identical construction, so this would apply to both apps equally.

@erikdarlingdata
erikdarlingdata merged commit 8fb82ea into dev Jul 30, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/1841-qs-interval-identity branch July 30, 2026 21:02
erikdarlingdata added a commit that referenced this pull request Jul 31, 2026
…in the CAGG chain (#1849)

The hourly/daily Query Store rollups materialized sum(execution_count) from
un-deduped raw rows, so one interval's work was baked in once per COLLECTION.
Tier 2 (#1853) fixed every raw-tier read and could not reach these: the
duplicates are gone once materialized and the rollup output carries no interval
identity to key on. A Custom Views panel inside the raw tier read corrected
numbers; past it, inflated ones, with a visible step at the routing boundary.

Three NEW aggregates carry the corrected numbers, alongside the old pair rather
than replacing it (a CAGG cannot be reshaped in place, and rebuilding would
destroy 21 days of hourly and all daily history - #1759/#1793):

  query_store_stats_interval_hourly   L1, interval grain, last(x, collection_time)
  query_store_stats_corrected_hourly  composer grain, identity-width off L1
  query_store_stats_corrected_daily   composer grain, 1-day bucket off L1

Column names are identical to the old pair, so ComposeCaggValueMapper and every
composed panel read them unchanged. Measured on a seeded store: one interval
re-collected 496 times reports 506 where the old rollup reports 123,311.

The shape is forced by what TimescaleDB accepts, live-probed on PG 18.4 /
TimescaleDB 2.28.1. An identity-width hierarchical CAGG is a LEAF - nothing can
be built on a child whose bucket equals its parent's width, though a plain
1h -> 1d -> 7d chain is fine - so the corrected daily is a SIBLING of the
corrected hourly sourced from L1, where every other daily here is built on its
hourly. That constraint is not in #1849; it was found building this.

Arming: query_store_stats now has two rollup families, so RawTierCoverage's
coverage became a LIST and the #1680 gate an AND over all of them. Raw stays
paused until both reach back over everything it holds. Evaluated in the reader,
not folded into GREATEST, which skips NULLs and would let an empty new rollup
vanish from the comparison.

Routing: ComposeCaggInfo gains the superseded pair. Corrected wins wherever its
coverage reaches; beyond it reads fall back to the old pair, comparatively (only
where it measurably reaches further back), so a backfilled store never reads
inflated. Availability is settled before coverage - a store whose service
predates this routes to the old pair exactly as before, which is why this needs
no migration and no viewer version gate.

Backfill: RollupViews now carries BucketWidth explicitly. "Hierarchical" and
"daily" used to be the same fact and the backfill inferred grain from the source
column; the corrected hourly is the first hierarchical rollup that is not a
daily, and inferring would have given it a 24x-too-wide bucket - an
under-estimate, the one direction the disk preflight exists to prevent.

Capacity settled before shipping (#1581): the interval layer is near-raw
cardinality, so it takes a short 7-day horizon rather than the 21-day one -
79 MB against 238 MB projected on a 600-query store at the default cadence.

Verified against live PostgreSQL 18.4 + TimescaleDB 2.28.1, watched red first:
removing L1 from the arming gate arms a purge over history nothing holds;
last() -> sum() in L1 reports 123,256; dropping or inverting the legacy fallback
each fails a named routing test. Darling 3848 passed / 0 failed, Lite 1728 / 0,
full-solution rebuild 0 warnings.

Filed rather than guessed: #1869, the daily tier's ~2x hour-straddle residual
and the fourth near-raw-cardinality aggregate that would remove it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Jul 31, 2026
From the review bot's inline comment on #1919, taking its first option
rather than the comment-softening one: softening would have been a
Lite/Darling parity scope-down.

QueryStoreDedupReadTests.EveryQueryStoreAggregateInTheFile_CarriesADedupCte
reads exactly one file, LocalDataService.QueryStore.cs, which is right
for what it checks but left 2 of Lite's 7 dedup sites -- the ones in
Analysis/DrillDownCollector.Queries.cs and
Analysis/DuckDbFactCollector.QueryPerf.cs, both of which this same PR
added tie-breaks to -- covered by no test at all. Dropping
execution_count DESC from either would have gone uncaught, which is the
recurrence mode #1841/#1845/#1853/#1907 keep demonstrating, and Darling
was already guarded against it.

Lite.Tests/QueryStoreSliceTieBreakSourceTests mirrors Darling's
counterpart exactly: an enumerated DedupSites list (3 files, 7 sites)
rather than a glob, so MOVING a read has to be re-declared instead of
silently shrinking coverage, and a missing tie-break fails naming the
file. It also carries a parity check that reads Darling's guard and
asserts its declared total, so neither app can quietly fall behind the
other.

The source comments in both apps claimed "a source-containment test pins
all of them" -- true only on the Darling side. Both now name the actual
tests and their real counts.

Watched red: dropping the tie-break from DuckDbFactCollector.QueryPerf.cs
leaves the OLD guard green (proving the gap was real) while the new guard
fails with "Lite\Analysis\DuckDbFactCollector.QueryPerf.cs @ char 9829".

Lite 1903 passed / 0 failed, build 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ianwalkeruk pushed a commit to ianwalkeruk/PerformanceMonitor that referenced this pull request Jul 31, 2026
…tion

sys.query_store_runtime_stats returns the flushed slice and the
still-in-memory slice of one runtime_stats_interval_id as two separate
rows, and they are ADDITIVE members of one interval rather than
competing snapshots of it. The collector selected straight from the
view, so both were stored -- and they then shared every column of the
read-side dedup key (erikdarlingdata#1841/erikdarlingdata#1845/erikdarlingdata#1853) AND collection_time, so the
viewer's ROW_NUMBER and the rollups' last(execution_count,
collection_time) were both ordering by a value identical for both rows.
The survivor was whichever the engine emitted first: 8 executions shown
where 94 was true, differently on each run.

Reproduces on box SQL Server 2022 (16.0.4255.1), not just Azure: 100
flushed + 25 in memory came back as two rows while
dm_exec_procedure_stats reported 125 at the same instant.

BuildPayloadBody -- the one body both execution shapes run -- now groups
on the natural key of the view (plan_id, runtime_stats_interval_id,
execution_type, replica group). execution_count SUMs; every avg_* takes
the count-weighted mean, because Query Store stores an average and a
count but never a total; min_*/max_* take the extreme; first/last
execution time the interval's span. The emitted row shape is unchanged
-- same 55 columns, same order, no migration -- only the row count per
interval.

The cutoff moves from a per-slice WHERE to HAVING MAX(...) at interval
grain, which is load-bearing: the flushed slice is static, so a
per-slice predicate stops matching it once the growing in-memory slice
advances the watermark, and the sum degrades to the sliver alone. The
IN (...) pre-filter is a prune only, and the fixed query is faster than
the one it replaces (375ms vs 453ms on a real 212k-row Query Store) --
half the rows means half the plan XML to materialize.

The deprecated Dashboard's collect.query_store_collector had the same
defect against the same view and takes the same fix.

Rows already collected cannot be repaired: all 19 read-side dedup sites
in both apps gain a documented execution_count tie-break so a pre-fix
tie resolves to the flushed slice deterministically instead of flapping.
Closest-available, not correct -- residual tracked in erikdarlingdata#1912.

Verified live on SQL Server 2022 (emitted SQL returns 125/70 against raw
slices of {100,25}/{60,10}, matching dm_exec_procedure_stats) and on
PostgreSQL 18 + TimescaleDB 2.28.1 (corrected rollups report the
hand-computed 155 and the exact weighted mean; the same store fed split
slices can never reach the interval's true 125).

Lite 1854 passed / 0 failed, Darling 3997 passed / 0 failed, full
solution rebuild 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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