Repository navigation
Query Store interval tables stop cancelling their own autovacuum (#5589, #5594) - #5593
Merged
Merged
Conversation
erikdarlingdata
marked this pull request as ready for review
October 8, 2026 20:12
erikdarlingdata
marked this pull request as draft
October 8, 2026 20:20
erikdarlingdata
marked this pull request as ready for review
October 8, 2026 21:02
This was referenced Oct 8, 2026
Closed
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 #5589.
Fixes #5594. The #5594 section is below the #5589 test plan.
Why
While a Query Store interval table is not promoted, its legacy table is the whole live table. A monitored clock can put the newest
first_execution_timemore than one retention horizon ahead. In that state the arm tookLOCK TABLE ... IN ACCESS EXCLUSIVE MODEbefore it read the newest time. It then found the time too far ahead and rolled back. Both the hourly convergence step and the background loop did this every hour. Each lock request cancels a running autovacuum (unless it is an anti-wraparound run), so autovacuum on that table never finished.What changes
QueryStoreIntervalPartitions.ArmCoreAsyncnow reads the legacy table's newest time before it opens the lock transaction. A newReadLegacyMaxUnlockedAsyncruns the existingReadLegacyMaxAsyncin its own short transaction and rolls it back. The read keeps the same index checks and the same 5 s statement timeout, and it takes only an ACCESS SHARE lock.IsLegacyMaxBeyondHorizonis true for that read, the arm callsWarnFarFutureMax(once per table and time, as before). It then returns the sameNotReadyresult and requests no lock.IsLegacyMaxBeyondHorizonandArmCoreAsynccomments.What else runs in this state
NotReady), so no VALIDATE runs.NotReadyfor an unpromoted table and takes no lock.CREATE INDEX ... ON ONLY) and builds on the leaves thatpg_partition_treelists. V171 attaches the legacy table asMINVALUE..MAXVALUE, so it is a leaf andpg_partition_treelists it. V171 also attaches each valid legacy index ({name}_legacy) to the parent's index. So on an upgraded store whose indexes were valid, the ensure sees a child on the legacy table and skips it. It builds on the legacy table only when no valid index existed there (V171 drops an invalid one), and then once: the same build the plain table would have had.AnInvalidLeftover_IsDroppedAndRebuiltpins that case.Changelog
No separate entry. The partitioning (#5573) ships in the same release, and its entry stays true.
Test plan
New live test
AFarFutureLegacyRow_AnHourOfBothLoops_RequestsNoLock_WhileAnAutovacuumLikeLockIsHeld. A second connection holds SHARE UPDATE EXCLUSIVE on the legacy table, standing in for autovacuum. The test runs threeConvergeUnpromotedAsynccalls. It then runs three passes of the background loop body throughRunDelayedAsync, withRunPromotionAsyncandAnalyzeIfDueAsyncas in production. Every call returnsNotReadyin under 3 s. A third connection pollspg_locksevery 20 ms and never sees an ungranted lock on the legacy table. One far-future warning is logged, and no CHECK exists afterwards. The test takes about 2 s.Red-plant: with the pre-check disabled, the test fails with "converge ended RetryLater" after 348 s of lock waits. Restored.
New live test
ALegacyRowInsideTheHorizon_StillTakesTheArmLock_AndIsRetryLaterWhileAnAutovacuumLikeLockIsHeld. A newest time inside the horizon still goes to the locked path. It returnsRetryLaterwhile the lock is held and arms once the lock is released.Source pin
TheRound2Fixes_AreInTheProductTextupdated. The read under the lock is now the lastIsLegacyMaxBeyondHorizoncall. A new assertion pins the unlocked read and its horizon test (withWarnFarFutureMax) before the lock transaction.On head ffb3e28, PostgreSQL 18:
*QueryStoreIntervalPartitions**QueryStoreIntervalConvergence**IntervalPartition**Census**Ratchet**DocCommentHygiene*Stage=GuardBuild: 0 warnings, 0 errors.
Not run locally: the rest of Darling.Tests and Lite.Tests. CI runs them.
#5594: BRIN autosummarize cancelled the interval tables' autovacuum
Why
ix_query_store_interval_wide_collection_time_brinwas built withautosummarize = on(#4862). Each new block range queues an autovacuum "BRIN summarize" work item. The item waits for SHARE UPDATE EXCLUSIVE on the table, which a running VACUUM holds. Afterdeadlock_timeout(1 s) PostgreSQL cancels that VACUUM. On the store in the issue, a big drain left the table's vacuum cancelled every ~2 minutes and never finished.What I measured (throwaway PostgreSQL 18.6 rig)
Setup: a 1 GB table of 3 M rows with a BRIN on
collection_time. I deleted 90% of the rows and throttled autovacuum (autovacuum_naptime = 1, cost delay 10 ms, cost limit 100). A loop inserted 3,000 rows every 0.7 s, so new block ranges kept landing.pg_locksshowed a granted SHARE UPDATE EXCLUSIVE forautovacuum: VACUUM ANALYZEand an ungranted one forautovacuum: BRIN summarize, the same pattern as the issue.ALTER INDEX ... SET (autosummarize = off)while the vacuum was running. That one ALTER took 1,008 ms and caused one more cancel (the last one).The ALTER and the lock it takes
PARTITION OFandCREATE TABLEplusATTACHgive a leafautosummarize=onfrom anonparent. Attaching anoffchild to anonparent works.pg_locks). A running VACUUM holds ROW EXCLUSIVE on the table's indexes for its whole run, so the ALTER waits for it. Afterdeadlock_timeoutPostgreSQL cancels a regular autovacuum and the ALTER goes through. While the ALTER waits, writers and readers of that leaf queue behind it. The planner takes ACCESS SHARE on every index of a leaf it plans against (checked withpg_locks). Round 1 limits that wait, see below.lock_timeoutbelowdeadlock_timeoutwould rarely get the lock on the store in the issue, where a vacuum is almost always running.Rung or runtime: runtime
on. The leaves can be fixed at runtime, in the code that already owns them.QueryStoreIntervalBrin.TurnOffAutosummarizeAsyncreads the catalog for BRIN leaf indexes with autosummarize on, through the table's partition tree, and alters only those. It runs first in the hourly "query store interval partitions" pass, so it also runs on the start path before the collectors.autosummarize = off(QueryStoreIntervalWideBrinIndex, so the background ensure's parent and leaf builds too).autosummarize = off. V171 is unreleased (no tag contains it). No version bump. I looked for a migration-text census, hash or ladder pin on that literal and found none (MigrationLadderPinssearches column names and a view name only).query_store_interval_latesthas no BRIN. The DEFAULT partition gets a clone like any leaf, and the fix covers it.Explicit summarize: added
Without autosummarize, a range is summarized only when a VACUUM finishes, and an unsummarized range is always read. Measured on the rig, a 6 h window of a 24 h, 111 K-block table:
brin_summarize_new_valuesInsert-driven autovacuum fires after 20% growth, and a leaf that stops growing keeps its tail until another vacuum. So the tail is real, and every
collection_timeread through the BRIN pays for it.QueryStoreIntervalBrin.SummarizeNewRangesAsyncruns hourly on the existing background loop (QueryStoreIntervalPartitions.RunDelayedAsync, which already holds the daily analyze). It is not inDarlingRetention.cs.deadlock_timeoutat run time and uses alock_timeoutof half of it, at most 2 s. It does nothing whendeadlock_timeoutis under 200 ms.pg_stat_progress_vacuumshows being vacuumed, without asking for a lock. A role that cannot see the worker reads NULL there, and then thelock_timeoutdecides.brin_summarize_new_valuesdoes (both go through the same function withinclude_partial).collection_time. The cost is per block read, whichever reader it is.Lock steps on these tables
Only the first two rows were measured. The rest are read from the code and the lock modes. Nothing else on an hourly path requests a lock that waits on a vacuum when the state is already converged.
ALTER INDEX ... SET (autosummarize = off)(new)deadlock_timeout. A manual VACUUM or an anti-wraparound autovacuum is never cancelled, so the index is skipped with no lock request. Anything unseen:lock_timeoutismin(5 s, deadlock_timeout + 1 s)brin_summarize_range, one call per range (new, wasbrin_summarize_new_values)lock_timeoutis half ofdeadlock_timeout, and a vacuumed table is skippedANALYZEof the parentlock_timeout. Left as is: daily, not hourlyADD CONSTRAINTon legacyVALIDATE CONSTRAINTon legacyLOCK TABLEparent (every leaf),DETACHandATTACHlegacy,CREATEpartitionsCREATE TABLE,LOCKDEFAULT,ATTACHDarlingRetention.cs, not edited)CREATE INDEX IF NOT EXISTS ... ON ONLYparentCREATE INDEX CONCURRENTLYlock_timeout(7,200 s deadline). Left: once ever, and it is the build itselfALTER INDEX ... ATTACH PARTITIONlock_timeout280 sDELETE, writer upserts, readsChangelog
Probably no entry. Autovacuum was only starved on a store that had just drained a very large table, and #5573 (the same release) changes the tables anyway. An entry is worth it if 3.10's notes already mention #4862's autosummarize. Suggested text: "Fixed: autovacuum of the Query Store interval tables is no longer cancelled every couple of minutes after a large drain."
Test plan for #5594
QueryStoreIntervalBrinLiveTests(10 tests on the rig in the first round, 23 after round 1). An upgraded store withonindexes ends with every BRIN leaf off after the hourly step, promotion, create-ahead and a pass.RetryLaterin underdeadlock_timeout.pg_locksnever shows a waiter that long. Once the lock is gone it summarizes the tail, then finds nothing.deadlock_timeoutof 100 ms makes the summarize not run. The lock-wait rule is checked for sixdeadlock_timeoutvalues.onfailedTheBuildSql_..., bothWideBrinIndexLiveTestsensure tests and the fresh-store test. The create-ahead hook removed failed the upgraded-store test. The summarizelock_timeoutset todeadlock_timeoutplus 500 ms failed the held-lock test. Restored.*QueryStoreInterval*,*IntervalPartition*,*BackgroundIndex*,*Brin*: 312 tests, 2 skipped, 2 failed. The two failures were my expected text (pg_get_indexdefprintsautosummarize=offwithout quotes). I fixed it, and the two classes that hold them then passed, 20 of 20.*Census*,*Ratchet*,*DocCommentHygiene*,*StorageCommandTimeout*: 602 passed.Stage=Guard: 1,432 passed. Build: 0 warnings, 0 errors.Review round 1 (the #5594 part)
Head after this round: 594ad7d. Every finding is fixed. Measured on a throwaway PostgreSQL 18.6 rig (UTC,
pg_stat_statementsloaded).M1. The turn-off waited 5 s behind a vacuum it cannot cancel
age(relfrozenxid)ormxid_age(relminmxid)is past the smaller of the table's own and the server'sautovacuum_freeze_max_ageorautovacuum_multixact_freeze_max_age, or the worker's activity text ends in(to prevent wraparound)(round 2). Such an index is left on, the step returnsRetryLater, and the next pass tries again. A role that cannot see another role's worker reads NULL forpg_stat_progress_vacuumandpg_stat_activity. I checked with a plain role: the row is there, butrelidandbackend_typeare NULL. Then only (b) applies.lock_timeoutismin(5 s, deadlock_timeout + 1 s), read from the server in the same session. A regular autovacuum still gets itsdeadlock_timeoutto be cancelled plus a second to let go. On a default server a futile wait drops from 5 s to about 2 s.brin_summarize_rangetakes its SHARE UPDATE EXCLUSIVE locks inside the call and releases them when the call ends:pg_locksshows none held after it. So a summarize holds the index for one range at a time, not for its run. I ran it. An ALTER sent 1.5 s into a 10.7 s summarize of a 156 K-block table took 150 ms. The summarize still finished all 1,220 of its ranges. I first wrote apg_locksskip for "another backend holds SHARE UPDATE EXCLUSIVE". It could not see a summarize between ranges, so I removed it. The service's own summarize needs no skip, so there is none.L1. One lock timeout left every later index on
The turn-off now moves on to the next index after a lock timeout or a skipped one. It counts them and returns
RetryLaterafter the loop. ItsCountis the indexes it did alter, and the maintenance pass now counts those too. It also treats42P01(the leaf was dropped between the read and the ALTER) as nothing to do.L2. A stopped summarize can strand a placeholder range: confirmed, and fixed by stopping between ranges
On the rig: a 150 ms
statement_timeoutonbrin_summarize_new_valuesover a 47 K-block table left oneplaceholder = ttuple. A secondbrin_summarize_new_values(347 ranges) andbrin_summarize_rangeon that very block both left it. Onlybrin_desummarize_rangeon that block followed by a summarize healed it. A scan always reads a placeholder range. So each deadline hit costs one 128-block range that every query reads, for good.So I took the cheap way to stop between ranges. The hourly summarize is now one statement per index. It calls
brin_summarize_rangefor each range of the heap in block order, and each call sits behind a clock check against the 60 s budget. Rig: a 200 ms budget summarized 24 ranges and left no placeholder. The next pass did the other 342. Astatement_timeout30 s past the budget stays as a backstop for a stuck read. Only that can leave a placeholder, and the log says so. Cost of testing summarized ranges each pass: about 3 microseconds a range, so about 0.3 s for the 12.3 M-block table.When can the budget be hit? Measured speed was 14,600 to 36,000 blocks a second (a 156 K-block table took 10.7 s, a 47 K-block one 1.2 s). One hour of a day leaf's growth is a second or two. Only a tail nothing ever summarized reaches 60 s. The 12.3 M-block legacy table takes 6 to 14 minutes of reading, so several hourly passes. With the old call that was several stranded ranges.
L3. A dropped leaf aborted the table's step
The statement resolves the index once and reads no rows when it is gone, so the result is
(0, 0), not a NULL scalar that throws. A drop that lands after that surfaces asXX000(round 2 corrected this from42P01), and the step skips the index only when it no longer resolves, so the table's other indexes still run. The first part is tested. The race itself cannot be forced in a test.L4. Duplicate Information line
The Information log in the summarize is removed. The step runner's line stays.
L5. The two missing live tests, plus the new paths
RetryLater.pg_locksnever shows its request waiting. The summarize does the same ("skipped 1 being vacuumed", nothing waiting), and the next pass does the work once the vacuum is gone.deadlock_timeout + 1 s, rolls that index back, still turns off the day leaf that sorts after it (L1), and logs the Warning.(0, 0).brin_summarize_rangewith a clock check and notbrin_summarize_new_values; the holder read has the freeze-age tests; the wide rung saysautosummarize = off.falsefailed the manual-VACUUM turn-off test. The same in the summarize target read failed the summarize test. Areturnin place of moving on after a lock timeout failed the reader test. Restored.L6. V171's parent BRIN now says off
PgMigrations.cscreates the parent BRIN withautosummarize = off, so a leaf made byPARTITION OForATTACHclones off. No release tag contains V171 (git tag --contains 92cdf50fcis empty). The upgrade-from-V170 rung test now asserts the parent isoff. It also asserts the legacy copy staysonuntil the runtime step turns it off. On a fresh install the rung never creates the parent (it needs an existing legacy BRIN). The background build already saysoff. A store that already ran V171 keeps anonparent, so the runtime code stays.Nit
var oid =awaitinQueryStoreIntervalWideBrinIndexLiveTests.csnow has its space.Test plan, round 1
*QueryStoreInterval*284,*IntervalPartition*50,*BackgroundIndex*41,*Brin*43,*MigrationLadder*4,*MigrationUpgradeLadder*7: all pass.*QueryStoreIntervalBrinLiveTests*: 23 pass. With the three red-plants in, 3 fail.*Census*485,*Ratchet*15,*DocCommentHygiene*78,*StorageCommandTimeout*24,*StartupCommandTimeout*15,Stage=Guard1,432: all pass. Build: 0 warnings, 0 errors.What to double-check
QueryStoreIntervalBrin.csis long. It carries the measured numbers above.SummarizeStatementTimeoutSecondsis nowSummarizeBudgetSecondsplusSummarizeBackstopSeconds. Nothing else referenced it.Review round 2
Head after this round: 1502a4d. Measured on a throwaway PostgreSQL 18 rig (UTC,
pg_stat_statementsloaded). Nit 2 is accepted and nothing changed for it. Only the log count of an index whose walk timed out is lost. The ranges it did summarize stay summarized.L1. The anti-wraparound test could read "not anti-wraparound" for a vacuum that is
autovacuum_freeze_max_agesetting and the server's (relation_needs_vacanalyze). The age test now usesLEAST(table setting, server setting)for the XID pair and the multixact pair. A per-table value above the server's no longer hides a real anti-wraparound vacuum.pg_stat_activitycheck in the same EXISTS now also acceptsquery LIKE '%(to prevent wraparound)'. Autovacuum writes that text into its worker's activity for an anti-wraparound vacuum (autovac_report_activityinautovacuum.c, PostgreSQL 16 to 18). It also covers the multixact member-space limit, which lowers autovacuum's real limit and which SQL cannot read.L3. The age leg had no test
AntiWraparoundAgeSql, whichAutosummarizeOnSqluses. A live test evaluates it alone against three scratch tables. One has the 100,000 minimum, one the 2 billion maximum, and one no setting.current_settingearlier insearch_paththat answers 100,000 for the freeze setting. The 2 billion table then reads true.>to<in the XID leg fails the test at the first "reads false" check. ReplacingLEASTwithCOALESCE(the table's setting only) fails it at the smaller-limit check. Both were restored.TheStatementShapes_StopBetweenRanges_AndCountEveryVacuumThatCannotBeCancelled.L2. A leaf dropped during the walk aborted the table's summarize
brin_summarize_rangeraisesXX000("could not open relation with OID") when its index is gone, so the old42P01catch could never fire. The catch now takesXX000or42P01, rolls back, and asksto_regclassfor the index on the same connection. When it reads NULL, the index counts as dropped and the walk goes on to the next index. Otherwise the error is rethrown as before.IsDroppedIndexError(sqlState, indexStillExists). A unit test covers seven cases: both codes with the index gone (dropped), both with the index still there (rethrow), and other codes. There is no timing-dependent live test.Nit 1. The partial range comment
brin_summarize_rangedoes summarize the last, partial range (include_partial). The comment inQueryStoreIntervalBrin.cs, the two test comments that said otherwise, and a line in this body are fixed.Test plan (round 2)
*QueryStoreIntervalBrin*,*Brin*,*BackgroundIndex*,*Census*,*Ratchet*,*DocCommentHygiene*on a live PostgreSQL rig: 670 passed, 0 failed.Stage=Guard: 1,432 passed, 0 failed.