Repository navigation
After a big retention drain, warm each server's Query Store floor read in the background (#5581) - #5585
Merged
Conversation
erikdarlingdata
marked this pull request as ready for review
October 8, 2026 19:09
…ows deleted or a failed part warm, a drop-only pass does not (#5581)
This was referenced Oct 8, 2026
Closed
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Claude posting for Erik Darling.
Fixes #5581.
Why
#5572's row-capped drain removed the oldest rows of every server. The read gate's floor read (
MIN(first_execution_time) WHERE server_id = $1) walks the index from the oldest entry. It makes a heap fetch for each dead entry that vacuum did not remove and no read marked. On a large store that was 19.6 s and 854,535 heap fetches for one server, against 1.8 s before the drain. The first walk marks the dead entries, so the re-run took 15 ms. Each server paid this on its first Query Store read after the drain.What changes
QueryStoreIntervalFloorWarmUp(Darling.Service). After the retention pass's last purge and its run-record, it runs the shippedQueryStoreIntervalWide.PlainTableFloorSqlandQueryStoreIntervalLatest.PlainTableFloorSqlonce per server, one server at a time. It executes the constants by reference, so it walks the same index with the same plan as the gate.collect.servers(a tiny registry table). There is noSELECT DISTINCT server_idon the big table.SET lock_timeoutof 3 s. The per-read command timeout is a named constant (FloorReadTimeoutSeconds= 300, about 7x the slowest measured read of 38.9 s). The read takes only ACCESS SHARE. So a lock timeout means an ACCESS EXCLUSIVE request is queued (a schema upgrade step, or Query Store interval tables are partitioned by day #5573's partition step). That server is skipped and logged. A read that passes its command timeout is logged and skipped, but it has already marked the dead entries it walked past. The next pass continues from there.PlainTableFloorSqldocs in both storage files and the wide-table purge comment now say the floor read is not free right after a big row delete. They also say the dead entries are in the legacy table and the DEFAULT partition.Where it runs (ruling 2)
Both callers of
PurgeAsyncalready run it in the background_purgeTaskslot. Neither is awaited on the sweep loop:DarlingWorker.cs:3691TryStartScheduledPurge(... RunScheduledPurgeAsync ...)(fire-and-track), which callsPurgeAsyncatDarlingWorker.cs:11778.purge_now:RunPurgeNowBackgroundAsync,PurgeAsynccall atDarlingWorker.cs:11927, same slot.So the warm-up sits at the end of
PurgeWithPacerAsync, and the loop and the collectors never wait for it. It uses one connection, one server at a time, with no parallel reads. The cost of this placement is that the chores afterPurgeAsyncin the same task wait behind the warm-up. Those are findings cleanup, the log-file sweep, the module-map refresh and the chunk-interval reconcile. They wait once after a big drain (about 11 min on the large store). I kept it simple instead of threading the drain counts out throughPurgeSummary. Tell me if you want it moved to the end of both tasks.The limit
PostgreSQL marks an index entry dead only when its row is dead to every open snapshot. A transaction that was open before the drain ended keeps the entries live. Then the warm-up pays the walk and the next read pays it again. The warm-up cannot see or end such a transaction.
Autovacuum scale factor (item 6): no change
Autovacuum did start on the large store (17.9 M dead against a threshold of 14.1 M, which is 50 + 0.2 x 70.5 M live). The wide table keeps 9 days, so steady state is about one day of rows (about 11% of the table) dead per day. That is below the 20% trigger, so it fires roughly every second day. That is already frequent for a table with 15 GB of indexes.
A lower factor scans all 15 GB of indexes more often. It does not fix the reported problem, which was the read latency until the index pass ends (42 to 49 min on the large store). The warm-up fixes that directly. Space reclaim stays with autovacuum, and
PgTableTuning.csis untouched.Merge order
#5573 merged first (as 92cdf50), and this PR carries the wiring to its
PurgeIntervalTableAsync.Wiring after #5573
Dev's merge was clean as text but did not compile against this PR:
intervalWideDeletedandintervalLatestDeletedno longer exist.DarlingRetention.cs:1120: the call now passesintervalWidePartitionsandintervalLatestPartitions, the twoIntervalPartitionPurgeresults.QueryStoreIntervalFloorWarmUp.cs:115:RunAfterDrainAsynctakes the twoIntervalPartitionPurgeresults instead of two nullable counts. It still never throws and never fails the purge.QueryStoreIntervalFloorWarmUp.cs:149: newLeftDeadEntries. A table warms whenRowsDeleted > 0orFailed > 0. A pass that only dropped whole day partitions skips it, because a dropped partition takes its indexes with it.DarlingRetention.cs:823and:1110: comments now describe the partitioned tables (dead entries sit in the legacy table and DEFAULT, and the warm-up gate).QueryStoreIntervalWide.cs:459andQueryStoreIntervalLatest.cs:349: thePlainTableFloorSqldoc comments say the dead entries are in the legacy table and DEFAULT after Partition the two Query Store interval tables by day on first_execution_time, so retention drops a day instead of deleting its rows #5571.QueryStoreIntervalFloorWarmUp.cs:61: class doc gains a "Partitioned tables" paragraph.lock_timeoutskip still works against an ACCESS EXCLUSIVE lock on the parent, and the locked-table test passes on the partitioned tables.QueryStoreIntervalFloorWarmUpTests.cs): seeding setsautovacuum_enabled = falseon the legacy table (:479), since a partitioned parent has no storage options. The sweep pin follows the new call (:115). NewOnlyAPurgeThatDeletedRowsOrFailed_LeavesDeadEntries(:143, 6 cases) andAPassThatOnlyDropsWholePartitions_DoesNotWarm(:378).Comparison (large-store copy, #5581 comments)
Test plan
QueryStoreIntervalFloorWarmUpTests(17 tests now). The rig is on port 55550 with two servers and 20,000 expired rows each. The rows are seeded through the partitioned parent, so they land in the attached legacy table:PlainTableFloorSqlconstants, one reference each. It builds threeNpgsqlCommands (lock timeout, registry read, floor read), with noDISTINCTand no otherFROM collect.. The sweep calls it once, after the last purge, the last prune and the run-record.rows += 0). 4 of the 17 failed:TheRetentionPass_WarmsEachServersFloorRead...for both tables, the locked-table test and the server-list test. Restored.*Retention*,*QueryStoreInterval*,*FloorWarmUp*,*IntervalPartition*: Total 494, Failed 0, Skipped 2.*Census*,*Ratchet*,*DocCommentHygiene*: Total 570, Failed 0.Stage=Guard: Total 1420, Failed 0. Build 0 warnings.The 2 skips are
TheRetentionPass_WarmsEachServersFloorRead...(both tables) in the big run. Another class's open transaction on the same cluster held the horizon, so nothing could be marked (the limit above). The test skips by design. Run alone, the class passes 17 of 17 with 0 skipped.Note on the measure: on the test tables the planner picks a plain Index Scan, which reports no "Heap Fetches" figure. So the tests use the plan's total buffers (heap fetch plus index page per dead entry) and also bound "Heap Fetches" when a plan reports it. Only the first read per server counts, since EXPLAIN ANALYZE marks entries too. The index is ordered by
first_execution_timeacross servers, so the first server's walk also marks the others' entries.Coordinator double-checks
PurgeAsync(above).Failed > 0warms even when the failure was a drop or a relation probe that deleted no row. That is the brief's rule (a failed part can leave committed batches), at the cost of an occasional cheap warm-up.Proposed CHANGELOG entry (Fixed): The first Query Store read after a big retention drain is no longer slow ([#PR]): after the retention purge removed the oldest Query Store interval rows, each server's first floor read walked every dead index entry (19.6 s on a large store). The purge now runs that read once per server in the background when it finishes. It goes one server at a time and skips a server whose table is locked.