Skip to content

Bound the Query Store backfill candidate read at its horizon (#4197) - #4306

Merged
erikdarlingdata merged 8 commits into
devfrom
fix/4197-backfill-candidate-bound
Sep 25, 2026
Merged

erikdarlingdata merged 8 commits into
devfrom
fix/4197-backfill-candidate-bound

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Part of #4197.

Why

Lane D3's design (issue comment 5830310090) and the coordinator's ruling (5834871359) measured
that the Query Store backfill's candidate read (SELECT DISTINCT database_name ... collection_time > now - 7 days) and its per-database MIN(last_execution_time) floor lookup both bind at a window
wider than raw retention. Neither predicate excludes any chunk as a result. On the design lane's rig,
the heavy-server candidate read cost 206 ms and 21,411 buffers. Bounded at the backfill's own floor
(floorLimit, from HorizonFor), the design measured 1.7 ms and 792 buffers. This lane implements
the ruling's decisions 1-4 (part a) for both Darling and Lite. Part b (persisting ring-buffer
watermarks) is explicitly out of this lane and waits for a field measurement after #4209.

What changes

  • QueryStoreBackfill.RunServerSliceAsync (Darling) now computes floorLimit (HorizonFor)
    before calling GetCandidateDatabasesAsync, not after. It binds floorLimit as the candidate
    read's own collection_time lower bound in place of the old 7-day CandidateWindow (removed).
  • GetCandidateDatabasesAsync unions the store's bounded result with every database a hole key
    already names. It does this through a new shared helper, QueryStoreBackfillState.MergeHoleDatabases
    (sorted ordinal, deduped). A database that goes fully quiet mid-hole (no row newer than the horizon)
    is still serviced this way, instead of silently aging out of the scan the moment its recent rows
    expire.
  • GetStoredFloorAsync takes its exact bounded form: EXISTS ... collection_time <= floorLimit LIMIT 1 first. A hit means the database is done (see the method's doc comment for why that is
    exact: last_execution_time never exceeds its own row's collection_time). Otherwise it falls
    back to MIN(last_execution_time) bounded at collection_time > floorLimit, which ranges over the
    exact same rows the old unbounded MIN did once the EXISTS branch is ruled out.
  • Lite's twin (RemoteCollectorService.QueryStoreBackfill.cs) gets the identical shape: the same
    bound, the same hole-key merge, the same exact floor form, using the shared
    QueryStoreBackfillState.MergeHoleDatabases helper. Lite persists nothing (ruling item 3).
  • Stale comments fixed. The "inert" claim on the candidate predicate is corrected in two places:
    the inline comment, and ServiceCommandDeadlines.QueryStoreBackfillReadSeconds's doc comment, which
    also claimed the MIN "cannot be bounded." Both now say what is true after this change. The 120 s
    timeout value itself is unchanged: it is now a wide safety margin rather than a close one.
  • CandidateWindow (the unused 7-day constant) is removed.

Test plan

  • Darling/Darling.Tests/Darling.Tests.csproj builds with 0 Warning(s), 0 Error(s). (R1, unverified by R2)

  • Lite.Tests/Lite.Tests.csproj builds with 0 Warning(s), 0 Error(s). (R1, unverified by R2)

  • PerformanceMonitor.Darling.Tests.QueryStoreBackfillTests: 12/12 pass, including 3 new tests
    (the pure MergeHoleDatabases unit test, and the Darling + Lite SQL-shape pins). (R1, unverified by R2)

  • Lite.Tests.QueryStoreServerGateTests: 13/13 pass. (R1, unverified by R2)

  • Live test on a seeded hypertable with compressed chunks: new file
    Darling/Darling.Tests/QueryStoreBackfillCandidateLiveTests.cs.
    CandidateDatabases_ServicesAQuietHole_DropsAFinishedDatabase_FindsANewDatabase — a hole on a
    quiet database (quiet_hole_db) is still serviced, a finished database (finished_db, no hole
    key, nothing newer than the floor) drops out, a new database (new_db) is found, asserted as one
    exact-list equality so the candidate method's own exception-swallowing cannot pass on a dead
    read. StoredFloor_ARowAtOrBeforeTheFloor_ReturnsTheFloor_ABackfilledRow_ReturnsItsTrueMin —
    GetStoredFloorAsync returns floorLimit itself for quiet_hole_db (EXISTS hit) and the true
    backfilled last_execution_time (2026-05-01, not floorLimit, not collection_time) for
    new_db (EXISTS miss, falls through to the bounded MIN). Compression is bounded with
    older_than so only the old (2026-06-10) chunk compresses, proven against
    timescaledb_information.chunks.is_compressed before either test asserts anything else — the
    bare show_chunks() call the repair test's own pattern uses would have compressed the hot chunk
    too and made the plan check below impossible to pass. (R3a)

  • Plan check and EXISTS numbers:
    CandidateRead_BoundAtItsOwnHorizon_ScansNoCompressedChunk EXPLAINs
    QueryStoreBackfill.CandidateSql with LITERAL values substituted for $1/$2 in place of
    parameters (a parameterized EXPLAIN can defer chunk exclusion to executor startup and leave
    never-executed Decompress nodes in the text) — no DecompressChunk, no ColumnarScan, no
    mention of the compressed chunk's name, and at least one (the hot) chunk still scanned.
    StoredFloorExistsMiss_ForANewDatabase_WalksTheCompressedChunk_MeasuredCost runs EXPLAIN (ANALYZE, BUFFERS) on the EXISTS text for new_db (no rows at or before floorLimit, so it has
    to walk the compressed old chunk before concluding no match — a Custom Scan (ColumnarScan) on
    the compressed chunk is expected and pinned here, not a defect). Measured on this test's tiny
    3-row fixture: execution Buffers: shared hit=3, Execution Time: 0.099 ms; planning shared hit=796 read=4, Planning Time: 9.027 ms. Small in absolute terms because the fixture itself is
    tiny — the pinned fact is the SHAPE (the EXISTS miss does reach the compressed chunk), not the
    number, per the ruling not to design against it. (R3a)

  • Lite twin DuckDB fixture test: flipped GetBackfillCandidateDatabasesAsync and
    GetMinCollectedTimeForDatabaseAsync from private to internal (visibility only) and added
    Lite.Tests/QueryStoreBackfillCandidateTests.cs with the same three database cases and two floor
    facts as the Darling live test. Lite.Tests.QueryStoreBackfillCandidateTests: 2/2 pass. Verified
    both tests fail against dev's pre-Ring-buffer collectors re-derive their watermark with an unbounded MAX that decompresses every retained chunk each cycle; persist it at write time instead #4197 shape (no floorLimit/state parameters, no hole merge,
    an unconditional MIN with no EXISTS clamp) by temporarily reverting the bound/merge to that
    shape in a local build, confirming both failed, then restoring the fix and confirming green
    again. (R3b)

  • git merge origin/dev (Lite side): merged cleanly, no conflicts, pushed. (R3b)

  • Full Lite suite once, after the dev merge: Lite.Tests.exe — 5403/5403 pass, 0 errors,
    0 failed, 0 skipped, 343.4s. (R3b)

  • git merge origin/dev (Darling side): merged cleanly, no conflicts, pushed. (R3a)

  • Full Darling suite once, after the dev merge, on a dropped-and-recreated darlingtest:
    Darling.Tests.exe — 14056 total, 0 errors, 3 failed, 49 skipped (other PG clusters this rig
    does not configure), 1 not run, 757.8s. All 3 failures were pre-existing census/pin gaps the
    Ring-buffer collectors re-derive their watermark with an unbounded MAX that decompresses every retained chunk each cycle; persist it at write time instead #4197 SOURCE change (R1) left stale, not caused by this lane's test-only work:
    CommandPlaneCommandTimeoutTests (x2) — GetStoredFloorAsync now builds two NpgsqlCommands
    (the EXISTS pre-check plus the MIN fallback) instead of one, so the command-plane census went
    from 9 to 10 and the hardcoded ExpectedSiteCount = 9 was never bumped; and
    RepoFileAdoptionTests.ExactlyOneFile_DeclaresTheSharedRepoFileReader (x1) —
    QueryStoreBackfillTests.cs declared its own private ReadRepoFile instead of calling the
    shared RepoFile.ReadRepoFile, tripping the "exactly one declarer" adoption census. Both are
    mechanical and fixed here: bumped ExpectedSiteCount to 10 (plus its doc comment), and replaced
    QueryStoreBackfillTests.cs's two calls with RepoFile.ReadRepoFile and deleted the duplicate
    method and its now-unused usings. Re-verified with a targeted run of the 5 affected classes
    (CommandPlaneCommandTimeoutTests, RepoFileAdoptionTests, QueryStoreBackfillTests,
    QueryStoreBackfillCandidateLiveTests, RepoFileResolutionEquivalenceTests) post-fix: 25/25
    pass, 0 failed. Did not re-run the full 14056-test suite a second time after the fix (context
    budget) — flagging this explicitly so the coordinator can re-run it if a second full pass is
    wanted before merge. (R3a)

  • CI on 1e9ff7bc failed one test, DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks: the ExpectedSiteCount bump in CommandPlaneCommandTimeoutTests.cs added a second <summary> block. 3bc7f443 merges the two into one. DocCommentHygieneTests 77/77 and CommandPlaneCommandTimeoutTests 16/16 pass locally. CI decides the rest.

CHANGELOG entry

SECTION: Fixed
ENTRY:

🤖 Generated with Claude Code

https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ

erikdarlingdata and others added 8 commits September 25, 2026 11:40
…4197)

The candidate DISTINCT and the per-database MIN(last_execution_time) scan
both bound at CandidateWindow (7 days) or nothing at all, wider than raw
retention, so they decompressed every retained chunk on every tick (206 ms
/ 21,411 buffers measured on a rig's heavy server). Bind both at floorLimit
(HorizonFor) instead, the same boundary the loop already uses to decide
whether a database needs digging, and union the candidate list with every
database a hole key names so a database that goes fully quiet mid-hole is
still serviced instead of silently stranded. Mirrors the fix in Lite's
twin backfill worker so the two SKUs keep one rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Covers the four DARLING items from #4306's handoff plan: the candidate
read servicing a quiet hole while dropping a finished database and
finding a new one, GetStoredFloorAsync's two floor facts (an
at-or-before-floor row returns the floor itself, a backfilled row
returns its true bounded MIN), a plan-shape pin that the bounded
candidate read excludes the compressed old chunk entirely, and an
EXPLAIN (ANALYZE, BUFFERS) of the EXISTS miss's worst case (a new
database with nothing older than the floor, which has to walk the
compressed chunk before concluding no match).

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

Part of #4197 (lane A, Lite tests).

GetBackfillCandidateDatabasesAsync and GetMinCollectedTimeForDatabaseAsync
were private, unlike Darling's internal twins, so nothing outside the
class could assert on them. Flip both to internal (visibility only, no
logic change) and add a DuckDB fixture test covering the same three
database cases and two floor facts as the Darling live test:

- the candidate read keeps a quiet database with an open hole, drops a
  finished database with no recent rows, and finds a new database;
- a database with a row at or before floorLimit gets floorLimit back;
  a database without one gets the true MIN(last_execution_time) from a
  backfilled row (old last_execution_time, recent collection_time).

Verified both tests fail against dev's pre-fix shape (no floorLimit/state
params, no hole merge, unconditional MIN) by temporarily reverting the
bound/merge in a local build, then restored the fix and confirmed green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
GetStoredFloorAsync now builds two commands (an EXISTS pre-check and
a MIN fallback) instead of one, which bumped the command-plane site
census from 9 to 10; ExpectedSiteCount and the doc comments describing
it were not updated. QueryStoreBackfillTests.cs also carried its own
private ReadRepoFile copy instead of the shared RepoFile.ReadRepoFile,
tripping the "exactly one declarer" adoption census. Both are
mechanical: bump the constant, and call the shared reader instead of
a local duplicate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks failed CI on
CommandPlaneCommandTimeoutTests.cs:110: the #4197 note was a second <summary>
stacked on the original. It is now one summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 16:52
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 25, 2026 16:52
@erikdarlingdata
erikdarlingdata merged commit bf865ef into dev Sep 25, 2026
16 of 18 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4197-backfill-candidate-bound branch September 25, 2026 17:13
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