Skip to content

query_store plan/text fetch goes activity-driven: the store is the watermark (#2312) - #2333

Merged
erikdarlingdata merged 2 commits into
devfrom
feat/2312-activity-driven-plan-fetch
Aug 19, 2026
Merged

query_store plan/text fetch goes activity-driven: the store is the watermark (#2312)#2333
erikdarlingdata merged 2 commits into
devfrom
feat/2312-activity-driven-plan-fetch

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #2312. ⚠ HOLD FROM MERGE until 3.5.0 cuts — the release candidate is soaking on the box from dev @ 9f01385, and this must not enter the release build. Review rounds proceed as usual; the merge waits.

The mechanism, finally named (Findings 3 & 4, on the issue)

The #2210 second-generation design had four moving parts. Three were built, tested, documented, and never wired: the adaptive sizing (Finding 1, wired in #2322), TouchSql liveness (Finding 3 — 'the whole of the protection' for Query Store digests, zero callers), and the re-verify cursor (Finding 4 — only tests invoke it). What ran in production was the budgeted walk alone, with Resolve's daily expiry still armed: catalogs whose full walk needs more than a day (measured 15.9–107.5 hours) expired MID-walk, restarted from plan_id 0, and looped the full catalog fetch forever. That is the 40–110s-per-cycle bill — and the loop's re-upserts were also accidentally substituting for the unwired liveness refresh, which is why killing the walk and wiring the touch must land together.

The reshape

  • The cycle's collected rows name their plans/texts. One touch-and-probe round trip per database (the extended TouchSql) refreshes map+dim last_seen AND returns per-id resolved/hash_stale — the missing set is the fetch list, and the live hashes the payload already carries give per-cycle in-place-rewrite detection (plans) and Query-Store-reset detection (texts), retiring both the cursor and the reset arm.
  • Fetch by id list (BuildPlanFetchByIdsQuery/BuildTextFetchByIdsQuery), chunked ascending, same running-bytes budget with the oversized-admission arithmetic intact, hash columns riding along (no decompression). A caught-up database issues no target query at all — the 23s-to-discover-nothing becomes nothing.
  • V77: query_store_plan_map.digest nullable (NULL-XML plans land a content-less marker so they read as known instead of riding every fetch list — the posted design's one gap), query_store_text.query_hash (reset detector; legacy rows adopt the live hash on first touch, zero refetches), and wholesale deletion of the orphaned planwm:/textwm: rows. Full rung ceremony including the viewer probe arm and the V74/V75/V76 pin-file demotions.
  • Upserts COALESCE toward knowledge (a NULL refetch never erases stored content or hashes). Budget-deferred ids carry over in memory so a once-referenced plan cannot starve; target-side-gone ids drop only on a provably-uncut pass (inside a cut pass, 'absent from result' and 'excluded by the budget predicate' are indistinguishable).

Honesty notes

Tests

  • QueryStorePlanWatermarkTests reborn as QueryStorePlanFetchTests: by-ids builder shape + guards (both fetches), touch-and-probe SQL pins (hourly guard, hash adoption, NULL-digest-resolves, stale expression, dim-touch NULL filter), upsert COALESCE pins, the surviving sizing decision tables, and the read-loop stages-no-state pin. The builders were additionally executed against the built assembly in a throwaway harness (escaping, id lists, budgets, all four guard throws — pass).
  • ActivityDrivenPlanFetchStoreTests: the V77 rung ceremony (ladder top, three strokes, probe arm, three-places-agree).
  • QueryStoreFetchProbeLivePostgresTests (gated): live round-trip of probe verdicts (missing/marker/current), liveness stamping on map AND dim, hash adoption then stale detection, and the writer's NULL-digest marker.
  • Prune suites rewired to the surviving qsowm: family on both SKUs; QueryStoreTextWatermarkTests retired with its subject.

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits August 18, 2026 23:41
…termark

The invariant 40-110s-per-run bill (#2312) had a named mechanism at last:
the #2210 watermark walk's daily expiry was supposed to be replaced by a
re-verify cursor that was built, tested, documented, and never wired
(Finding 4) - so catalogs whose full walk needs more than a day expired
MID-walk, restarted from plan_id 0, and looped the full catalog fetch
forever. TouchSql, the liveness refresh the dimension GC depends on, had
the same story: designed as 'the whole of the protection', zero callers
(Finding 3), latent only because the perpetual walk's re-upserts were
accidentally standing in for it.

The reshape retires all of it in one shape change: the cycle's collected
rows name their plans/texts; one touch-and-probe round trip per database
refreshes map/dim liveness AND returns the missing set plus per-cycle
in-place-rewrite / Query-Store-reset detection via the live hashes the
payload already carries; the fetch selects exactly those ids under the
same byte-budget arithmetic. A caught-up database issues NO target query.
A reset recovers as the normal path. A dormant plan resuming execution is
fetched the cycle it resumes.

V77: query_store_plan_map.digest goes nullable (the NULL-XML content-less
marker, so unpersistable plans read as known instead of riding every
fetch list), query_store_text gains query_hash (the reset detector), and
the orphaned planwm:/textwm: state rows are deleted wholesale. Budget-
deferred ids carry over in memory so a plan referenced once cannot starve;
target-side-gone ids drop only on a provably-uncut pass. Upserts COALESCE
digest and hash toward knowledge, never absence.

Retired: QueryStorePlanXmlState's watermark half (sizing stays - it caps
server-side decompression), QueryStoreTextState wholesale, the cursor
members, and the planwm:/textwm: entries in the shared prune set. Tests:
QueryStorePlanWatermarkTests is reborn as QueryStorePlanFetchTests (new
builder/probe/upsert shape pins + the surviving sizing tables, harness-
executed against the built assembly), the V77 rung ceremony lands in
ActivityDrivenPlanFetchStoreTests with the V74/V75/V76 files demoted, and
QueryStoreFetchProbeLivePostgresTests round-trips the probe verdicts,
liveness stamping, hash adoption, and the NULL-digest marker against a
real store.

Closes #2312

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…metic, and two source pins

Both splice edits landed between a member's doc block and its body - the
fetch method's doc stranded above the new extractor helpers, and V76's rung
doc stranded above the V77 insertion. Third instance of this exact trap;
both docs now sit with their members and a local scan mimicking the hygiene
test confirms zero stacks. The seed candidate cap pin claimed the issue
text's rounded ~118 where ceil(12MB/160KB*1.5) is 116. The text call-site
literal QueryStoreTextStoreTests pins is restored to one line, and the
runner's comments stop naming the retired prefixes literally so the
retirement source pin can hold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 8c79f0c into dev Aug 19, 2026
6 checks passed
erikdarlingdata added a commit that referenced this pull request Aug 19, 2026
With #2333's catalog walk gone, the per-database split named the phase that
does not subside after catch-up: wm, 1-3s per database per cycle. It is a read
against OUR store, not the monitored server -- an unbounded MAX over a
non-partitioning timestamp, so it touches every chunk that database has.

Measured on the live use1 store (106 GB, 5 chunks), same server/database pair:

  unbounded, cold: 25,766 buffer reads + 195 written (temp spill)
  unbounded, warm: 228 ms
  bounded to 3h:    29 ms, 5 chunks excluded

The unbounded cost is a function of store size and cache residency rather than
of the monitored workload, so it degrades precisely where an operator is
weakest: a long-lived store, a busier Query Store, slower disks.

The bound changes no answer, and that is the whole justification: every
consumer ends at max(stored, now - MaxCatchup), because ClampCatchup floors
anything older and a null result falls back to query_store's 60-minute
first-run window -- the same instant as the floor. So a row below the horizon
cannot move the result whether it is found or not, and the unbounded MAX was
paying to confirm a value the clamp would have produced anyway. The predicate
goes on collection_time (the partitioning column) because a predicate on the
watermark column alone prunes nothing, and a row's watermark can never exceed
its own collection_time, so nothing qualifying hides behind it.

Bounded for query_store ONLY, on both hosts, name-guarded like the other
query_store-specific behavior in these runners: a ring-buffer collector whose
legitimate catch-up spans days must keep reading its whole history, and the
floor would silently truncate it. WatermarkPolicy.ReadFloor carries the
reasoning and the measurements; its tests pin that the floor sits strictly
older than the clamp horizon and that a buried watermark and a not-found one
reach the same instant.

Closes #2344

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Aug 19, 2026
With #2333's catalog walk gone, the per-database split named the phase that
does not subside after catch-up: wm, 1-3s per database per cycle. It is a read
against OUR store, not the monitored server -- an unbounded MAX over a
non-partitioning timestamp, so it touches every chunk that database has.

Measured on the live use1 store (106 GB, 5 chunks), same server/database pair:

  unbounded, cold: 25,766 buffer reads + 195 written (temp spill)
  unbounded, warm: 228 ms
  bounded to 3h:    29 ms, 5 chunks excluded

The unbounded cost is a function of store size and cache residency rather than
of the monitored workload, so it degrades precisely where an operator is
weakest: a long-lived store, a busier Query Store, slower disks.

The bound changes no answer, and that is the whole justification: every
consumer ends at max(stored, now - MaxCatchup), because ClampCatchup floors
anything older and a null result falls back to query_store's 60-minute
first-run window -- the same instant as the floor. So a row below the horizon
cannot move the result whether it is found or not, and the unbounded MAX was
paying to confirm a value the clamp would have produced anyway. The predicate
goes on collection_time (the partitioning column) because a predicate on the
watermark column alone prunes nothing, and a row's watermark can never exceed
its own collection_time, so nothing qualifying hides behind it.

Bounded for query_store ONLY, on both hosts, name-guarded like the other
query_store-specific behavior in these runners: a ring-buffer collector whose
legitimate catch-up spans days must keep reading its whole history, and the
floor would silently truncate it. WatermarkPolicy.ReadFloor carries the
reasoning and the measurements; its tests pin that the floor sits strictly
older than the clamp horizon and that a buried watermark and a not-found one
reach the same instant.

Closes #2344

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Aug 19, 2026
With #2333's catalog walk gone, the per-database split named the phase that
does not subside after catch-up: wm, 1-3s per database per cycle. It is a read
against OUR store, not the monitored server -- an unbounded MAX over a
non-partitioning timestamp, so it touches every chunk that database has.

Measured on the live use1 store (106 GB, 5 chunks), same server/database pair:

  unbounded, cold: 25,766 buffer reads + 195 written (temp spill)
  unbounded, warm: 228 ms
  bounded to 3h:    29 ms, 5 chunks excluded

The unbounded cost is a function of store size and cache residency rather than
of the monitored workload, so it degrades precisely where an operator is
weakest: a long-lived store, a busier Query Store, slower disks.

The bound changes no answer, and that is the whole justification: every
consumer ends at max(stored, now - MaxCatchup), because ClampCatchup floors
anything older and a null result falls back to query_store's 60-minute
first-run window -- the same instant as the floor. So a row below the horizon
cannot move the result whether it is found or not, and the unbounded MAX was
paying to confirm a value the clamp would have produced anyway. The predicate
goes on collection_time (the partitioning column) because a predicate on the
watermark column alone prunes nothing, and a row's watermark can never exceed
its own collection_time, so nothing qualifying hides behind it.

Bounded for query_store ONLY, on both hosts, name-guarded like the other
query_store-specific behavior in these runners: a ring-buffer collector whose
legitimate catch-up spans days must keep reading its whole history, and the
floor would silently truncate it. WatermarkPolicy.ReadFloor carries the
reasoning and the measurements; its tests pin that the floor sits strictly
older than the clamp horizon and that a buried watermark and a not-found one
reach the same instant.

Closes #2344

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Aug 19, 2026
…ally use (#2344) (#2346)

With #2333's catalog walk gone, the per-database split named the phase that
does not subside after catch-up: wm, 1-3s per database per cycle. It is a read
against OUR store, not the monitored server -- an unbounded MAX over a
non-partitioning timestamp, so it touches every chunk that database has.

Measured on the live use1 store (106 GB, 5 chunks), same server/database pair:

  unbounded, cold: 25,766 buffer reads + 195 written (temp spill)
  unbounded, warm: 228 ms
  bounded to 3h:    29 ms, 5 chunks excluded

The unbounded cost is a function of store size and cache residency rather than
of the monitored workload, so it degrades precisely where an operator is
weakest: a long-lived store, a busier Query Store, slower disks.

The bound changes no answer, and that is the whole justification: every
consumer ends at max(stored, now - MaxCatchup), because ClampCatchup floors
anything older and a null result falls back to query_store's 60-minute
first-run window -- the same instant as the floor. So a row below the horizon
cannot move the result whether it is found or not, and the unbounded MAX was
paying to confirm a value the clamp would have produced anyway. The predicate
goes on collection_time (the partitioning column) because a predicate on the
watermark column alone prunes nothing, and a row's watermark can never exceed
its own collection_time, so nothing qualifying hides behind it.

Bounded for query_store ONLY, on both hosts, name-guarded like the other
query_store-specific behavior in these runners: a ring-buffer collector whose
legitimate catch-up spans days must keep reading its whole history, and the
floor would silently truncate it. WatermarkPolicy.ReadFloor carries the
reasoning and the measurements; its tests pin that the floor sits strictly
older than the clamp horizon and that a buried watermark and a not-found one
reach the same instant.

Closes #2344

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