Repository navigation
Ring-buffer collectors re-derive their watermark with an unbounded MAX that decompresses every retained chunk each cycle; persist it at write time instead #4197
Description
Problem
Before each run, the server-scoped watermark read, SELECT MAX(<col>) FROM <table> WHERE server_id = $1, recomputes the newest value the store already holds for that server. #2344 and #2795 bounded it on collection_time for query_store, where the catch-up clamp makes a bound provably harmless. They deliberately left the ring-buffer collectors unbounded (default_trace_events, system_health_events, job_history, the deadlock/XE families and others), because a bound would truncate a legitimate multi-day catch-up. That reasoning is right. The consequence is that every cycle, for every server, the read decompresses every chunk in retention, so its cost grows with the retention window and the fleet, not with the work.
The value being recomputed is one the writer already had in hand: the maximum of the batch it just committed.
Measured
Production SQL Server store A (43 servers), build 3.8.0-nightly.20260924.467, 2026-09-25:
EXPLAIN (ANALYZE, BUFFERS)ofSELECT MAX(event_time) FROM default_trace_events WHERE server_id = …(one server, quiet minute):Parallel Appendover all 32 chunks: 30ColumnarScandecompressions plus 2 heap chunks;- ~903 k rows aggregated to return one timestamp;
- 106 ms,
shared hit=2730 read=2670.
get_store_query_stats(owner role, ~5 h since restart):default_trace_events: 1,875 calls, mean 344 ms, max 21.4 s, 8.9 M blocks read;system_health_events: 1,875 calls, mean 343 ms, max 15.9 s, 2.3 M blocks read;- store B:
job_historyMAX(run_datetime), 2,073 calls, mean 87 ms, max 4.9 s, 2.0 M blocks read.
- Same class, different read:
QueryStoreBackfill.GetCandidateDatabasesAsync,SELECT DISTINCT database_name FROM query_store_stats WHERE server_id = $1 AND collection_time > $2. Its own comment says it is "unbounded across every chunk that exists". Store A: 1,781 calls, mean 915 ms, max 39 s, 14.4 M blocks read.EXPLAINon a quiet minute: 168 ms,shared hit=176417across all chunks, returning 1 row.
The quiet-minute times are small. What matters is the per-cycle multiplier, the tail (15–39 s maxima, when these land inside a checkpoint or refresh), and the growth with retention.
Where
Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs:BuildServerWatermarkSql/BuildServerWatermarkPairSql~:3480–3500 (the unbounded arm) and their callers ~:1355–1372 (theQueryStoreCollectorname guard for the read floor);GetLastCollectedInstanceIdAsync~:5170–5185 (job_history, numeric).
Darling/PerformanceMonitor.Darling.Service/QueryStoreBackfill.cs~:462–475,GetCandidateDatabasesAsync.collector_statealready exists as a per-(server, collector) key/value store (SaveCollectorStateAsync).
Fix shape
- Persist the watermark at write time. In the batch writer, after the COPY commits, upsert
collector_state(server_id, collector, 'watermark') = GREATEST(existing, max(batch.<watermark column>)). That's the same transaction or immediately after. The read becomes a primary-key lookup.- Correctness is unchanged: it's the same maximum, and it no longer depends on retention. A watermark older than a purged chunk still answers correctly, where today the purge can move
MAXbackwards. - Fall back to the current
MAXread only when the state row is absent (first run after upgrade, or a store restored without state), then write the result.
- Correctness is unchanged: it's the same maximum, and it no longer depends on retention. A watermark older than a purged chunk still answers correctly, where today the purge can move
- Record the database list.
GetCandidateDatabasesAsynccan read the same list from a small per-server set the Query Store writer maintains, or from the latestdatabase_statessnapshot, instead of aDISTINCTover raw. - Pin:
- a live test that the second cycle's watermark read touches no hypertable chunk (
collector_stateonly); - that the persisted value equals
MAX()over the table after N writes; - a purge-then-read case where
MAX()would regress but the state doesn't.
- a live test that the second cycle's watermark read touches no hypertable chunk (
Activity
erikdarlingdata commentedon Sep 25, 2026
Lane S3b, diagnosis-only follow-up on
QueryStoreBackfill.GetCandidateDatabasesAsync(the "same class, different read" this issue also names).The shipped predicate is not always inert - it depends on whether continuous aggregates are active.
GetCandidateDatabasesAsync's SQL already carriescollection_time > $2withCandidateWindow= 7 days. Its inline comment says this is inert becauseCandidateWindowis "wider than raw retention." That claim is precise, not vague:RetentionTierRouter.RawMaxAge(rawquery_store_statsretention once continuous aggregates take over history,TimescaleSupport.RawRetentionSpan= 4 days minus a routing margin) is narrower than the 7-day window. On a store with continuous aggregates active,query_store_statsnever holds data older than ~4 days, so every row already satisfies the 7-day predicate and TimescaleDB has nothing to exclude - matching the issue's own productionEXPLAIN("shared hit=176417 across all chunks").I confirmed this on a rig (fresh TimescaleDB,
PgMigrations+TimescaleSupport.CreateHypertableSql/EnableCompressionSql, 33 one-day chunks, compressed past 1 day): with NO continuous-aggregate drop-to-4-days applied, the same predicate DOES prune (8 of 33 chunks touched, not 33; execution buffers 901-1030 vs 1633 unbounded; planning buffers 74 vs 29015 for a literal-constant bound). That setup doesn't match the shapePlainStoreHorizonvsRollupStoreHorizonimplies for a store with rollups, so it doesn't contradict the comment - it just isn't the configuration the comment is about. I did not have a way to reproduce the WITH-continuous-aggregates, 4-day-raw-retention shape without a multi-day backfill/rollup cycle actually running, which is beyond a single-session rig diagnosis.Net: the comment is accurate for the rollup-store shape, and the existing predicate genuinely cannot help there - narrowing
CandidateWindowfurther only makes an already-inert bound more inert. A real fix needs the issue's own suggested shape (read the candidate list from a maintained per-server set or the latestdatabase_statessnapshot instead ofDISTINCTover raw), which is a design-level change: what maintains that set, when it's refreshed, and how a database that stops shipping rows ages out of it. Leaving this open under #4197, as PR #4209 already does (title: "Part of #4197").Bound ring-buffer watermark reads to a recent window, fall back to un…
erikdarlingdata commentedon Sep 25, 2026
Same three reads after the store host's RAM upgrade (2026-09-25 03:20Z). The OS file cache now serves the store's reads at ~7 µs per 8 KB block, and EBS reads fell ~92% against the same hours the day before. This is a
pg_stat_statementsdelta over 83 min (04:52–06:15Z) on the Query Store–ingesting store, 43 servers:QueryStoreBackfillSELECT DISTINCT database_name FROM query_store_stats …SELECT MAX(event_time) FROM default_trace_events WHERE server_id = $1SELECT MAX(run_datetime) FROM job_history WHERE server_id = $1Warm, the means drop (the DISTINCT went from 915 ms to 200 ms), but the work doesn't: every cycle still walks every retained chunk. The DISTINCT alone touches ~2.5 GB of buffers per call, about 1.2 TB an hour. Memory hides this read's I/O but not its CPU or its growth with retention, so the persist-at-write-time fix still stands.
erikdarlingdata commentedon Sep 25, 2026
Lane D3: design for the rest of #4197. Design only: no product code and no PR. Everything below was measured on a local rig.
In short
DISTINCT(part a): bound it at the backfill's own floor,floorLimit, which is 23 hours back on a store with rollups. Then add the databases named by hole keys, which the method already loads. No migration. On the rig's heavy server, this took the read from 206 ms and 21,411 buffers to 1.7 ms and 792 buffers. The read now stops at the two uncompressed chunks. A persisted per-server list is cheaper still (0.26 ms, 4 buffers). But the gap is under 2 ms a call, and closing it needs writer changes. A loose index scan is ruled out: across the compressed chunks it took 38.8 s.collector_state, which needs no migration.DISTINCT, and unbounded per-serverMAXwatermarks), but on DuckDB they cost 1.5-13 ms. Twin the candidate change for parity. Lite gains nothing from persisting.Rig
query_store_stats,default_trace_events,job_historyandcollector_statecome from a store the Darling migrations built (schema version 143). They have the same columns and indexes, 1-day chunks oncollection_time, and compression segmented byserver_id. TimescaleDB chose the ordercollection_time DESC, query_hashitself.query_store_stats: 43 servers, each with 10 databases and 12 rows per 15-minute cycle, for 4 days (1.99M rows). That makes 5 chunks. The 3 older chunks are compressed, as the 1-day policy leaves them. One heavy server was added: 100 databases and 50 rows per cycle (1.92M rows).default_trace_eventsandjob_history: 30 days each, in 31 chunks, 29 of them compressed. One quiet server has written no row for 8 hours, to exercise the Bound the ring-buffer watermark read to a recent window, fall back to unbounded only on a miss (#4197) #4209 miss path.PREPAREwith the types Npgsql binds (int4,timestamp without time zone), thenEXPLAIN (ANALYZE, BUFFERS) EXECUTE. That gives a custom plan, which is what an unnamed Npgsql statement gets. The cache was warm, and each time is the median of 3 runs. Buffers are shared hit plus read.1. The candidate
DISTINCTinQueryStoreBackfill.GetCandidateDatabasesAsyncWhat the backfill needs from the list
RunServerSliceAsyncwalks the list in name order. For each database it does one of these:holeTo <= floorLimit.MIN(last_execution_time). If there are no rows, it skips. If the minimum is at or belowfloorLimit, it writes the done key. If not, it digs the tail.In steady state every database is done and there are no holes, so the list is only walked and skipped. Only two kinds of database can ever have work:
state, which the method loads just before theDISTINCT.floorLimit. Its oldest row then hascollection_time >= last_execution_time > floorLimit, so any read bounded atfloorLimitfinds it.floorLimitisnow - HorizonFor(hasContinuousAggregates):RollupStoreHorizon, which ismin(RawMaxAge = 3 days, RefreshReachableDepth = 23 hours), so 23 hours.PlainStoreHorizon, which is 3 days.With 1-day chunks and
compress_afterof 1 day, the two newest chunks are always uncompressed. A 23-hour bound therefore never touches a compressed chunk. If the compression job lags, more chunks stay uncompressed, which does no harm.Measured
Chunks are shown as uncompressed + compressed. Buffers are shown as execution / planning.
DISTINCT, 7-day boundPlainStoreHorizon)RollupStoreHorizon)DISTINCT, 7-day boundThe heavy server's shipped read (206 ms) matches the field mean (200 ms). The field read touches far more bytes per call: about 2.5 GB, against 167 MB here. So the ratios carry over, and the absolute numbers do not.
Why the loose index scan fails
Inside a compressed chunk there is no btree on
database_name. The compressed chunk's only index is(server_id, _ts_meta_v2_first_collection_time), and its sparse index is a bloom filter oncollection_timeandquery_hash. So each recursive step unpacks the server's whole segment again, once per database. On the uncompressed chunks it runs level with the plain boundedDISTINCT, so it adds nothing there either.Correctness of each option
floorLimit, plus hole keysfloorLimit. That loses nothing. Its first contact is older than its last row, so it has no tail left to dig.last_seen > floorLimit. The result matches today's.floorLimit, so it followsHorizonFor(flag): 23 hours with rollups, 3 days without.floorLimit, so it is exact under both.collector_statekeys with no migration. Also a one-off seed, and a writer upsert inside the Query Store COPY transaction, which #3953 already opens.Stores without continuous aggregates
There are two ways to handle that case:
HorizonFor(flag), which is exact. On the heavy server it still reads 2 compressed chunks, at 162 ms.I recommend the exact form. It costs nothing on the field store, which runs rollups, and it keeps the behaviour identical on every store.
Recommendation for part (a)
Use option A with
HorizonFor(flag), and no migration. ComputefloorLimitonce, before the read. Bind it as the read's lower bound, then merge in the databases named byHoleKeyPrefixkeys. Also replace the comment that says the predicate is "inert".A sibling read with the same problem
GetStoredFloorAsync(MIN(last_execution_time), unbounded) cost 68 ms and 19,417 buffers per call on the heavy server. It runs once a tick for each database that is not done yet. In steady state that is never, but a new database pays it every tick while its tail is dug. A cheap exact form exists:collection_time <= floorLimit, the database is done. That is anEXISTSwith a limit of 1, which stops at the first batch.MINovercollection_time > floorLimitis the true minimum, because every row is newer than that.This is optional for the same lane.
2. Are persisted ring-buffer watermarks still worth it after #4209?
Measured on the standard server. dte is
default_trace_events, and jh isjob_history.MAX(event_time), dteMAX(run_datetime), jhcollector_stateprimary key)On the heavy server, the hit cost 0.4 ms and 9-15 buffers, and the unbounded read cost 8 ms.
What this says
So persisting pays only on misses, and in proportion to how often they happen. A miss means the server wrote no row to that table in 6 hours. That is normal for a quiet default trace, and for a server whose jobs run nightly or weekly. So the miss rate can be large. The field numbers cannot tell us, because they come from a build before #4209. After #4209 deploys,
pg_stat_statementsshows the rate directly: the bounded and unbounded statements have different texts. Divide the unbounded statement's calls by the bounded one's.The cheaper step, if misses matter
Add a second probe at 23 hours before the unbounded fallback. It stays on the uncompressed chunks (1-1.4 ms), needs no writer change, and returns the same value (the #4209 argument, extended one step). It covers servers quiet for 6-23 hours, such as nightly jobs. It does not cover servers quiet for longer than that.
Persisting at write time, if it is still needed after that
Store the value as a
collector_statekey (server_id, collector_name, state_key), which needs no migration. The writer's side:GREATEST(existing, batch max over the rows actually copied). Then the watermark cannot pass rows that did not commit. Today these collectors COPY in autocommit mode. Only the diverting collectors and Query Store open a transaction. So this adds a transaction and one statement to every batch they write.GREATESTunder the row lock does not depend on commit order. An older batch, such as a replay after an outage, cannot move the watermark back. A failed batch rolls back both the rows and the key.drop_chunks, and the derivedMAXdoes not. On a server with no rows left in retention, today's read returns null and falls into the first-run window. The persisted key keeps the real watermark. That is a behaviour change, and the right one: no events are skipped.collector_staterows. The runtime deletes there only prune single keys, or the keys of dropped databases. So the watermark key must sit outside those per-database prefixes. With the sameserver_id, the old key and the old rows agree, as today. A newserver_idstarts from nothing, as today.3. Lite parity
Lite has both reads:
RemoteCollectorService.QueryStoreBackfillruns the sameSELECT DISTINCT database_name ... collection_time > now - 7 days, and the same unboundedMIN.GetLastCollectedTimeAsyncandGetLastCollectedInstanceIdAsyncrun an unboundedSELECT MAX(col) FROM table WHERE server_id = $1.Both read the hot DuckDB tables, which hold 7 days (
ArchiveService,hotDataDays: 7).I timed them on a file-backed DuckDB 1.4.4 database: 20 servers and 7 days, at the rig's standard per-server rates (1.6M
query_store_statsrows). Each time is the median of 25 warm runs.MAX(event_time), dte, unbounded (today)MAX(instance_id), jh, unbounded (today)DISTINCT database_name, 7-day bound (today)MIN(last_execution_time)for one databaseDuckDB has no chunks to decompress, and its row groups mix servers, so a
server_idfilter prunes nothing. Every one of these reads is a scan of a few columns, measured in milliseconds. Recommendation: twin the candidate change (the bound plus the hole-key merge) so the two backfills keep the same rules. Do not persist Lite's watermarks: there is nothing to win.Proposed lanes
Lane A runs on the default tier. It covers Darling and its Lite twin, with no migration:
floorLimit, merge in the hole-key databases, and fix the "inert" comment.EXISTS-then-bounded-MINform ofGetStoredFloorAsync.About 60-90 calls.
Measurement (coordinator, no code): after Bound the ring-buffer watermark read to a recent window, fall back to unbounded only on a miss (#4197) #4209 is on the production store, take a
pg_stat_statementsdelta over at least 60 minutes. Split the bounded and unboundedMAXtexts for dte and jh, and add the candidate read after Lane A ships.Lane B runs if step 2 shows the misses matter. It builds the 23-hour second probe first, which is small and needs no writer change. If that is not enough, it then persists the watermark in
collector_stateinside the COPY transaction, with the writer census above. Darling only.Decisions for Erik
floorLimitplus the hole-key merge, with no migration.HorizonFor(flag)or 23 hours always. I recommend the exact bound, which still costs 162 ms on a heavy store of that kind. The 23-hour bound is cheap, but it can drop the rest of a tail when a database goes quiet mid-dig.GetStoredFloorAsync: fold its exact bounded form into Lane A, or file it separately.MAXtexts still above some number of GB an hour.collector_state, which needs no migration. Also approve a transaction around the COPY for the collectors that persist. They commit without one today.🤖 Generated with Claude Code
https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata commentedon Sep 25, 2026
Ruling on the design's six decisions. These are technical choices inside the issue's scope, so the coordinator makes them:
floorLimit, then merge in the databases named by hole keys. No migration. Replace the "inert" comment.HorizonFor(flag). The 23-hour bound is cheaper, but it can drop the rest of a tail when a database goes quiet mid-dig.GetStoredFloorAsyncgoes in the same lane: anEXISTSatfloorLimitfirst, then the boundedMIN.pg_stat_statementsdelta that splits the bounded and unboundedMAXstatements fordefault_trace_eventsandjob_history. If the unbounded statements read more than 1 GB of buffers an hour on either store, Lane B starts. It builds the 23-hour second probe first. If the probe is not enough, it then persists the watermarks.Lane A's tests are the design's list:
🤖 Generated with Claude Code
https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Add Darling live tests for the #4197 backfill candidate bound
Add Lite DuckDB fixture tests for the #4197 backfill candidate/floor …
Fix two census pins the #4197 source change left stale
Merge #4306's stacked ExpectedSiteCount summaries into one
Bound the Query Store backfill candidate read at its horizon (#4197) (#…
erikdarlingdata commentedon Sep 25, 2026
Part a (the candidate read bounded at the backfill's horizon, the hole-database merge and the exact floor form, in Lite and Darling) merged to dev in #4306 (bf865ef). Part b, persisting the ring-buffer watermarks, waits for a field measurement after #4209, so this issue stays open.
erikdarlingdata commentedon Sep 26, 2026
Claude posting for Erik Darling
Part (b) field reading: the unbounded fallback no longer runs, but the bounded watermark read still moves about 122 GB of buffers an hour. So part (b) goes ahead, as a watermark cached in memory per server.
Production SQL Server store (43 servers), nightly build 470, which includes #4209. It's a 60-minute
pg_stat_statementsdelta, 2026-09-26 04:31–05:31Z:SELECT MAX(run_datetime) FROM job_history WHERE server_id = $1 AND collection_time > $2SELECT MAX(instance_id) FROM job_history WHERE server_id = $1 AND collection_time > $2SELECT MAX(event_time) FROM default_trace_events WHERE server_id = $1 AND collection_time > $2MAX(the quiet-server fallback)job_historycall touches about 14.6k blocks (about 117 MB).The part (b) fix (coordinator ruling, 2026-09-26):
MAXat first use or after a restart.MAXruns. Nothing is persisted, so the writer's transaction doesn't change and no migration is needed.Wire the in-memory server watermark cache into DarlingCollectorRunner (…
Pin ServerWatermarkCache and the cache-eligibility defaults (#4197 pa…
Extract the watermark-cache resolve/advance seam for live testing (#4197
Add runner-level live pins for the watermark cache (#4197 part b)
Fix #4197 watermark cache microsecond truncation and run live pins (#…
Move the #2797 dispatch gate into ResolveServerWatermarkAsync (#4197 …
Count the watermark reads without pg_stat_statements (#4197)
Fix the counting logger's accessibility in the watermark live tests (#…
Cache the server-scoped watermark read across a runner's lifetime (#4399
erikdarlingdata commentedon Sep 26, 2026
Claude posting for Erik Darling.
#4399 is merged (66364bd). The collector runner caches the server-scoped watermark per (server, collector) for its lifetime, so the repeated MAX() reads drop to one seed per pair. Any fault in a run invalidates the entry.
erikdarlingdata commentedon Sep 26, 2026
Claude posting for Erik Darling
Closing: both parts have shipped.
MAX. That's Cache the server-scoped watermark read across a runner's lifetime (#4197) #4399.MAXcalls fell from 1,032 to 86 (the fill after start only), and its buffers from about 43 GB to 3.6 GB.collector_state) no longer applies, because nothing is persisted.pg_cpu_utilization, because a second writer also fills it;cpu_utilization, because of its UTC-twin frame.