Repository navigation
The Query Store liveness touch costs ~13% of the Query Store–ingesting store's WAL (~33 GB/day) at its 6-hour guard: every touch is a non-HOT update #4250
Description
Related: #3189, #3210, #3216 (the touch guard). The guard's doc comment names write volume as its trade and records no measurement of it at the 6-hour width.
Problem
QueryStoreLivenessTouchGuard sets how often the touch-and-probe statements refresh last_seen on query_store_plan_map and query_store_text: GuardHours = StampSkewMarginHours / MarginShareDivisor = 24 / 4 = 6 h.
The comment on MarginShareDivisor states the divisor is "CHOSEN, not measured". It also says what the divisor trades: "WRITE VOLUME against stamp freshness". last_seen is indexed on both tables, so every touch is a non-HOT update: a new heap tuple, new b-tree entries, a dead tuple, and WAL for all of it. With data checksums and 5-minute checkpoints, most of those pages are imaged in full. Here is the write-volume side, measured.
Measured
Production SQL Server store A (43 servers, the Query Store–ingesting store), build 3.8.0-nightly.20260924.467. Steady window 2026-09-25 04:45–05:41Z (55.6 min), no restart inside.
pg_stat_statements:- plan-map touch-and-probe: 310 calls, 399,287 WAL records, 128,191 page images, 0.74 GB;
- text touch-and-probe: 308 calls, 280,439 records, 75,733 page images, 0.53 GB;
- together 1.28 GB = 13.5% of the store's WAL (9.45 GB), and 1.71 GB of buffers dirtied.
pg_stat_all_tables, same window:query_store_plan_map82,880 updates (0 HOT),query_store_text79,836 (31 HOT). That's ~163 k touches an hour, ~3.9 M a day, which matches the 6-hour guard over the referenced set.pg_waldumpper-relation sample (1.1 GB at 05:41Z): thequery_store_textheap 5.6%, thequery_store_plan_mapheap 4.1%, their pkeys 1.1%. Most of it is page images and hint images.- Per day: ~33 GB of WAL and ~44 GB of dirtied pages written back, on a store generating ~250–350 GB of WAL a day.
- The window straddling the 04:04Z restart had 3.73 GB, 23% of WAL: the first cycle after a start probes and touches the whole referenced set at once.
- Store B holds Query Store
READ_ONLYreplicas and stores nothing here.
Where
Darling/PerformanceMonitor.Darling.Storage/QueryStoreLivenessTouchGuard.cs:MarginShareDivisor:133,GuardHours:141,StampSkewMarginDays:102.Darling/PerformanceMonitor.Darling.Storage/QueryStorePlanMap.cs:170,TouchAndProbeSql;last_seenindex :68.Darling/PerformanceMonitor.Darling.Storage/QueryStoreTextStore.cs:114,TouchAndProbeSql;last_seenindex :65.
Fix shape
Pick by what the margin can afford. All three cut writes roughly in proportion.
- Widen the guard.
- Raise
QueryStorePlanMap.PruneMarginDaysfrom 1 to 2, matching the text store. ThenMarginShareDivisor4 gives a 12 h guard and halves the touches, with the same ¼-of-margin headroom the comment asks for. - Or keep the margin and move the divisor to 2.
- Raise
- Make the touch HOT.
- Drop the
last_seenindexes and prune by a batched sequential scan on the daily purge (the tables are keyed stores, not hypertables). With afillfactorbelow 100, alast_seen-only update stays on-page with no index entries. - Measure the purge's cost before and after.
- Drop the
- Spread the post-restart wave. The first cycle touches everything at once, which is 23% of WAL in that hour. It could jitter the touch by server or by key hash.
- Pin:
- the guard derivation test already exists; add the measured write volume to the divisor's comment, so the next reader has both sides of the trade;
- if the index is dropped, a live test that a touch is HOT (
n_tup_hot_updincrements).
Activity
erikdarlingdata commentedon Sep 25, 2026
Ruled: how this gets built
Two changes now. The third waits for a measurement.
QueryStorePlanMap.PruneMarginDaysfrom 1 to 2, matching the text store. WithMarginShareDivisorat 4, the guard becomes 12 hours and the touches halve. Add this issue's measured write volume to the divisor's comment, so the comment states both sides of the trade.last_seenis already inside the guard. Then a restart does not rewrite fresh rows. If the statement cannot skip them without locking them, spread the first cycle by key hash instead. The PR says which.last_seenindexes, set a lower fillfactor, purge by a batched scan). It needs a migration and a purge redesign. Decide it after a day of measurement with 1 and 2 installed. So the PR says "Part of The Query Store liveness touch costs ~13% of the Query Store–ingesting store's WAL (~33 GB/day) at its 6-hour guard: every touch is a non-HOT update #4250", and this issue stays open for that decision.Pins:
🤖 Generated with Claude Code
https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata commentedon Sep 25, 2026
Correction to the ruling above
I checked two parts of it against the code after posting, and both were wrong.
QueryStorePlanMap.MarginOrderingHoldsrequires the map'sPruneMarginDaysto stay below the dimension's margin,ChunkIntervalDays + 1, which is 2. A map margin of 2 fails that pin. Worse, it lets a dimension row age out before its map row, which is the silent missing-plans case.QueryStoreLivenessTouchGuard.MarginShareDivisorfrom 4 to 2. The guard becomes 12 hours, and the touches halve.WHERE last_seen < $5 - interval '6 hours'. It reads the storedlast_seen. No state is kept in memory. So the first cycle after a start skips every row stamped within the guard.Item 3 stands as posted. The pins stand, with "the new margin" read as "the new divisor", and
MarginOrderingHoldsunchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
#4250: live pin for the touch guard's restart-safety claim
Skip locking fresh dimension rows; halve the Query Store liveness-tou…
erikdarlingdata commentedon Sep 25, 2026
#4288 merged to dev (dfb4fbd): items 1 and 2 are done. The Query Store liveness touch's guard is now 12 hours, not 6. Item 3, the HOT-only touch, is still open and waits for its own measurement.
erikdarlingdata commentedon Sep 26, 2026
Claude posting for Erik Darling
Measured after #4288 (the 12-hour guard): the steady-state touch cost is now about 0.6% of the store's WAL, against 13.5% before. A restart still produces a burst. Item 3, the HOT-only touch, waits for a 24-hour delta.
Production SQL Server store (43 servers, the Query Store–ingesting store), on a build that includes #4288.
pg_stat_statementsdeltas, 2026-09-26:erikdarlingdata commentedon Sep 27, 2026
Claude posting for Erik Darling
The 24-hour delta: the Query Store liveness touch now costs about 9.9 GB of WAL a day, 6.4% of the store's WAL. Before #4288 it was ~33 GB a day (13.5%). The 12-hour guard cut the touch's WAL by about 70%. But the steady share is not near 1%, which was this issue's bar for closing without item 3. So item 3, the HOT-only touch, is warranted, and this issue stays open for it.
This corrects the 2026-09-26 comment's "about 0.6%". That 15-minute window fell in the quiet part of the 12-hour cycle, soon after the restart had touched every referenced row. The whole set comes due again 12 hours later, so a day holds two full waves. The comment named this limit ("A 15-minute window can't show the 12-hour guard's full cycle"), and the 24-hour read measures it.
Production SQL Server store (43 servers, the Query Store–ingesting store), on a build that includes #4288.
pg_stat_statements, 2026-09-26 04:13Z → 2026-09-27 04:14Z.checkpoint_timeout15 min throughout.pg_stat_all_tables, same window:query_store_plan_map: 741,748 updates, 0 HOT, 34,381 moved to a new page;query_store_text: 691,239 updates, 429 HOT, 65,769 moved.Why item 3 is the lever [inferred, not measured]. The guard can't widen further within the prune margin (the 2026-09-25 correction's reasoning), so the number of touches a day is now fixed at about two per referenced row. What's left to cut is the cost per touch. A HOT update touches no index. With fillfactor room, it stays on its heap page, which removes the index-page images and most of the new-page moves. How much that saves needs its own measurement; I haven't estimated it here.
Scheduling: item 3 needs a migration and a redesign of the purge, which today relies on the
last_seenindexes. It waits until after the upcoming release, and it gets its own design decision before any work starts.Replace the map/text prune's 3-scan shape with a one-scan row-capped …
Pin the Query Store prune's drain through the real purge path
The Query Store liveness touch updates rows in place: drop the last_s…
erikdarlingdata commentedon Sep 27, 2026
Claude posting for Erik Darling.
Item 3 is merged as #4472 (4fb8962). Store migration V149 drops the last_seen indexes on collect.query_store_plan_map and collect.query_store_text and sets fillfactor 90, so the liveness touch can update rows in place. The retention prune for both tables is now one capped scan per batch. On a test store, the touch wrote 23% less WAL per touch on plan_map and 35% less on text. A steady-state prune batch took 190 ms and 483 ms, against 819 ms and 1,888 ms for the old shape.
This issue stays open. The saving in the field is not measured yet, and a 24-hour pg_stat_statements delta after the next install closes it.
erikdarlingdata commentedon Sep 30, 2026
Claude posting for Erik Darling
Measured in the field. The fix works; closing.
Production SQL Server store A (43 servers, the Query Store–ingesting store):
pg_stat_statementsandpg_stat_all_tablesdeltas over 2026-09-29 21:25Z → 2026-09-30 14:40Z (17.2 h), on builds carrying V149.stats_resetwas unchanged anddealloc0. The store restarted once inside the window (onto a newer nightly). A start's first cycle touches the whole referenced set, so these figures include that burst and slightly overstate steady state. Total store WAL over the window, by LSN: 99.2 GB.query_store_plan_mapHOT updatesquery_store_textHOT updatesWhat remains: the plan-map touch still costs ~8.5 KB per row. That is heap full-page images written the first time each page is touched after a checkpoint. HOT removes the index writes but not those images, and the plan-map rows the touch visits are scattered across pages. The lever for the rest is the guard's width (
MarginShareDivisor, write volume against stamp freshness), which is a design choice rather than a defect, so nothing is filed.