Repository navigation
Partition the two Query Store interval tables by day on first_execution_time, so retention drops a day instead of deleting its rows #5571
Copy link
Copy link
Closed
Labels
enhancementNew feature or requestNew feature or request
Description
Claude posting for Erik Darling.
The two Query Store interval tables are plain heaps, and retention removes their old rows with DELETE. On a large store this is the heaviest write the retention pass does. On the wide table it now fails on every pass (#5569). If each table is partitioned by day on first_execution_time, retention drops a whole day. It deletes no rows, makes no dead tuples and writes almost no WAL.
What happens today
- The tables:
collect.query_store_interval_wide(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) andcollect.query_store_interval_latest(V143, PLAN_REGRESSION's read deduplicates the whole raw Query Store slice every pass; keep the latest snapshot per interval as it is written #3953). Both arerelkind = r, so they are plain tables, not hypertables. - The purge:
DarlingRetention.TimeSlicedDeleteSqldeletes the oldest day past the horizon in one statement, underDeleteTimeoutSeconds= 300.- The wide table keeps 9 days (
QueryStoreIntervalWideRetentionDays). The latest table keeps 15 days.
- The wide table keeps 9 days (
- The other collector tables: the 71 raw collector tables are already hypertables in 1-day chunks, and retention drops their chunks. These two tables are the large exception.
- The original plan: the V143 summary says "The hypertable conversion, compression and retention are runtime work in
collection_log's shape, not this rung's." That conversion did not happen. Both tables are still plain.
Measured
On a store that monitors 43 SQL Server primaries, build 3.10.0-nightly.20261008.533, 10-08 about 14:10Z. All reads were read-only.
query_store_interval_wide |
query_store_interval_latest |
|
|---|---|---|
| Heap | 91 GB | 34 GB |
| Indexes | 15 GB | 15 GB |
| Live rows | 88.2 M | 97.9 M |
| Dead tuples | 1.8 M | 0.26 M |
| Rows per day (wide, measured 10-06) | 7.2 to 8.0 M | |
| Rows past the horizon now | 15.6 M (oldest 09-27 13:05) | 0 (oldest 09-26, horizon 09-23) |
- The wide purge has removed 0 rows since 10-06. It timed out on all 4 passes since then (The retention purge of the wide Query Store table times out on every pass on a large store and removes nothing #5569).
- Reading the expired keys alone is expensive. An index-only scan of the expired rows on the wide table took 120.8 s. It read 530,743 pages, made 3,775,762 heap fetches and dirtied 491,320 pages (3.8 GB) to set hint bits. The DELETE then adds the row writes, the WAL and the vacuum.
- The retention pass writes a lot of WAL. The whole pass wrote 6,790 MB at 13:09Z and 1,386 MB at 13:50Z. That is all tables, not only these two.
Why day partitions fit these two tables
- A row never changes its partition. The upsert never rewrites
server_idorfirst_execution_time. That is why its updates stay HOT (QueryStoreBackgroundIndexes, Plan regression day totals: each build reads the server's whole index slice on a large store #5507). - The upsert keeps working. PostgreSQL requires that a unique index on a partitioned table includes the partition key.
first_execution_timeis already in both unique indexes (ux_query_store_interval_wide,ux_query_store_interval_latest), soON CONFLICTstays valid. - Retention becomes
DROP TABLE(orDETACH PARTITION) of each expired day. That costs no row deletes, no dead tuples and no vacuum debt, and the time does not grow with the day's size.
Proposed shape
- Native declarative partitioning, one partition per UTC day, with no compression. Two TimescaleDB limits argue against a hypertable here:
- TimescaleDB refuses
CREATE INDEX CONCURRENTLYon a hypertable (QueryStoreBackgroundIndexes.Decide). The Plan regression day totals: each build reads the server's whole index slice on a large store #5507 index and later indexes need it. - Compression would work against the upsert. Open intervals are updated in place.
- TimescaleDB refuses
- Each partition keeps the parent's storage settings. For example, the latest table uses
fillfactor = 50, which was measured for HOT updates in PLAN_REGRESSION's read deduplicates the whole raw Query Store slice every pass; keep the latest snapshot per interval as it is written #3953. - Partitions are created ahead of time by the service, for example 2 days ahead. A
DEFAULTpartition catches a row whose day has no partition, so no insert is ever refused. - Retention drops each partition whose whole day is older than the horizon.
Upgrade path on a large store
- A rewrite in a migration rung is not possible. The startup migration cancels after
MigrationCommandTimeoutSeconds(300 s) of silence, and the wide table is 91 GB. - Proposed path, with no data copy:
- Create the partitioned parent.
- Attach the current table as one legacy partition for the range up to the switch day. First add a matching
CHECKconstraintNOT VALIDand validate it in the background, because a validated constraint lets the attach skip its scan and hold its lock only briefly. - New days go to new daily partitions.
- When the legacy partition's newest row is past the horizon (9 days for wide, 15 for latest), drop it in one statement.
- Until then, the legacy partition still needs the The retention purge of the wide Query Store table times out on every pass on a large store and removes nothing #5569 row-capped purge.
Costs to check
- Reads with no
first_execution_timebound touch every partition: about 10 for wide and 16 for latest. Query Store wide-table reads bound first_execution_time at the window end too (#5523) #5533 (Query Store top and history reads on the wide interval table take 10 to 50 seconds #5523) and Query Store reads on the stored interval tables no longer miss an interval that began just before the table's floor (#5541) #5543 (Query Store reads on the interval tables can miss an interval that started just below the table's floor #5541) added these bounds to many reads. The rest need an audit, with an EXPLAIN on a large store for each one. - Reads by
collection_timedo not prune. One example is the wide table's BRIN index oncollection_time. - Planning time grows with the partition count. At 10 to 16 partitions we expect this to be small. We have not measured it.
Not in scope
collect.query_plan_dimand the other dimension tables. They are purged onlast_seen, which moves forward each time a plan is seen again. Partitioning on it would move rows between partitions. Those tables need a different fix.- The retention purge of the wide Query Store table times out on every pass on a large store and removes nothing #5569's row-capped purge. That is the fix for the timeout itself. This issue removes the DELETE.
Acceptance
- Retention removes expired days of both tables by dropping partitions. It runs no row DELETE on them, except on the legacy partition until that partition is dropped.
- No upsert is refused when a day's partition is missing.
- An upgrade of a store with a 91 GB wide table finishes within the migration silence limit, with no data copy.
- The reads that bound
first_execution_timeshow partition pruning to the window's days, with an EXPLAIN on a large store for each one. - The HOT live tests for both tables still pass.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request
Activity
erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
This is the baseline script for the partition change. It runs the shipped reads of both interval tables against a large store as it is today, before any partitions exist. The same script runs again on the partitioned copy, and the two results are compared.
It also measures the one slow step of the upgrade. A background step checks every row of the old table once. Section 9 reads a 1% sample of the wide table, so 100 times its time estimates that check. The WAL it writes is recorded too.
Nothing in it writes data. The session is read-only, and each statement stops at 120 seconds. Set
sid, the two window times and the day start near the top first. Section 5 also needs a real database name and query id.Please paste back each plan's shape, buffers, planning time and execution time, and the results of sections 0 and 10.
explain-5571.sql
erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
This is the runbook for the restored-copy test of the partition change. The copy is a restored snapshot of a large store, on a scratch instance. It is never the live store. The test has two steps. Step 1 is the upgrade path from 3.9 to 3.10 with the purge fix from #5572. Step 2 is the partition change.
Before the service starts
\dt config.*.Step 1: upgrade from 3.9, and the purge fix
Build: the head of PR #5572 (the sha comes with the message that sends this link). Start the service against the copy.
Record:
Stop the service once the first retention pass ends. Record
pg_stat_user_tablesagain.Step 2: the partition change
Build: the head of the partition PR. It comes in a second message when it is ready.
seq_scanon both_legacytables before and after the rung (it must not change)log_min_messagesback towarningand reload.Tear the instance down afterwards.
erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
Baseline on today's unpartitioned tables is done. The slowest read is section 1, the Compose wide route, which has no
first_execution_timebound: 53.0 s for one day. Every other read takes 0.1 to 2.0 s. The 1% sample in section 10 took 67.6 s and wrote 0.42 MB of its own WAL. Times 100 that is 1.9 h, but I think that overstates a sequential VALIDATE (see section 10).Setup:
sid= the server with the mostquery_storerows in the last day.win_start/win_end=2026-10-07 14:00:00/2026-10-08 14:00:00.day_start=2026-10-07 00:00:00.Two changes to the script:
WAL, so the hint-bit WAL is the statement's own. The LSN delta also counts every other writer on the store. It was 138 MB in the same 67 s, against the statement's 0.42 MB.0. Shape facts
query_store_interval_widequery_store_interval_latestux_query_store_interval_wide11 GB,idx_query_store_interval_wide_first_exec1,769 MB,ix_query_store_interval_wide_server_first_exec2,559 MB,ix_query_store_interval_wide_collection_time_brin3,144 kB.ux_query_store_interval_latest11 GB,idx_query_store_interval_latest_first_exec1,913 MB,ix_query_store_interval_latest_server_first_exec2,422 MB.trg_plan_regression_daily_lateon the latest table (V168).pg_rewrite): 0 rows.1 to 9. The reads
collection_timeonlycollection_time. Lossy, 1,221,006 heap blocks. Hash Join toservers(43 rows)MINix_query_store_interval_wide_server_first_exec, Heap Fetches 20,607MINix_query_store_interval_latest_server_first_exec, Heap Fetches 0ix_…_wide_server_first_exec. Lossy, 59,244 blocksinterval_startarmix_…_wide_server_first_exec. Exact, 60,451 blocksux_query_store_interval_wide,collection_timeas a Filter (27 removed)ix_…_wide_server_first_exec. Lossy, 58,331 blocksix_…_latest_server_first_exec(#5507)ix_…_latest_server_first_execix_*_server_first_execNotes on the reads:
first_execution_timebound, so it can't prune partitions. Today the BRIN narrows it to the day's block ranges, and it still reads 9.2 GB. Over day partitions it would probe the BRIN of every partition.query_idis the fifth column ofux_query_store_interval_wide, afterruntime_stats_interval_idandplan_id, so the scan walks every entry for the server and database in thefirst_execution_timerange.10. The VALIDATE cost proxy
Sampling: system ('1'), 0 rows (Rows Removed by Filter 890,868)data_checksumswal_log_hints13E7/EA8CA608/13E7/F32CA000, 144,701,944 bytes for the whole store in that timeTABLESAMPLE SYSTEMreads a random 1% of blocks. That's random I/O: 885 MB in 66.8 s, about 13 MB/s.relallvisible11,135,869 of 11,946,817 pages), so few hint bits are left to set.Background steps for the day-partitioned Query Store interval tables (#…
Live tests for the day-partition background steps (#5571)
The Query Store interval tables become day-partitioned tables with th…
Run the day-partition live tests on the real V171/V172 rungs, drop th…
Wire the day-partition steps into the worker, retention drops days an…
Store size inventory sums the interval parents' partitions and leaves…
Merge origin/dev into the interval partitions branch (#5571)
Interval purge, store-metrics tests follow the partitioned tables (#5571
erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
This records a measured slow read, so the tracker shows it now. On a large store, the Custom Views read of the Query Store wide table took 53.0 s for a one-day window across all servers.
collection_timeonly. It has nofirst_execution_timebound, by design since Custom Views panels over a recent window scan raw hypertables with no bound; a Query Store panel spilled ~23 GB and tore collector writes #4605. With that bound, the planner picked a different index and the read was about 4 times slower.collection_time. The bitmap is lossy over 1,221,006 heap blocks. The scan read 9.2 GB from disk and returned 8,457,483 rows. The recheck removed 550,719 rows.This issue partitions by
first_execution_time, which cannot prune a read with nofirst_execution_timebound. The restored-copy test for this issue runs the same read again after partitioning. If it is still slow and the fix belongs to the partition change, the fix goes into #5573. If it does not, the read gets its own issue.The numbers are from the baseline run above (section 1).
The 13 tests that read the parent of the partitioned interval tables …
Retention, maintenance-pass and read-pruning tests for the partitione…
The retention pin class carries the Guard trait and the test kit reco…
erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
Step 1 on the restored copy: the row-capped drain cleared the whole wide-table backlog in one pass. That was 16,457,626 rows in 330 batches over 22 min. No batch came near the timeout: the slowest was 10.9 s against 300 s. The 3.9 to 3.10 rungs V166 to V170 took about 1.2 s.
Setup:
cdbaa5997, the full service, started 16:15:01Z against the copy.log_min_duration_statement = 0was on for the statement times below.Upgrade
Postgres store ready (schema v170, 5 migration(s) applied)at 16:15:03.241, 1.2 s after the config load at 16:15:02.037.ix_query_store_interval_latest_server_first_execbuilt in 184.5 s, 20 minutes after the start. That's in line with the live store's 190.5 s.The first retention pass
query_store_plan_mapquery_store_textquery_plan_dimquery_store_interval_latestquery_store_interval_wideRetention purge: 89 table(s) purged, 16936418 row(s) deleted, 0 chunk(s) dropped, 0 failed, 2852324ms; store WAL during the purge's batches: 11185 MB, paced 1218s at 4.8 MB/s.The wide drain, from the statement log (the
executelines of theDELETE … WHERE ctid IN (SELECT ctid … ORDER BY first_execution_time LIMIT 50000)batches):2026-09-27 13:05:08.887to2026-09-29 16:16:10.89, just above the cutoff2026-09-29 16:16Z.After the pass (
pg_stat_user_tables, counters since the copy started):Notes:
CREATE INDEX CONCURRENTLYscans the heap twice, which would explain 2. I haven't found the other 2.WITH src AS (…)read took 55.6 s, from 16:15:14. Its text is cut off in the log. My guess is the start-up module-map refresh that readsprocedure_stats, but I haven't confirmed it.Next: step 2, the partition build
2043dd3b2, on the same copy.The read-pruning test explains the SQL each shipped Query Store read …
Query Store interval partitions: the hourly step keeps the legacy CHE…
Live tests and source pins for the convergence step and the backgroun…
erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
Copy step 2b: the re-check after review round 1
The build is the #5573 branch at
0cd56725b, merged locally with the #5580 branch atff545c527(#5578). Nothing is pushed.Review round 1 changed how the two tables reach promotion. The arm step now reads the legacy table's newest
first_execution_timeunder its lock. The hourly pass promotes, or re-arms when S is less than 12 hours away. VALIDATE retries hourly. ANALYZE moved off the sweep loop. The V171 and V172 rungs did not change.1. The read the arm step now takes under its lock (read-only)
Expected: a backward index scan of
idx_query_store_interval_wide_first_exec_legacy, well under 5 seconds. A heap scan is a defect.2. From the service log after the restart
If step 2 already promoted both tables, this shows only the hourly pass. Item 1 still times the read.
3. #5578: start-up timings
The same two numbers from step 1's restart are the "before".
erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
Step 2 on the restored copy (
2043dd3b2): both tables are partitioned and promoted.MINon the wide table. That one is slow because step 1's drain left dead index entries, not because of the partitions.Setup:
log_min_messages = debug1was on for the rungs only. TimescaleDB background workers were 0.Rungs (statement log)
AccessExclusiveLockbehind an autovacuum of the wide table. The deadlock-timeout path cancelled the autovacuum (canceling autovacuum task … while scanning block 826051), and the rung then ran in about 80 ms.Postgres store ready (schema v172, 2 migration(s) applied)at 17:14:27.351, 1.46 s after the config load.partition constraint for table "…_legacy" is implied by existing constraints.AccessExclusiveLockon the whole table. A long read holdingAccessShareLock, such as the 50 s Compose read below, would hold the rung up, and the rung would block new reads while it waits. Here the only holder was an autovacuum, and PG cancelled it after about 1 s.First retention pass after the restart
89 table(s) purged, 478949 row(s) deleted, … 0 failed, 200636ms, with 756 MB of WAL.Background steps
2026-10-10 00:00:00Done (promoted at 2026-10-10 00:00:00)2026-10-10 00:00:00Donecollection_timeBRIN and bothserver_first_execindexes report0 leaf index(es) built and attached in 0.0s; parent valid.Layout after promotion:
_legacyFROM (MINVALUE) TO ('2026-10-10 00:00:00')_p20261010,_p20261011_default_legacy48 GB, the rest 32 kB each,_default0 rowsThe question from the review (does the legacy table hold any
first_execution_timeat or after S?):max(first_execution_time)is2026-10-08 12:47:11.383on both legacy tables, the snapshot time.>= '2026-10-10 00:00:00'returns 0 rows.Index Only Scan Backward using idx_query_store_interval_wide_first_exec_legacy, with 1 heap fetch, 5 buffers, 1.9 ms.Baseline script re-run on the copy (runbook 2.6)
sidand the same section-5 database and query as on the live store. Section 9 runs each EXPLAIN 5 times.2026-10-07 12:00to2026-10-08 12:00(live: 14:00 to 14:00).day_startis the same,2026-10-07.collection_timeonly)MINMINinterval_startarmSubplans Removed: 1: the filter pruned the legacy partition, so this no longer measures anything. The real VALIDATE times are in the table above.Read 2a is the one finding: a drain leaves a one-time cost on the first per-server floor read, until autovacuum finishes. My inference is that this will happen on large field stores once #5572 ships.
first_execution_timerange.MIN(first_execution_time) WHERE server_id = …walks the dead index entries, and makes a heap fetch for each.n_dead_tupon wide_legacy: 17,629,020.erikdarlingdata commentedon Oct 8, 2026
Claude posting for Erik Darling.
Step 2b on the restored copy (
a02dbfcb7, the #5573 + #5580 rehearsal merge): it built clean and started clean.NothingToDo (already promoted)on both tables.Setup:
query_store_interval_wide_legacy(from about 17:42Z, throttled) was running for this whole start.1. The read the arm step takes under its lock
From step 2, on the same tables (issuecomment-6065843886):
Index Only Scan Backward using idx_query_store_interval_wide_first_exec_legacy.max(first_execution_time)=2026-10-08 12:47:11.383on both legacy tables, and 0 rows at or after S.2. The service log after the restart
2026-10-10 00:00:00, which is 30 h away, outside the 12 h re-arm window.analyzed the parentline on this start, in a convergence step or anywhere else.3. #5578: start-up timings, three starts on the same copy
collection_logrow after the startWITH src AS (… FROM collect.procedure_stats …), up AS (INSERT INTO collect.module_map …)cdbaa5997(step 1)2043dd3b2(step 2)a02dbfcb7(2b)oversized_plan_sweep). It isn't a server collection.Where the refresh's 35 to 60 s goes:
procedure_statsread takes 5.3 s for 10.6 M rows.Query Store interval tables are partitioned by day (#5573)
erikdarlingdata commentedon Oct 8, 2026
Fixed by #5573, merged into dev. On the PostgreSQL store the two Query Store interval tables now hold one partition per day, so retention drops a whole expired day. The upgrade attaches the current table as a legacy partition and copies no data. It ships in 3.10. Claude posting for Erik Darling.