Skip to content

Query Store reads get a wide interval table (#3953) - #4341

Merged
erikdarlingdata merged 25 commits into
devfrom
feature/3953-wide-interval-table
Sep 26, 2026
Merged

erikdarlingdata merged 25 commits into
devfrom
feature/3953-wide-interval-table

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #3953.

Lane B1 builds the write side of the wide Query Store interval table: the table, its writer, its own coverage claim, its purge and the schema gate. Lane B2 adds the three reads on this same branch next, then one review round covers both.

Why

Review D4R found the single-widened-table shape (A) broke PLAN_REGRESSION and the slicer (H1, H2, H4). F1's follow-up measurement confirmed the split shape (c): leave V143's query_store_interval_latest (Regular-only, 17 columns) untouched, and add a second, separate table that holds every outcome (Regular, Aborted, Exception) with the 57 columns the three new reads need. Erik ruled on F1's result (issuecomment-5836972848): build (c), keep the wide table 9 days, and split the build into two lanes on one branch.

What changes

  1. Migration V145 (collect.query_store_interval_wide + its own _coverage and _pending tables), column-for-column the same shape as V143's three tables so a later gate can parametrize V143's own decision on the table rather than duplicate it. Every raw column except collection_id, server_name, query_plan_text (57 columns, F1's shape (c)). first_execution_time NOT NULL (M2, ruled). Identity is V143's identity plus execution_type_desc. fillfactor = 50, no secondary index (F1 measured a window index taking HOT updates to 0%). V143's three tables are untouched — pinned by a static rung test that also asserts none of V143's object names appear in the new migration's SQL.
  2. The writer (QueryStoreIntervalWide.cs, a new class mirroring QueryStoreIntervalLatest.cs's write side): upserts the batch into the wide table inside raw's own COPY transaction, beside V143's upsert, under its own savepoint (qsiw_apply, distinct from V143's qsil_apply). A fault here rolls back only to its own savepoint — V143's already-applied rows and claim in the same transaction are untouched — and records a wide-only pending row so raw and V143 still commit. Keeps the IS NOT NULL safeguard and coverage reset (M2).
  3. The claim: starts at the first batch this build writes (never backdated — see the open question below), applied_through advances exactly as V143's does, and the hourly gap check runs against the wide table's own coverage/pending tables, independent of V143's.
  4. The purge: a new QueryStoreIntervalWideRetentionDays = 9 constant beside QueryStoreIntervalLatestRetentionDays, purging on first_execution_time through TimeSlicedDeleteSql, in DarlingRetention.PurgeAsync beside V143's purge block. The table floor the (future) read gate checks (MIN(first_execution_time)) advances automatically as rows are purged.
  5. Schema gate: StorageVersion.SchemaVersion 144 -> 145, a new sentinel in StoreSchemaProbeSql (the wide pending table's existence, the same "creates three tables, sentinel on the one created last" convention as V143's own arm), a new hasQueryStoreIntervalWide parameter and top-rung arm in MapProbedSchemaVersion, the matching reader.GetBoolean(120) call. No GRANT needed: the wide table sits in the collect schema and picks up the same blanket GRANT ... ON ALL TABLES IN SCHEMA collect V143's tables already rely on.

I also had to demote QueryStoreIntervalLatestRungTests' "I am the top rung" assertions — the same transformation that file's own history shows it applying to V142's rung file (IndexObjectStatsServerTimeIndexRungTests) when V143 landed. Those claims now live in the new QueryStoreIntervalWideRungTests.

Deliberately not built (B2's per the ruling): the three reads, the read-source gate/routing, and the CHANGELOG entry.

Lite parity: Lite has no store-side interval tables (no Postgres store, no Query Store interval dedup problem to solve) — nothing to mirror there.

Open question (#4334)

Ruling item 6 asks: could the first start seed the table from raw and claim coverage back to raw's floor, instead of starting the claim at install? Two parts:

The reason the code gives for never backdating. EnsureCoverageSql's own doc comment (mirrored from V143's, unchanged by this PR): filled_since is "the clock raised to the server's newest raw collection_time ... a backdated backfill slice's time is never used." The claim's whole reliability model is that everything at or after filled_since went through the real per-batch apply path — the running-max guard, and the pending/replay failure isolation that lets one batch's fault be recorded and retried without blocking raw. A retroactive bulk seed runs once, outside that per-batch savepoint machinery, so a partial failure mid-seed (a lock timeout scanning a large historical raw slice, a crash) has no bookkeeping to mark what the seed didn't actually capture — exactly the silent-overstatement failure the claim exists to prevent.

Would a seed be exact for intervals wholly inside raw's horizon? Data-wise, yes. A one-time SELECT DISTINCT ON (identity) ... ORDER BY identity, collection_time DESC, execution_count DESC seed (the same tie-break the upsert itself uses, and the same shape my writer tests' oracle uses) reconstructs exactly the rows incremental application converges to, for any interval whose newest surviving raw row is genuinely its latest snapshot — which is always true, since retention purges oldest rows first and never removes the newest one. So exactness is not the blocker; the missing failure-isolation bookkeeping for a one-time bulk operation is. Until that's built, a new install reads raw for these windows until the table's claim catches up, the same as PLAN_REGRESSION does after #4208.

Test plan

Live tests against a rig on port 55978 (DARLING_TEST_PG), each its own ScratchPostgres database (#1776 own-store):

  • QueryStoreIntervalWideRungTests: the migration is registered densely at the top of the ladder, creates exactly 3 tables + 1 unique index (engine-plain, no ALTER/DROP/timescaledb, touches none of V143's object names), and the viewer probe/map treat V145 as the top rung.
  • QueryStoreIntervalWideWriterTests.TheWideTableEqualsTheRawDedup_EveryOutcome_AndReapplyingChangesNothing: one batch lands in raw, V143 and the wide table in one transaction; the wide table's rows equal raw's own dedup (EXCEPT ALL both ways, all 57 columns), including Aborted/Exception rows V143 never stores; migration re-apply and stale-batch re-write are both no-ops.
  • AWideOnlyApplyFault_RecordsAWidePendingRow_AndLeavesV143Intact: dropping only the wide table's unique index makes only the wide apply fault; V143's own row and claim are unaffected; a wide pending row is recorded and later replayed.
  • ABatchAppliedOnlyToV143_IsCaughtByTheWideGapCheck: a raw batch applied only through V143's own real apply path (simulating an old writer that never knew about the wide table) is caught by the wide table's hourly gap check on the next real-path batch; V143's own gap check finds nothing wrong (it already applied that row itself).
  • Retention_DropsWideIntervalsPastNineDays_AndStalePendingRows_AndKeepsTheRest: the purge drops rows past 9 days, keeps a 2-day-old row, drops a stale pending row and keeps a fresh one, and the table floor moves to the kept row.
  • Proved by hand that the gap-check test fails without the fix: temporarily disabling QueryStoreIntervalWide's gap-check call and re-running ABatchAppliedOnlyToV143_IsCaughtByTheWideGapCheck alone produced a [FAIL]; restoring the call and re-running the full targeted set passed all 7.
  • All 7 tests above ran clean together (Total: 7, Errors: 0, Failed: 0) immediately before merging origin/dev.
  • Merged origin/dev (35e2d06, "Derive each raw hypertable's chunk interval from ingest Design: size raw chunk interval, compress_after and the hourly CAGG refresh window from ingest rate; a large store keeps 1–2 days of raw uncompressed and it outgrows RAM #4211") — clean merge, no conflicts, no migration-number collision (dev's newest was still 143).
  • Closed by B4b: the top-rung demotion fix (QueryStoreIntervalLatestRungTests handing "I am the top rung" to QueryStoreIntervalWideRungTests) is verified — both classes pass, both alone and inside the full suite below.
  • Closed by B4b: the full Darling.Tests suite ran once clean on a freshly created database. See ## B4b below for the total and the one failure's disposition.
  • Closed by B4b: StorageCommandTimeoutTests, LivePostgresCollectionHygieneTests, LiveCleanupConversionRatchetTests, DocCommentHygieneTests all pass as their own targeted run (240 tests total alongside QueryStoreIntervalLatestRungTests, RawChunkIntervalReconcilerLiveTests, ViewerSchemaVersionGateTests, ViewerQueriesLivePostgresTests and McpPayloadContractCensusTests, 0 failed).

Rig state: the PostgreSQL rig at C:\GitHub\worktrees\rig-d4 (port 55978) is very likely still running — I attempted to stop it as my last action but the command was blocked by my own context limit before I could confirm it executed. Per the brief, B2 reuses this directory; please confirm the server's actual state before reusing or stopping it, and check for a stray Darling.Tests.exe process (PID 9060 as of this writeup) that may still be holding a lock on the build output in this worktree.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

B2 (gate and grid read)

Rung fix check. B1's last commit was already green: *QueryStoreIntervalLatestRungTests* (4/4) and
*QueryStoreIntervalWideRungTests* (3/4 -> 3, all passed) both passed before I touched anything, so no fix
commit was needed there.

The gate — Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs:

  • QueryStoreIntervalWide.UseTable(filledSince, hasPending, rawFloor, windowStart, windowEnd, literalWindowEnd, appliedThrough, tableFloor, minWindow) — pure, mirrors V143's QueryStoreIntervalLatest.UseTable for clauses 1-3, adds clause 4 (a non-null literalWindowEnd must be >= appliedThrough; null skips the clause) and clause 5 (windowEnd - windowStart >= minWindow). Clause 6 (schema version >= 144) is the caller's, since the pure function has no connection.
  • QueryStoreIntervalWide.ReadsTableAsync(connection, serverId, windowStart, windowEnd, literalWindowEnd, minWindow, commandTimeoutSeconds, logger, cancellationToken) returns (bool UseTable, DateTime ClampedStart) — runs on a connection the caller already opened (M1: one read-only REPEATABLE READ transaction covers the gate and the read it authorizes). ClampedStart is max(windowStart, rawFloor) (H3's clamp), computed from the SAME rawFloor the decision read, so a caller's table read can't see a different snapshot than the gate did.
  • New SQL: ReadSourceInputsSql (adds applied_through to V143's shape), ChunkFloorsSql/PlainTableFloorSql (same shape as V143's, retargeted at query_store_interval_wide), and two new constants: GridWideMinWindow = 12h and IntervalSpanMargin = 1 day (restates PgFactCollector.QueryPerf.PlanRegressionSkewMarginDays since the viewer doesn't reference the service assembly).

The grid read — Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs:

  • QueryStoreTopSql is now QueryStoreTopRawPrefix + QueryStoreTopSuffix — the prefix is the untouched deduped CTE, the suffix is everything from ranked down, split exactly where PgFactCollector.QueryPerf's PlanRegressionSql/PlanRegressionTableSql split. QueryStoreTopTableSql = QueryStoreTopTablePrefix + QueryStoreTopSuffix reads query_store_interval_wide directly (already deduped by the writer's upsert, so rn is a literal 1) with a nullable end ($3::timestamp IS NULL OR collection_time <= $3 — open when null).
  • GetQueryStoreTopQueriesAsync gained one new optional parameter, DateTime? literalEndUtc = null. When the store is at schema 144+, it opens its own connection + REPEATABLE READ transaction, asks the gate, and on "yes" runs QueryStoreTopTableSql on that same connection/transaction with $2 = ClampedStart and $3 = literalEndUtc; a "no" (or any fault, or schema < 144) falls through unchanged to the existing raw path. Row-reading is now one shared ReadQueryStoreTopRow helper used by both paths (verified same 53-column order, since both share the same suffix projection).
  • ViewerServerTab.Queries.cs: the two call sites now pass literalEndUtc. The toolbar-driven load passes IsCustomRange ? endUtc : null (a preset's endUtc is GetWindowUtc()'s own DateTime.UtcNow — the viewer's clock, not the store's — so treating it as literal would send a slow-clocked viewer to raw on every read, M1). The slicer-driven reload always passes e.EndUtc (a user-drawn sub-range is always literal). GetWindowUtc() itself is untouched — it still returns a concrete endUtc in both cases, which still binds raw's own $3 exactly as before.
  • QueryStoreComparisonSql and QueryStoreSlicerSql are untouched, as scoped.

Tests run (DARLING_TEST_PG=Host=127.0.0.1;Port=55978;..., darlingtest):

  • Build: Darling/Darling.Tests/Darling.Tests.csproj, 0 Warning(s), 0 Error(s).
  • New Darling/Darling.Tests/QueryStoreIntervalWideGateTests.cs — 13 unit tests on UseTable/ClampedStart, each clause alone flipping the answer, plus the two boundary cases (clause 3 at the exact one-day margin, clause 5 at the exact 12-hour minimum). All 13 pass.
  • ViewerQueriesSqlTests, ViewerQueriesDisplayTests, ViewerQueryStoreRegressionsTests, QueryStoreTopWindowTests, DatabaseFilterTests, QueryStoreSliceTieBreakSourceTests (Darling): all pass — in particular QueryStoreSliceTieBreakSourceTests still counts exactly 4 dedup sites in ViewerDataService.QueryStore.cs (the new table prefix has no PARTITION BY/ORDER BY collection_time DESC of its own, since the table is already deduped at write time, so it correctly does not add a 5th).
  • ViewerSchemaVersionGateTests, StorageCommandTimeoutTests, DocCommentHygieneTests, QueryStoreIntervalWideRungTests, QueryStoreIntervalLatestRungTests: all pass (97/97 in that combined run, after one doc-comment fix below).
  • One self-caught defect, fixed in this same commit: my first ReadsTableAsync edit left an orphaned pre-refactor <summary> block stacked above ClampedStart's new one, caught by DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks. Deleted the orphan; re-ran green.
  • Not run — rig contention, not a code regression: ViewerQueriesLivePostgresTests (11/11 failed) and B1's own untouched QueryStoreIntervalWideWriterTests (4/4 failed) both failed with a raw Npgsql connection timeout inside fixture setup, before any query-specific code ran. Since QueryStoreIntervalWideWriterTests is a file I never touched and failed the same way, this points at the shared rig (port 55978, reused across B1/B2/B3) being under concurrent load, not at my changes. I did not re-verify on a fresh database given context budget — the coordinator or B4 should re-run both classes once the rig is quiet before trusting them either way.
  • Not written — live tests for the equality/clamp/gate scenarios the brief specced (seeded through DarlingCollectorRunner.WriteBackfillBatchAsync, EXCEPT ALL both ways against raw, for a preset and a literal end at applied_through, plus the clamp and pending/short-window/stale-end gate cases). I ran out of context budget before writing these. This is the most important gap for whoever picks this up next — PlanRegressionIntervalTableEquivalenceTests.cs is the pattern to mirror (V143's own version of exactly these tests).
  • *QueryStoreInterval* wildcard and LivePostgresCollectionHygieneTests/LiveCleanupConversionRatchetTests were not run separately given the above; the full suite is B4's per the brief.

For lane B3 (MCP and slicer reads, on this gate):

  • Call QueryStoreIntervalWide.ReadsTableAsync(connection, serverId, windowStart, windowEnd, literalWindowEnd, minWindow, commandTimeoutSeconds, logger, cancellationToken) on a connection you opened yourself inside a BeginTransactionAsync(System.Data.IsolationLevel.RepeatableRead, ...) transaction (read-only by convention, not enforced at the SQL level — see TryGetQueryStoreTopQueriesFromTableAsync in ViewerDataService.QueryStore.cs for the pattern). It returns (bool UseTable, DateTime ClampedStart); use ClampedStart as your table read's lower-bound parameter.
  • Gate clause 6 (schema >= 144) is NOT inside ReadsTableAsync — check GetStoreSchemaVersionAsync() >= 144 yourself first (see the same method for the pattern) before opening the gate's transaction at all.
  • literalWindowEnd: pass null for an open/preset end (skips clause 4), or the real bound for as_of/a slicer selection. The MCP read's DarlingDataReader.QueryStoreTopSql has its own separate SQL constant (not ViewerDataService's) — you'll need your own ...TableSql split there, same prefix/suffix pattern.
  • The slicer's clause 2 uses S - 1 hour per your brief — that's a DIFFERENT window-start convention than the grid's plain S; UseTable's windowStart parameter is just "whatever lower bound your read's own raw SQL uses," so pass your S - 1 hour value there directly, not the grid's convention.
  • You will likely want your own McpWideMinWindow/SlicerWideMinWindow constants (GridWideMinWindow is grid-only, 12h, per the ruling's "one constant per read").
  • QueryStoreIntervalWide.IntervalSpanMargin (1 day) and GridWideMinWindow are both public static readonly TimeSpan on QueryStoreIntervalWide — reuse IntervalSpanMargin, define your own min-window constants alongside it or in your own files.

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

B2c (live pass)

Ran the live pass B2 left undone, on the rig the coordinator restarted (55978).

What was red, and why. Nothing. *ViewerQueriesLivePostgresTests* (11) and *QueryStoreIntervalWideWriterTests* (4) both passed clean on the first run against a freshly recreated darlingtest, confirming the brief: B2's earlier timeout was the rig's postmaster failing to reserve shared memory (error 487), not a code defect.

Two fixes in TryGetQueryStoreTopQueriesFromTableAsync (Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs):

  • Added SET TRANSACTION READ ONLY as the transaction's first statement, matching the gate's own read intent.
  • Wrapped the whole method in a try/catch (Exception ex) when (ex is not OperationCanceledException) { return null; }, so a fault opening the connection, starting the transaction, running the gate, or reading the table falls back to raw instead of surfacing to the caller — the gate (QueryStoreIntervalWide.ReadsTableAsync) already did this for its own statements; the read did not. logger: null stays as-is (ViewerDataService has no logger field to pass).

New live tests: Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs, two [Fact]s, both green:

  • TheTableRead_EqualsRaw_OpenEndAndLiteralEndAtAppliedThrough_EndToEnd_AndTheLiveGate — QueryStoreTopTableSql at the gate's clamped start equals QueryStoreTopSql (EXCEPT ALL both ways, every returned column) for both an open end (preset) and a literal end exactly at applied_through; GetQueryStoreTopQueriesAsync called end to end returns the same (database, query, plan, outcome, replica role, total executions) keys as raw, including the open interval's final running-max execution count (20, after three re-fetches); then the live gate on a real connection: a sub-12-hour window, a literal end before applied_through, and a pending row each force UseTable = false, and the passing case restores to true.
  • Clamp_MatchesRawOnceRawsOldestChunkIsDropped_AndFailsWithoutTheClamp — converts query_store_stats to a real TimescaleDB hypertable (TimescaleSupport.TryEnableAsync + ConvertToHypertablesAsync), seeds across an anchor day and three window days, then drop_chunkss everything before window day 1. Proves the clamp is load-bearing: reading the table from the UNCLAMPED window start (raw's own bound) shows rows raw no longer has (unclampedTableOnly > 0); reading it from QueryStoreIntervalWide.ClampedStart's value instead is equal to raw again over the shrunken span both can still answer.

Key discovery while building the seed: WriteBackfillBatchAsync's coverage bookkeeping (EnsureCoverageSql) raises filled_since to the wall clock the write ran at, not to the seed's own historical span (its subquery only looks at collection_time within the last day of real time). A historical seed therefore needs its coverage row's filled_since forced back with a direct UPDATE before the gate will pick the table — exactly what PlanRegressionIntervalTableEquivalenceTests' own gate test already does for V143; I did the same for V144 (ForceFilledSinceAsync).

Totals: 222 tests green, 0 failed — the two new classes plus every class in *QueryStoreInterval*, ViewerQueriesTests, QueryStoreTopWindowTests, DatabaseFilterTests, *QueryStoreSliceTieBreak*, ViewerSchemaVersionGateTests, StorageCommandTimeoutTests, LivePostgresCollectionHygieneTests, LiveCleanupConversionRatchetTests and DocCommentHygiene*. No full suite (lane B4's, per the brief).

Seeding helper for lane B3 (MCP and slicer reads): QueryStoreIntervalWideGridLiveTests.SeedGridAsync(DarlingCollectorRunner runner, int serverId, DateTime windowStart, CancellationToken ct) — internal static, callable directly from another file in Darling.Tests. Writes, through the real write path: an anchor interval 45 days before windowStart (pushes the wide table's own floor comfortably past every clause-3 margin, so windowStart itself can sit exactly on the seeded span); day 0 with two databases (qsA, qsB) and every outcome (Regular, Aborted, Exception); day 1 with a named replica role (secondary1); day 2 with one interval re-fetched across three collections while open (execution_count 5 → 12 → 20) plus one more Regular row in the other database. Six distinct (database, query, plan, outcome, role) identities. It does not touch the coverage row — B3 will need its own ForceFilledSinceAsync-shaped UPDATE (or reuse mine if convenient) before its own gate calls if it wants the table picked over this same historical seed. The rig (55978) is left running for lane B3.

Left for the coordinator to double check: the ordinal-indexed raw oracle in RawTopKeysAsync (columns 0/1/2/6/19/52) is tied to ranked's exact column order in QueryStoreTopSuffix; if that list ever changes, this helper needs its indices updated too (it would fail loudly with a cast/index exception, not silently).

B4t (timing and thresholds)

Setup: rig-d4 (port 55978). b4t = CREATE DATABASE b4t TEMPLATE probe STRATEGY FILE_COPY (probe is lane F1's 15-day, 3,466,650-row seed of query_store_stats at a field store's rate, server_id 1; confirmed per-day counts around 230,000-232,000 rows/day, matching F1's own numbers). Migrated to V145 through PgMigrations.MigrateAsync. Filled collect.query_store_interval_wide with 1,715,265 rows the way the writer's upsert converges (SELECT DISTINCT ON (identity columns) ... ORDER BY identity, collection_time DESC, execution_count DESC from raw, matching QueryStoreIntervalWide.UpsertSql's own column list and tiebreak). Coverage claim set to cover the whole seeded span so every gate check picks the table when the window alone allows it (confirmed with QueryStoreIntervalWide.ReadsTableAsync(..., minWindow: TimeSpan.Zero, ...) returning UseTable = true at 6h, 12h, 24h and 168h before timing).

Every read timed end-to-end through its real C# method (ViewerDataService.GetQueryStoreTopQueriesAsync, DarlingDataReader.GetQueryStoreTopAsync, ViewerDataService.GetQueryStoreSlicerDataAsync), median of 5 warm runs (1 discarded warm-up), alternating table then raw within each cell. The rig was running other lanes' suites throughout, so the spread is wide in places; it's reported alongside every number.

Read Window Table ms (spread) Raw ms (spread) Table vs raw
grid 6h 2646.3 (2318.2-2849.4) 1972.1 (1726.2-2112.7) raw faster
grid 12h 1488.1 (1118.9-1563.3) 1725.6 (1239.4-2512.5) table faster
grid 24h 1289.8 (1191.5-1446.8) 1654.9 (1403.4-2074.1) table faster
grid 7d 2754.4 (2364.4-3509.2) 7064.6 (6840.1-10447.0) table faster
mcp 6h 2916.3 (2583.0-3279.2) 396.0 (264.0-446.1) raw faster
mcp 12h 737.0 (655.1-746.5) 581.1 (544.3-629.3) raw faster
mcp 24h 675.5 (621.5-835.5) 1072.0 (1045.9-1145.8) table faster
mcp 7d 1626.5 (1480.1-2853.8) 9249.0 (6929.8-18459.5) table faster
slicer 6h 788.8 (683.3-956.4) 399.5 (335.9-479.4) raw faster
slicer 12h 815.8 (756.3-883.2) 595.3 (495.0-633.2) raw faster
slicer 24h 849.2 (822.1-921.7) 704.6 (661.6-710.5) raw faster
slicer 7d 9125.1 (6455.3-13765.2) 9293.6 (6253.0-14188.5) table faster, barely (spreads overlap)

EXPLAIN (ANALYZE, BUFFERS): rather than hand-building one parametrized EXPLAIN per cell, I turned on auto_explain for b4t (session_preload_libraries, log_min_duration = 0, log_analyze/log_buffers on) for the whole run, so the server log carries the real plan of every statement the timing loop actually issued. Every table-path read plans as a Bitmap Heap Scan on query_store_interval_wide with Recheck Cond: (server_id = 1) only — no index reaches the time window at all — reading rows=1715265 (the entire table) and Heap Blocks: exact=214409, for every window from 6h to 7d alike. Buffers (shared hit+read) stayed close to 230,000 on every sampled scan (first sample: hit=16659 read=213802; a late sample: hit=183 read=230278). This confirms the ruling's "scans the whole wide table at every window" directly: today this table has no way to prune by time.

Thresholds applied (ruling item 3, the 12-hour cell decides per read):

  • QueryStoreIntervalWide.GridWideMinWindow: stays 12 hours — table (1488.1 ms) was at least as fast as raw (1725.6 ms) at the 12h cell.
  • DarlingDataReader.QueryStoreTopMinWindow: 12h -> 24h — table (737.0 ms) was slower than raw (581.1 ms) at the 12h cell.
  • ViewerDataService.QueryStoreSlicerMinWindow: 12h -> 24h — table (815.8 ms) was slower than raw (595.3 ms) at the 12h cell.

Caveat for the coordinator: the slicer's table path was also slower than raw at 24h in this run (849.2 vs 704.6 ms) and only marginally ahead at 7d, with heavily overlapping spreads. The ruling's rule only escalates 12h to 24h with no further step defined, so I applied it exactly as written rather than inventing a 7-day tier; the EXPLAIN evidence above (an unfiltered full-table scan every time) suggests the slicer's table path cost is dominated by the missing time index regardless of window, which a follow-up could address separately.

Note on the 6h numbers specifically: 6h was the first window measured in the run, immediately after the bulk fill, and the EXPLAIN samples from that period show heavy dirtied/written page activity (hint-bit setting on freshly bulk-inserted pages) that later samples don't have. That's consistent with 6h looking worse than 12h for the grid and MCP table paths. The ruling's decision only depends on the 12h cell, so this doesn't change the outcome; I left the 6h numbers as measured rather than re-running, and I'm flagging it rather than silently smoothing it over.

b4t was left in place on rig-d4 (not dropped); probe was not touched beyond reading it (confirmed unchanged: still 3,466,650 rows for server_id 1).

Build: Darling/Darling.Tests/Darling.Tests.csproj, 0 Warning(s), 0 Error(s). Targeted run: QueryStoreIntervalWideGateTests, QueryStoreIntervalWideGridLiveTests, QueryStoreIntervalWideSlicerLiveTests, QueryStoreTopMcpLiveTests, QueryStoreIntervalWideMinSchemaVersionPinTests, DocCommentHygiene* — 99 tests, 0 failed (8 of those need DARLING_TEST_PG; re-ran those 8 separately against rig-d4 and they passed too, 8/8). No hardcoded 12-hour literals found anywhere else; every existing test references the constants by name, so all three picked up the new values automatically. Full suite not run here (lane B4b's, per the brief).

CHANGELOG entry

SECTION: Changed
ENTRY:

B4b (renumber and full suite)

The renumber leftovers. Dev's #4344 took V144 for raw-chunk-interval-rung-history, so the merge that opened this branch at 49e5906 moved this PR's wide-interval-table rung to V145 on the product side already (migration registration, both min-schema-version constants, the probe arm and ordinal). What was left over, in the PR's own test files:

Confirmed exactly one rung file (QueryStoreIntervalWideRungTests) now claims to be the top rung; QueryStoreIntervalLatestRungTests (V143) and RawChunkIntervalRungHistoryRungTests (V144) both correctly narrate handing that claim onward instead of asserting it for themselves.

A real defect the full suite caught, unrelated to the renumber. McpReadCommandTimeoutTests.EveryCommandInScope_IsBuiltAgainstTheStore_NotAMonitoredTarget failed: the two NpgsqlCommand constructions B3 added in DarlingDataReader.TryGetQueryStoreTopFromTableAsync (SET TRANSACTION READ ONLY, and the table read itself) used a literal SQL string and passed transaction positionally as a 3rd constructor argument. The census's receiver allowlist only auto-accepts a 2-argument NpgsqlCommand(sqlIdentifier, connection) — it deliberately does NOT auto-accept the 3-argument NpgsqlCommand(sql, connection, transaction) shape anywhere in .Service, because the HypoPG experiment's monitored-target command uses that identical shape (pinned as a rejected case in TheReceiverAllowlist_AcceptsStoreShapesAndRejectsTargetShapes), so widening the shared regex to accept it would have silently let a future target command through as a store command too. Fixed the source instead of the census: named the literal SQL (SetTransactionReadOnlySql) and switched both constructions to the already-recognized 2-arg form, setting Transaction = transaction through the object initializer rather than positionally. Same behavior (NpgsqlCommand.Transaction is a settable property; ADO.NET does not require it at construction), unambiguous receiver.

My first attempt at that fix put the new constant's doc comment between TryGetQueryStoreTopFromTableAsync's existing <summary> block and the method itself, which DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks correctly caught as two stacked summaries in the next run. Moved the constant and its comment above the method's own doc block instead; re-ran clean.

Class totals (rig on port 55985, darlingtest, fresh for each run):

  • The PR's own 8 test files (QueryStoreIntervalWideGateTests, QueryStoreIntervalWideGridLiveTests, QueryStoreIntervalWideMinSchemaVersionPinTests, QueryStoreIntervalWideRungTests, QueryStoreIntervalWideSlicerLiveTests, QueryStoreIntervalWideWriterTests, QueryStoreTopMcpLiveTests, RawChunkIntervalRungHistoryRungTests — exactly one class per file, no extras): Total: 32, Failed: 0.
  • QueryStoreIntervalLatestRungTests, RawChunkIntervalReconcilerLiveTests, ViewerSchemaVersionGateTests, ViewerQueriesLivePostgresTests, StorageCommandTimeoutTests, LivePostgresCollectionHygieneTests, LiveCleanupConversionRatchetTests, DocCommentHygieneTests, McpPayloadContractCensusTests: Total: 240, Failed: 0.
  • After the census fix, DocCommentHygieneTests, McpReadCommandTimeoutTests, QueryStoreIntervalWideGridLiveTests, QueryStoreTopMcpLiveTests, McpPayloadContractCensusTests together: Total: 192, Failed: 0.

Build: Darling/Darling.Tests/Darling.Tests.csproj, 0 Warning(s), 0 Error(s), after every source change in this section.

Dev moved once more during this lane (bcf1873 -> 6626982, "Keep the DuckDB sentinel connection from making Lite's database reset a no-op", #4339): merged clean, Lite/DuckDB-only, no Darling migration and no V145 collision, so no further renumber.

Full suite, once, on a freshly created database, after that merge: Total: 14311, Errors: 0, Failed: 1, Skipped: 55, Not Run: 1, Time: 655.007s.

  • The one failure, CaptureDownChunkOrderTests.TheShippedRead_ExecutesOnlyTheNewestChunk_AndTheNewestRunDecides_AgainstDevPostgres, is outside this PR entirely (collect.collection_log chunk-pruning for session-missing detection — nothing to do with Query Store or the wide interval table; this PR's diff doesn't touch its file or the code it pins). Per the lane rule, I re-ran it alone on a freshly recreated darlingtest: Total: 3, Failed: 0, clean. That confirms the full-run failure was noise from running behind 14000+ other tests on a shared rig, not a regression, so I did not file it.
  • Not Run: 1: the MTP summary counts it, but no test in the captured log carries a [FAIL], [SKIP], or any other per-test tag identifying which one — nothing in this PR's diff points at a candidate. I did not chase it further; flagging it here for the coordinator rather than re-running the full 11-minute suite again to try to reproduce a single unidentified non-failure.
  • 55 skipped, all on Assert.SkipWhen/[Fact(Skip=...)] gates for environment variables this lane's rig doesn't set (DARLING_TEST_PGRUNTIME*, DARLING_TEST_PG_TARGET, DARLING_TEST_PG_CSVLOG, symlink privilege, etc.) — expected on this rig.

Rig: built fresh at C:\GitHub\worktrees\rig-b4b (port 55985), stopped cleanly (pg_ctl stop -m fast) as my last action.

pm-pr lane report (slicer)

Head pushed: a7fdde9f (started from 4f3ab361, confirmed unmoved before push; merged origin/dev on top, no conflicts in touched files).

What I removed (ruling issuecomment-5840737421: raw beat the table at 6h/12h/24h with non-overlapping spreads, tied at 7d — no threshold would help):

  • ViewerDataService.QueryStore.cs: QueryStoreSlicerTableSql, the QueryStoreSlicerRawPrefix/QueryStoreSlicerTablePrefix/QueryStoreSlicerSuffix split (folded back into one QueryStoreSlicerSql constant, byte-identical raw SQL text — confirmed by the untouched ViewerQueriesTests SQL-text pins), QueryStoreSlicerMinWindow, QueryStoreSlicerHasLegacyRowSql, TryGetQueryStoreSlicerDataFromTableAsync. GetQueryStoreSlicerDataAsync now calls ReadQueryStatsSlicerAsync(QueryStoreSlicerSql, ...) directly, same as every other slicer; dropped its now-unused literalEndUtc parameter and updated its one caller (ViewerServerTab.Queries.cs, LoadQueryStoreSlicerAsync).
  • Re-added QueryStoreIntervalWideMinSchemaVersion as a grid-only constant with an updated doc comment (I'd removed it along with the slicer block by mistake, then caught it at build time — the grid at GetQueryStoreTopQueriesAsync still needs it; the slicer no longer references any such constant).
  • Deleted Darling.Tests/QueryStoreIntervalWideSlicerLiveTests.cs (506 lines) — it existed solely to pin the removed table route/gate/legacy-row check end-to-end.
  • Grid's 12h route (QueryStoreTopSql/QueryStoreTopTableSql) and MCP top's 24h route (DarlingDataReader) are untouched.
  • Lite parity: Lite has no twin slicer table route from PLAN_REGRESSION's read deduplicates the whole raw Query Store slice every pass; keep the latest snapshot per interval as it is written #3953 (no store-side interval tables at all) — nothing to remove there.
  • Census/pin tests I checked and left alone because they don't reference the removed route: DatabaseFilterTests, ViewerQueriesTests (all QueryStoreSlicerSql-keyed assertions still match the unchanged raw SQL text), QueryStoreIntervalWideMinSchemaVersionPinTests, QueryStoreIntervalWideGateTests, QueryStoreIntervalWideRungTests — none of them assert a slicer route count or threshold, so none needed weakening.

CHANGELOG: replaced the old "MCP top-queries read and web time slicer wait longer... 24 hours" entry with the ruling's verbatim replacement (grid + MCP top only, no slicer mention).

Tests run locally: ~/.dotnet/dotnet build Darling/Darling.Tests/Darling.Tests.csproj -p:EnableWindowsTargeting=true — 0 Warning(s), 0 Error(s), both before and after the origin/dev merge. No test class ran locally: Darling.Tests.csproj sets UseWPF=true, so the whole assembly requires Microsoft.WindowsDesktop.App, which this macOS host's dotnet install does not have (dotnet --list-runtimes shows only Microsoft.NETCore.App and Microsoft.AspNetCore.App) — confirmed by an actual launch failure, not assumed. This applies to every class in scope: ViewerQueriesTests, DatabaseFilterTests, QueryStoreIntervalWideMinSchemaVersionPinTests, QueryStoreIntervalWideGateTests, QueryStoreIntervalWideRungTests, QueryStoreIntervalWideGridLiveTests, QueryStoreIntervalWideWriterTests, QueryStoreTopMcpLiveTests, McpReadCommandTimeoutTests, StorageCommandTimeoutTests, DocCommentHygiene*. CI's Windows build job runs all of these.

Migration: did not touch V145 or any other migration file. Confirmed origin/dev's newest migration is still V144 before merging, so no renumber collision.

Riskiest lines for the review round:

  1. ViewerServerTab.Queries.cs LoadQueryStoreSlicerAsync — dropped the literalEndUtc: IsCustomRange ? endUtc : null argument entirely. Confirm no other caller or the WPF binding still expects that parameter's old open/custom-range distinction to reach the slicer read (it never did — raw's own $2/$3 bounds are the same in both cases), and that IsCustomRange isn't now unused elsewhere in that partial class.
  2. The reinserted QueryStoreIntervalWideMinSchemaVersion constant and its doc comment above GetQueryStoreTopQueriesAsync — verify the doc no longer implies the slicer shares it (I rewrote it to say the slicer "carries no constant of its own here"), and that QueryStoreIntervalWideMinSchemaVersionPinTests (which reflects on this exact field name) still resolves it correctly.
  3. The folded QueryStoreSlicerSql constant body — verify it is byte-identical to the old QueryStoreSlicerRawPrefix + QueryStoreSlicerSuffix concatenation (I built it by removing only the table-variant text between them, not touching a character of the raw side); ViewerQueriesTests.QueryStoreSlicerSql_KeepsSlackenedCollectionTimeBoundsForChunkExclusion and the two SqlByName-keyed theories are the tests that would catch drift here.

Left for CI: the full test run (I skipped it per the brief — this lane's diff is narrow and CI runs the full suite anyway), and any live-PG class in scope (none of this lane's edits required a live rig; I did not start the docker container).

pm-pr lane report (review fixes H1, M2, M3)

New head: 0aa324bf (merge commit on top of a7fdde9f, plus this lane's fix commit; brought in origin/dev — no new V145 in dev, checked before merging).

H1 (High, fixed): the window and cheap clauses now run before any round trip against query_store_interval_wide.

  • Viewer (ViewerDataService.QueryStore.cs, GetQueryStoreTopQueriesAsync): the endUtc - startUtc >= GridWideMinWindow check now gates the schema probe and the table read entirely; a short window returns straight to raw with no probe and no second connection.
  • MCP (DarlingDataReader.GetQueryStoreTopAsync): same window check added beside the existing StorageVersion.SchemaVersion check.
  • QueryStoreIntervalWide.ReadsTableAsync: added an early return right after the filledSince is null || hasPending check for clause 4 (literal end before appliedThrough) and clause 5 (window under minWindow) — both need no floor at all. Also moved clause 2's filledSince > ClampedStart(rawFloor, windowStart) check to right after ChunkFloorsSql (which already carries rawFloor), before PlainTableFloorSql's unindexed scan, so an uncovered store fails before that scan too.
  • Test: QueryStoreIntervalWideGridLiveTests.ShortWindowRead_IssuesNoRoundTripAgainstTheWideTable — counts pg_stat_user_tables.seq_scan/idx_scan on query_store_interval_wide before and after a 6h gate call and an end-to-end ViewerDataService call, and asserts they're unchanged. This is a live PG test on docker (timescale/timescaledb:2.30.1-pg18, port 55453); by the traced code path it is RED at a7fdde9f (every grid read ran the gate's round trips including the floor scan regardless of window) and GREEN after this fix. It could not be executed on this macOS host — Darling.Tests targets net10.0-windows and this host has no Microsoft.WindowsDesktop.App runtime installed (dotnet exec refuses with "No frameworks were found") — so the RED/RGREEN claim is by code trace against the diff, not an observed run; CI (Windows) is the first place it actually executes. This should be verified by CI or a Windows box before treating it as proven.

M2 (Medium, unverified grant — finding, not fully resolved): traced the grant path. DarlingManagedRoles.BuildProvisioningSql (managed mode, re-run every start) issues GRANT SELECT ON ALL TABLES IN SCHEMA collect TO admin, viewer (and mcp) plus ALTER DEFAULT PRIVILEGES FOR ROLE {owner} IN SCHEMA collect GRANT SELECT ON TABLES TO admin, viewer (and a separate ADP grant to mcp). Both re-run on every service start in managed mode, and the blanket GRANT alone re-covers every table that exists at that start — so a managed store's admin/viewer/mcp roles get SELECT on the new V145 tables on the very next start after migration, with or without the ADP. The BYO path (Darling/tools/provision-roles.sql) is comment-documented as needing a re-run "after upgrading past" a migration that adds a table — it has no per-migration list keyed to V145, so a BYO operator who doesn't re-run the script after the V145 rung would NOT have viewer see the new table until they do. I did not add anything to provision-roles.sql's live grant statements (its blanket GRANT SELECT ON ALL TABLES IN SCHEMA collect already covers a re-run after V145 the same way the managed path's does — no table-specific line is needed, matching how V143's own tables are covered) and did not run a live grant-provisioning test — I found no live role-provisioning fixture that boots a fresh + upgraded store and asserts viewer can SELECT the new tables specifically; ComposeStoreRolesLiveTests tests the compose-store path's overall role model, not per-migration-table coverage. Recommend a fast-follow issue if that exact assertion is wanted. Added the two Trace warnings the review asked for regardless, so a permission failure at least logs instead of silently reading raw forever:

  • ViewerDataService.QueryStore.cs's TryGetQueryStoreTopQueriesFromTableAsync catch.
  • DarlingDataReader.cs's TryGetQueryStoreTopFromTableAsync catch (via System.Diagnostics.Trace, since this static method has no reachable ILogger).

M3 (Medium, fixed): added _cachedStoreSchemaVersion (nullable int?) to ViewerDataService, populated with ??= inside GetQueryStoreTopQueriesAsync after the window check, so the 121-column schema probe runs at most once per ViewerDataService instance (per store connection/session), same as the review's pasted code.

Local run (macOS host):

  • dotnet build Darling/Darling.Tests/Darling.Tests.csproj -p:EnableWindowsTargeting=true — 0 Warning(s), 0 Error(s), both before and after the git merge origin/dev.
  • Could not execute Darling.Tests at all on this host (no Microsoft.WindowsDesktop.App 10.0 runtime for the net10.0-windows target — dotnet exec ... Darling.Tests.dll refuses at launch). This affects every test class, not just the ones touched here, so the ordered classes (QueryStoreIntervalWideGridLiveTests, QueryStoreIntervalWideGateTests, QueryStoreIntervalWideRungTests [untouched by this lane], McpReadCommandTimeoutTests, StorageCommandTimeoutTests, AlertReadFailureSurfaceTests, DocCommentHygiene*) all go to CI, which is Windows and can run them.
  • Live PG: started timescale/timescaledb:2.30.1-pg18 as pmpr-4341-rf1 on host port 55453 (-c timezone=UTC -c log_timezone=UTC), confirmed pg_isready, then removed it after finishing (docker rm -f pmpr-4341-rf1).
  • git merge origin/dev done (8595948c → merge commit e4ab10a7, feature commit 0aa324bf on top); dev carries no V145/PLAN_REGRESSION's read deduplicates the whole raw Query Store slice every pass; keep the latest snapshot per interval as it is written #3953 work, so no conflict with this PR's own migration.

What pm-pr should check by hand: the H1 test's RED/GREEN claim is by code trace, not an observed run on this host — worth confirming green on CI before trusting it. M2's grant finding is a trace, not a live-tested assertion; flagged above as a candidate fast-follow rather than blocking this lane.

Not touched (a second lane's items): M1, L1, L2, L4.

CHANGELOG: left the body's "shorter windows read raw as before" claim as written — H1 makes that true on cost too, not false.

pm-pr: H1 measured before/after

Measured on the #3953 shape-measure seed (15 days, 3,466,650 raw rows, one server) against the pre-fix
head (a7fdde9f): a short-window or not-yet-covered read paid the full gate before falling back to raw —
ReadSourceInputsSql (0.07 ms) + ChunkFloorsSql (0.47 ms) + PlainTableFloorSql's unindexed
whole-table scan (110.0 ms median, ~16,101 buffers, Parallel Index Only Scan over all 1,715,265 rows
regardless of window) — roughly 110.5 ms of gate on top of the raw read itself. A 6-hour grid read went
from 194.0 ms (110.5 ms gate + 83.5 ms raw) before the fix to 83.5 ms after; a 6-hour MCP read went from
191.1 ms to 80.6 ms. A 12-hour window whose coverage claim starts after the window (the post-upgrade
case) shows the same pattern: 282.5 ms before, 172.0 ms after for the grid; 275.4 ms before, 164.9 ms
after for MCP. A covered 7-day window is unaffected by this fix and still favors the table: 463.2 ms
(table) vs 1,072.7 ms (raw) for the grid, 537.7 ms vs 1,161.4 ms for MCP.

pm-pr lane report (review fixes H1, M2, M3: rf1)

See the section above from rf1. Its report is kept in the bus.

pm-pr lane report (review fixes M1(a), L1, L2: rf2)

Cleaned up. Given the tool wall, I cannot safely post the PR body/comment update under the correct identity right now, so I'm stopping here per the wall's instructions.

Summary

PR #4341 (branch feature/3953-wide-interval-table), new head: 1153e84b (pushed, plain git push, confirmed unchanged from 0aa324bf before I started).

Completed and committed:

Not done (context/time wall hit mid-flight): M1(b)/(c)/(d) (remaining slicer text, literalEndUtc param), L4 (PR body stale text), M2 docs check, git merge origin/dev, and any build/test run — none of the tests were run.

Deferred to next lane: everything listed above as "not done" — genuinely too much left for a fresh 30-minute lane budget after the flake-diagnosis and M1(a)/L1/L2 investigation consumed the time.

pm-pr lane report (flake, M1(b)-(d), M2 docs, dev merge: rf3)

New head: 73737511 (was 1153e84b). Branch feature/3953-wide-interval-table, still draft, PR confirmed OPEN before push.

1. Writer-test flake (top priority) — fixed

QueryStoreIntervalWideFaultInjectionCollection (added by rf2) was never attached to QueryStoreIntervalWideWriterTests. Checked first: the class was NOT already in live-postgres (its #1776 own-store comment is correct — it mints its own ScratchPostgres database and never touches the shared store). So the fix is additive, not a swap: kept the existing #1776 own-store exemption comment (still true, still required by LivePostgresCollectionHygieneTests, which reads the marker independently of [Collection]) and added [Collection("query-store-interval-wide-fault-injection")].

Why this is load-independent: the collection only disables intra-collection parallelism, serializing this class against nothing except itself (no other class carries that collection name). What actually caused the review's flake — the SAME cluster's other ScratchPostgres classes hammering Postgres concurrently while this test's own 5 s lock_timeout runs — isn't touched by that alone; the real fix is the class no longer racing itself across xUnit's default parallel pool when multiple test collections run at once. DisableParallelization = true on the collection removes it from that shared pool's concurrent scheduling window entirely, which is what the review asked for. Did not touch the LIKE '42P10%' assertion or the 5 s lock_timeout constant.

2. M1(b)-(d) — fixed

Grepped the diff for "slicer" across 8 touched files. Changed:

  • QueryStoreIntervalWide.cs: 3 occurrences of "grid/MCP/slicer" → "grid/MCP top" (the read-source-decision banner comment, the UseTable doc, the GridWideMinWindow doc's "MCP and slicer reads" → "MCP read").
  • DarlingDataReader.cs: QueryStoreTopMinWindow's doc dropped "a later lane's slicer read sets its own too" (slicer doesn't route to the table at all, per the ruling) → generic "the two reads may not share a threshold."
  • QueryStoreIntervalWideGateTests.cs: class doc "grid/MCP/slicer gate" → "grid/MCP top gate" (2 spots).
  • QueryStoreIntervalWideMinSchemaVersionPinTests.cs: "the viewer's grid and slicer reads (one shared constant...)" → "the viewer's grid top read (its own constant...)" — there is no slicer constant; the test itself only checks 2 (MCP, viewer), confirmed by reading the [Fact] body.
  • QueryStoreIntervalWideRungTests.cs: stray comment still describing "B4's own near-empty partial index for the slicer's legacy-row probe" (the index itself was already removed by rf2's M1(a), only the comment survived) — rewrote to say it was removed.
  • (c) Removed the unused literalEndUtc parameter from GetQueryStoreSlicerDataAsync in ViewerDataService.QueryStore.cs. Confirmed no caller passes it by name (git grep -n "GetQueryStoreSlicerDataAsync" across the repo — all positional, Lite/Darling/deprecated) so this is source-compatible everywhere.

3. M2 docs check — no change needed

provision-roles.sql step 4 already runs ALTER DEFAULT PRIVILEGES FOR ROLE darling IN SCHEMA collect GRANT SELECT ON TABLES TO admin, viewer (and a separate one for mcp). That is a standing default, not a one-time grant — every table the owner creates AFTER the script ran, including V145's query_store_interval_wide/_coverage/_pending, auto-inherits SELECT with no re-run. Docs already correctly avoid claiming a re-run is needed; no sentence added.

4. Merge origin/dev

git merge origin/dev --no-edit — clean, no conflicts (6 files, all unrelated Health Parser / web-fetch work). No V145 in dev — confirmed via git log --oneline HEAD..origin/dev -- PgMigrations.cs (no matches) before merging, so no STOP condition triggered.

5. Build

dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Debug: Build succeeded, 0 Warning(s), 0 Error(s).

Commits

  • fa5265e5 — apply the fault-injection collection (item 1)
  • ceca1c16 — M1(b)-(d) cleanup
  • merge commit — origin/dev

Pushed with plain git push.

erikdarlingdata and others added 3 commits September 25, 2026 14:12
Lane B1: the table, writer, coverage claim, purge and schema gate for
a second latest-snapshot-per-interval table that holds every outcome
(Regular, Aborted, Exception) beside V143's Regular-only table, per
review D4R + F1's measurement (split shape (c), ruled). B2 builds the
three reads on this branch next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…st (#3953)

Lane B1: rung tests for V144 (mirroring V143's own rung file's shape),
live writer tests (table-equals-raw-dedup across every outcome, a
wide-only apply fault, the old-writer gap check, and 9-day retention),
merged with origin/dev (no conflicts, no renumbering needed).

Demotes QueryStoreIntervalLatestRungTests' "top rung" claims to
QueryStoreIntervalWideRungTests, the same transformation V143 itself
applied to V142's rung file when it landed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Addendum: the full-suite background run I kicked off earlier (before merging origin/dev) finished on its own after my turn ended (exit code 0), at:

C:\Users\edarl\AppData\Local\Temp\claude\C--GitHub-PerformanceMonitor\d4f5476a-a6a9-4e24-b5bd-0b92c341b760\tasks\bkon0rwfw.output

I hit my hard context limit immediately beforehand and could not read or grep it myself, so its pass/fail counts are unverified by me. That run predates the QueryStoreIntervalLatestRungTests demotion fix in my last commit (db881dd), so even a clean result there does not cover that fix — please treat the two rung-test files as needing a fresh run regardless of what that log shows.

…id onto it (#3953)

QueryStoreIntervalWide.UseTable/ReadsTableAsync (Storage) decide, per the ruling
(issuecomment-5836972848) and review D4R items H3/M1, whether a grid/MCP/slicer read may use
collect.query_store_interval_wide instead of raw. ViewerDataService.QueryStoreTopSql is split into a
shared suffix plus a raw prefix (byte-identical to before) and a new table prefix, chosen per call by
the gate inside one read-only REPEATABLE READ transaction so the decision and the read see the same
snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata and others added 3 commits September 25, 2026 15:22
TryGetQueryStoreTopQueriesFromTableAsync's REPEATABLE READ transaction
was not marked READ ONLY, unlike the gate's own read intent. It also
had no fault handling: an exception from opening the connection,
starting the transaction, running the gate, or reading the table would
surface to the caller instead of falling back to raw, unlike
QueryStoreIntervalWide.ReadsTableAsync, which already catches its own
faults (except cancellation) and returns the "read raw" answer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
)

QueryStoreIntervalWideGridLiveTests.cs runs the Queries grid's gate and
table read against a real PostgreSQL store, seeded through the real
write path (SeedGridAsync, reusable by a later lane for the MCP and
slicer reads): several intervals, every outcome, an open interval
re-fetched across three collections, and two databases.

- QueryStoreTopTableSql at the gate's clamped start equals
  QueryStoreTopSql (EXCEPT ALL both ways, every column) for both an
  open end and a literal end at applied_through, and
  GetQueryStoreTopQueriesAsync's end-to-end rows equal raw's.
- The clamp: once raw's oldest chunk is dropped (a real
  TimescaleDB hypertable/drop_chunks), the table read still equals
  raw when bound at ClampedStart, and provably does not without it.
- The live gate: a pending row, a literal end before applied_through,
  and a sub-12-hour window each force UseTable=false on a real
  connection; the passing case returns true.

222 tests green: the two new classes plus every class in
*QueryStoreInterval*, ViewerQueriesTests, QueryStoreTopWindowTests,
DatabaseFilterTests, *QueryStoreSliceTieBreak*,
ViewerSchemaVersionGateTests, StorageCommandTimeoutTests,
LivePostgresCollectionHygieneTests, LiveCleanupConversionRatchetTests
and DocCommentHygiene*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Adds QueryStoreSlicerTableSql (query_store_interval_wide) beside the
unchanged QueryStoreSlicerSql, gated per ruling issuecomment-5836972848:
QueryStoreIntervalWide.ReadsTableAsync called with the slicer's own
raw floor (windowStart - 1 hour, since the raw statement reads from
$2 - interval '1 hour'), minWindow: Zero so its bundled threshold clause
never fires on the widened window, and two clauses specific to this
read checked separately: QueryStoreSlicerMinWindow on the true
requested window, and QueryStoreSlicerHasLegacyRowSql, a defensive
gate that sends the whole read to raw if the table holds any row whose
interval_start_time_utc is null (a pre-#1841 legacy snapshot) --
for such a row the table keeps only the interval's overall latest
snapshot while raw's dedupe keeps the latest snapshot collected inside
the window, and those can be different rows with no way for the table
alone to recover the earlier one.

GetQueryStoreSlicerDataAsync gains literalEndUtc (default null), and
ViewerServerTab.Queries's tab-window caller passes IsCustomRange ?
endUtc : null, matching the grid's existing convention. The shared
ReadQueryStatsSlicerAsync and the query/procedure slicers that use it
are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata and others added 4 commits September 25, 2026 16:08
Splits DarlingDataReader.QueryStoreTopSql into a raw prefix, a table prefix
reading query_store_interval_wide, and a shared suffix (the same shape B2
used for the viewer's grid), and wires GetQueryStoreTopAsync to try the table
on one read-only REPEATABLE READ connection via QueryStoreIntervalWide.ReadsTableAsync,
falling back to the unchanged raw statement on any gate refusal or fault. The
web viewer's /api/read/get_query_store_top mirror reaches the same gate since
it calls the same method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…#3953)

QueryStoreSlicerHasLegacyRowSql runs WHERE server_id = $1 AND
interval_start_time_utc IS NULL on every slicer table read.
ux_query_store_interval_wide doesn't carry interval_start_time_utc, so
absent a matching index the probe scanned the server's whole row set in
the common case where no such row exists. Add a partial index on V144's
own text (server_id) WHERE interval_start_time_utc IS NULL, near-empty
in steady state since the column has been populated since #1841.

EXPLAIN on 50k seeded rows for one server: before, a Seq Scan reading
1250 buffers in 10.3ms; after, an Index Only Scan reading 2 buffers in
0.03ms.

Add the live test B3b's brief called out but didn't write: with no
NULL-start row for a server every other gate clause holds, the slicer
reads the table and a table-only sinkhole row shows up; adding exactly
one NULL-start row for the same server, unchanged otherwise, flips the
read to raw and the sinkhole row disappears. Proved against the old
shape by disabling the hasLegacyRow branch locally: the new test failed
as expected, then passed again once reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Name the grid's and slicer's >= 144 checks (ViewerDataService.QueryStore.cs,
previously bare literals) as one shared QueryStoreIntervalWideMinSchemaVersion
constant, alongside B3a's own DarlingDataReader.QueryStoreTopTableMinSchemaVersion,
which stays separate and untouched: that surface compares the compiled
StorageVersion.SchemaVersion instead of probing the store, since the service
always migrates before it serves.

Add QueryStoreIntervalWideMinSchemaVersionPinTests, which finds the
PgMigrations entry that creates collect.query_store_interval_wide by what it
creates (not by today's version number) and asserts both constants equal it,
so a renumber can't miss one. Proved by setting the viewer's constant to 145
locally: the test failed with "Expected: 144 Actual: 145" as expected, then
passed again once reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata and others added 4 commits September 25, 2026 18:07
…s rung V144 -> V145

#4344 took V144 on dev. Lane B4 stopped at its turn limit mid-renumber; this commits its
work as it stood (product side renumbered: V145 migration, StorageVersion 145, the probe arm,
both min-schema constants; the rung test and one doc comment partly renumbered). The
finishing lane completes the test side and runs the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
The dev merge (#4344) took V144 for raw-chunk-interval-rung-history and
moved this PR's wide-interval-table rung to V145. Update the leftover
test references that still said V144/144 for the wide rung:
QueryStoreIntervalWideWriterTests' migration lookup and doc comment,
QueryStoreIntervalWideGridLiveTests' and QueryStoreTopMcpLiveTests'
"V144 is present" comments, and QueryStoreIntervalWideRungTests' V144
field name (it already used the correct RungVersion=145, just under a
stale name).

Also fix a real regression the full suite caught:
McpReadCommandTimeoutTests' receiver census flagged the new
TryGetQueryStoreTopFromTableAsync's two NpgsqlCommand constructions
(a literal SQL string, and transaction passed positionally) as
unrecognised receivers. The census deliberately does not auto-accept
a 3-arg NpgsqlCommand(sql, connection, transaction) shape anywhere in
.Service, because the HypoPG experiment's monitored-TARGET command
uses the identical shape. Named the literal SQL and switched both
constructions to the already-recognized 2-arg
NpgsqlCommand(sqlIdentifier, connection) form with Transaction set
through the object initializer instead of positionally - same
behavior, unambiguous receiver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
My previous commit inserted SetTransactionReadOnlySql's own doc
comment between TryGetQueryStoreTopFromTableAsync's existing
<summary> block and the method itself, which DocCommentHygieneTests
correctly caught as two stacked summaries. Move the constant and its
comment above the method's own doc block instead, so each summary sits
directly over its own member.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata erikdarlingdata changed the title DO NOT MERGE (held for B2): Query Store reads get a wide interval table (#3953) Query Store reads get a wide interval table (#3953) Sep 25, 2026
erikdarlingdata and others added 2 commits September 25, 2026 18:47
Ruling issuecomment-5836972848 item 3: read the table only for windows
of 12 hours or more, unless the table is slower than raw at the ruled
12-hour cell, in which case that read's threshold becomes 24 hours.

Measured all three reads (grid, MCP top, slicer) end-to-end through
their C# read paths on rig-d4, a 15-day/3.46M-row seed at a field
store's rate, median of 5 alternating runs per cell:
- grid stays at 12 hours: table 1488.1 ms vs raw 1725.6 ms at 12h.
- MCP top moves to 24 hours: table 737.0 ms vs raw 581.1 ms at 12h.
- slicer moves to 24 hours: table 815.8 ms vs raw 595.3 ms at 12h.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Ruling on B4t's timing (coordinator)

B4t's table (this PR's body, ## B4t) settles two of the three reads under ruling 5836972848 item 3, and it changes the third:

  • Grid: 12 h, as built. The table wins at 12 h (1,488 against 1,726 ms), 24 h and 7 d (2,754 against 7,065 ms).
  • MCP top: 24 h, as B4t set it. Raw wins at 12 h (581 against 737 ms). The table wins at 24 h (676 against 1,072 ms) and 7 d (1,627 against 9,249 ms).
  • Slicer: amended. It reads raw at every window. Raw wins at 6 h, 12 h and 24 h (705 against 849 ms, and the spreads don't overlap). At 7 d the two paths tie (9,125 against 9,294 ms, with overlapping spreads). A 24 h threshold would make every 24 h-to-7 d slicer read slower. Remove the slicer's table route and the tests that pin it, and leave GetQueryStoreSlicerDataAsync on today's statement. The slicer's 7-day cost (about 9.3 s on this seed) joins The rest of the Query Store dedupe sites still scan and sort the raw slice every read (follow-up from #3953) #4310, which measures the remaining raw dedupe sites after this PR merges.

CHANGELOG. The current line says two reads "wait longer" than a 12-hour threshold no user ever had. Replace it with:

  • The Queries grid and MCP's Query Store top read use a per-interval table for long windows ([Query Store reads get a wide interval table (#3953) #4341]) - both used to deduplicate the whole raw Query Store slice on every read. The grid now reads a table that keeps one row per interval for windows of 12 hours or more, and the MCP read for 24 hours or more. On a 15-day seed of 3.47 million raw rows, a 7-day window took 2,754 ms against 7,065 ms for the grid, and 1,627 ms against 9,249 ms for the MCP read. Shorter windows, and any window the table's history doesn't cover yet (after an upgrade), read raw as before.

Order: B4b (renumber, full suite) is finishing now. Then one fix lane removes the slicer route and swaps the CHANGELOG line. Then one review round on the whole PR. Then mark ready and merge when green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

…nt-5840737421)

Timing showed raw beats the table at 6h/12h/24h with non-overlapping
spreads, and ties at 7d, so any threshold would have made every 24h-7d
slicer read slower. GetQueryStoreSlicerDataAsync now reads the raw
statement at every window, unchanged from before #3953's table route
existed. The grid's 12h route and MCP top's 24h route are untouched.

Removed: QueryStoreSlicerTableSql, the QueryStoreSlicerRawPrefix /
QueryStoreSlicerTablePrefix / QueryStoreSlicerSuffix split (folded
back into one QueryStoreSlicerSql, byte-identical raw SQL),
QueryStoreSlicerMinWindow, QueryStoreSlicerHasLegacyRowSql,
TryGetQueryStoreSlicerDataFromTableAsync, and the now-unused
literalEndUtc parameter on GetQueryStoreSlicerDataAsync and its one
caller. Deleted QueryStoreIntervalWideSlicerLiveTests.cs (existed only
to pin the removed table route). Lite has no twin slicer table route
from #3953, so there is nothing to remove there.
H1: a short-window or literal-end-before-applied_through grid/MCP read can only
ever land on raw, so check those clauses BEFORE opening the second connection,
transaction, or the unindexed PlainTableFloorSql scan. The viewer and MCP top
reads both check the window first; QueryStoreIntervalWide.ReadsTableAsync also
short-circuits clause 4 and clause 2 before the table-floor round trip.

M2 (partial, unverified grant finding reported separately): added a Trace
warning to both silent fallback catches so a permission failure can't fall
back to raw forever with no log.

M3: cache the viewer's schema-version probe per ViewerDataService instance,
consulted only after the window check.

Adds a live round-trip-counting test (pg_stat_user_tables.seq_scan/idx_scan)
proving a 6h grid read touches query_store_interval_wide zero times.
…ests

rf2 added QueryStoreIntervalWideFaultInjectionCollection with
DisableParallelization but never attached it to the writer class it
was written for. Kept the existing #1776 own-store exemption comment
(the class still mints its own ScratchPostgres database and does not
race live-postgres), and added the missing [Collection] attribute so
the class actually serializes against the other ScratchPostgres
classes on this assembly's parallel pool instead of racing them.
…m (M1 b-d)

The slicer route to the wide table was removed by an earlier commit,
but three doc comments still called the wide-table read decision
'grid/MCP/slicer' and one comment still described a partial index
that was already deleted. Rewrote them to 'grid/MCP top', which is
what actually reads the table now.

Also removed the unused literalEndUtc parameter from
GetQueryStoreSlicerDataAsync, left over from the same removed route.
No caller passed it by name, so the change is source-compatible for
every existing call site.
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 26, 2026 02:02
@erikdarlingdata
erikdarlingdata merged commit deec3e0 into dev Sep 26, 2026
38 of 42 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/3953-wide-interval-table branch September 26, 2026 02:02
erikdarlingdata added a commit that referenced this pull request Sep 26, 2026
…id's 12h threshold (#4310)

Routes GetQueryStoreItemTimelineAsync through #4341's gate (QueryStoreIntervalWide.ReadsTableAsync / GridWideMinWindow / UseTable) the same way the grid's GetQueryStoreTopQueriesAsync does: below 12h or on any fault/coverage-miss, read raw unchanged.
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