Repository navigation
The interval-hourly refresh recomputes one fleet-wide bucket per dirty hour and paid twelve index inserts per row for eleven indexes nothing reads; the capture-down alert read decompressed a server's whole collection_log to learn two statuses (#3597, partial) - #3647
Conversation
…materialized row for eleven indexes nothing reads, and the capture-down alert read stops decompressing a server's whole collection_log to learn two collectors' latest status (#3597, partial) Rig-measured on PostgreSQL 18.4 / TimescaleDB 2.28.1 at one tenth of the largest production store's scale: a refresh of query_store_stats_interval_hourly recomputes exactly the hour-buckets the invalidation log marks dirty — the newly closed one every hour, plus one whole fleet-wide bucket per distinct past hour any backdated row landed in (a ONE-row backdated insert re-materialized 36,120 rows; twelve backdated rows in one transaction across twelve hours re-materialized twelve buckets, clean hours included). The window's width is not the cost; the dirty-bucket count times the per-bucket cost is. The only backdated writer is QueryStoreBackfill, by design (collection_time = slice ceiling). The per-bucket cost carried TimescaleDB's default create_group_indexes: eleven (column, bucket DESC) btrees on the L1 materialization that no reader uses — its three child aggregates read it by bucket range, the coverage probe and arming gate read min(bucket), retention drops chunks. EXPLAIN (ANALYZE, BUFFERS, WAL) of one bucket's materialization INSERT: 45.5 MB WAL / 483,689 records / 1.64 M buffer touches / 6,040 dirtied with them; 10.7 MB / 72,735 / 447 K / 56 with the bucket index alone. L1 is now created without them and EnsureIntervalDedupMaterializationIndexesAsync drops them on an existing store at startup, per index under a 10 s lock_timeout so a refresh in flight is yielded to rather than convoyed. The capture-down read was the #3496 shape #3496 named and left: ROW_NUMBER() OVER (ORDER BY log_id DESC) over every collection_log row the server had for two collectors — 9,573 buffers across 61 chunks (59 decompressed) per alert pass per server on the rig. It is now one chunk-orderable LIMIT 1 per collector: 14 buffers, the newest chunk only, 120 of 122 chunk scans never executed. Partial: the dirty-bucket multiplier is the backfill's designed behaviour and is not changed here; the production dirty-bucket count per hour is the read that decides how much of the 417 s average this removes, and the exact statements for it are on the issue.
|
Reviewed the diff (Darling-only:
No correctness, security, or parity issues found. Did not run the live-Postgres-gated tests myself (no |
There was a problem hiding this comment.
LGTM — reviewed the capture-down read rewrite, the create_group_indexes=false change plus its startup converge, and worker ordering; no correctness bugs, injection risk, or Lite/Darling parity drift found (change is Darling/TimescaleDB-only with no Lite counterpart needed).
…ized against the live collection: its gated arm mints a scratch database, so it cannot race the shared store (#1776 own-store, the census's own guidance)
| AND i.tablename = ca.materialization_hypertable_name | ||
| WHERE ca.view_schema = 'collect' | ||
| AND ca.view_name = '{QueryStoreStatsIntervalHourlyView}' | ||
| AND i.indexdef ~ 'USING btree \([a-z_]+, bucket DESC\)$' |
There was a problem hiding this comment.
Non-blocking observation: this selects indexes to drop purely by shape (USING btree (<one column>, bucket DESC)), not by any marker that ties them to create_group_indexes. If an operator (or a future migration) ever hand-creates an index on this materialization that happens to match this shape — e.g. (query_hash, bucket DESC) for some ad-hoc diagnostic — EnsureIntervalDedupMaterializationIndexesAsync will silently drop it on the next service start. Given this is TimescaleDB's own internal _materialized_hypertable_N relation that nothing in this codebase indexes by hand today, the risk is low, but it's worth a one-line acknowledgment in the doc comment (which already documents several other edge cases) so a future maintainer adding a legitimate index here knows to steer clear of this shape or update the selector.
| } | ||
|
|
||
| await transaction.CommitAsync(cancellationToken); | ||
| dropped++; |
There was a problem hiding this comment.
Non-blocking: dropped++ and freed += bytes fire whenever DROP INDEX IF EXISTS completes without error, including the (very unlikely) race where the index was already gone by the time this runs — IF EXISTS makes that a silent no-op rather than an error. The summary log line would then over-report what this start actually freed. Harmless given this only runs once per service start against a store this service itself owns, but since the surrounding code is otherwise careful to make every logged number accurate (the byte-size comment above even calls out where the read side undercounts), it might be worth the same rigor here — e.g. check the command's affected/notice output, or just note in the log message that the count is "attempted" rather than "dropped."
There was a problem hiding this comment.
LGTM — reviewed the Darling-only TimescaleDB change (query_store_stats_interval_hourly's group indexes, the interval-dedup index converge on startup, and the capture-down alert read's rewrite from a windowed scan to a chunk-orderable LIMIT 1 per collector). Checked: SQL injection surface (catalog-derived identifiers are quoted via QuoteIdentifier, view names are compile-time constants), lock-timeout/transaction handling in EnsureIntervalDedupMaterializationIndexesAsync (per-index transactions, bounded lock_timeout, correct break-on-first-lock-timeout semantics), the rewritten capture-down query's semantic equivalence to the old ROW_NUMBER() version (verified against live-gated tests and index support), startup ordering (drop-before-compress, after EnsureContinuousAggregatesAsync), and Lite/Darling parity — this is TimescaleDB/PostgreSQL-specific plumbing with no Lite (DuckDB) or deprecated-Dashboard counterpart, so no parity gap. No T-SQL touched. Left two non-blocking inline notes (shape-based index selection risk, a cosmetic drop-counter edge case) — neither is a functional bug.
The question the issue asked first, answered on a rig
What does each hourly refresh of
query_store_stats_interval_hourlyactually recompute? Measured on a Dockertimescale/timescaledb:2.28.1-pg18(PostgreSQL 18.4 / TimescaleDB 2.28.1 — the production pair), store built by the product's own path (PgMigrations.MigrateAsync→TryEnableAsync→ConvertToHypertablesAsync→EnsureContinuousAggregatesAsync→PgTableTuning.ApplyAsync), seeded to a 1/10 model of the largest production store: 43 servers × 840 interval identities per server per hour × 6 five-minute snapshots = 216,720 raw rows/hour → 36,120 L1 rows/hour, 26 hours, 5.7 M raw rows, 5.3 GB. Every refresh below is the policy's exact window,[now − 1 day, now − 1 hour], run throughCALL refresh_continuous_aggregatewithclient_min_messages = debug1so TimescaleDB printed the ranges it refreshed and the rows it deleted and inserted.collection_time = now − 20 hVerdict. TimescaleDB refreshes exactly the buckets the invalidation log marks dirty, and the window's width is not the cost (r2: 22-bucket window, nothing dirty, 0 s — #3012's "a refresh's cost is set by the WINDOW it re-scans" is false at the mechanism level; its measured 6,330 → 864 s win came from shrinking the backfill's reach, which
QueryStoreBackfill.RollupStoreHorizonderives from the window, i.e. from cutting the dirty-bucket count). Each hour costs (1 + k) × one bucket, where k is the number of distinct past hour-buckets any backdated row landed in since the last run — and a one-row backdated write costs the entire fleet's hour (r3), because invalidation is by time only, never by server. A single write transaction spanning hours dirties every hour between (r4). So:QueryStoreBackfill, by design:WriteBackfillBatchAsync(..., ceilingUtc, ...)stamps every slice's rows at the slice CEILING, up to 23 h back (RollupStoreHorizon), so the rows land beside their own activity (Query Store phase 2: newest-first backfill worker for the history the live path never takes #2022's horizon contract, the BLOCKED: narrowing the hourly CAGG refresh window would reintroduce #1759 — the window is a self-healing repair loop, not just a cost #3100 repair-loop argument). The brief's VERIFIED claim thatQueryStoreBackfill.cs:322stampsCollectionTime = DateTime.UtcNowis false as a description of the row stamp — that is theCollectorContextclock; the write usesceilingUtc. Nothing stamps rows incorrectly; every backdated row is backdated on purpose, which is why lever 1 as written ("fix the writer's timestamps") is not a lever anyone can pull without a product decision about where backfilled history lands. Each backfill slice (one per server per 5-minute tick, holes and first-contact tails) dirties one bucket; a first-contact tail walks down hour by hour and dirties up to 23; a clamp hole (WatermarkPolicy.MaxCatchup) opened by a slow Query Store pass dirties the hour it was clamped at. That k rises with collector load is the shape the issue measured as "breathes with load".create_group_indexeshad built eleven(column, bucket DESC)btrees on L1's materialization beside the bucket index — twelve indexes on the one aggregate in the file whose cardinality is near-raw. Nothing reads L1 by any of them: its three children (corrected_hourly,corrected_daily,interval_daily) refresh over it bybucketrange,RollupCoverageProbeSql/RetentionArmSafetySqlreadmin(bucket), retention drops chunks (grep: no product read names the view outsideTimescaleSupport). What they did was tax every re-materialization:EXPLAIN (ANALYZE, BUFFERS, WAL)of one bucket's materialization INSERT (36,121 rows from 216,721 raw, isolated in a rolled-back transaction):4.3× the WAL, 1.19 M extra buffer touches and 108× the dirtied pages per bucket, from twelve index inserts per row where one suffices. On the rig those touches are shared-buffer hits and the wall clock barely moves; on a store whose L1 materialization is 71.5 GiB they are the leaf pages of eleven cold high-cardinality indexes (
query_hash,first_execution_time,query_id,plan_id, …), random-read on every insert — the I/O the issue's alert reads were starved by. The rig can measure the amplification; it cannot reproduce the production I/O regime, so this PR claims no production duration figure. The DELETE half is cheap either way (36 K rows, 16 ms, 2 MB WAL). The other (A) suspect — the 12-column aggregate spilling — is real (external merge sort, 38 MB + 14 MB temp per bucket atwork_mem = 4MB; ~520 MB per production bucket) butwork_mem = 512MBdid not move the rig's wall clock measurably (0.9–1.3 s vs 1.1–1.3 s), and a policy job'swork_memis not settable per job in 2.28 (alter_job(config)carries only the policy's own keys) — reported, not pulled.What ships
1. L1 without the group indexes (the (A) lever the rig licensed).
CreateQueryStoreStatsIntervalHourlySqlis nowWITH (timescaledb.continuous, timescaledb.create_group_indexes = false); that option speaks only at CREATE, soTimescaleSupport.EnsureIntervalDedupMaterializationIndexesAsync(#3620's converge shape) brings an existing store to the same state at startup: resolves L1's materialization from the catalog by view name, selects its indexes by shape (USING btree (<one column>, bucket DESC)— the bucket index cannot match), drops each in its own transaction underSET LOCAL lock_timeout = '10s', logs each drop with the bytes it released across the hypertable and its chunks, and stops at the first lock timeout so a refresh in flight is yielded to rather than convoyed (a queuedAccessExclusiveLockblocks every later shared request — the #3012 convoy). Idempotent under the catalog: a settled store issues nothing. Wired inDarlingWorkerafterEnsureContinuousAggregatesAsyncand beforeEnsureAggregateCompressionAsync, so #3611's nightly pass compresses a relation already without them. Verified on the rig through the product code: 11/11 dropped (109.3 MiB at rig scale), chunk copies gone, second start a no-op; with aROW EXCLUSIVElock held on the materialization the drop yielded at 10.0 s with the Warning and succeeded on the following start; the refresh, all three child refreshes,compress_chunkanddecompress_chunkon an L1 chunk all ran unchanged with the bucket index alone. Scoped to L1 only: the composer-grain rollups ARE read through their group indexes (server_id,query_hash), and the day-grain L2 refreshes once a day and was not measured — the scope pin says so.2. The capture-down read (lever 3).
DarlingSelfAlertEvaluator.ReadMissingCaptureSessionsAsyncshipped asROW_NUMBER() OVER (PARTITION BY collector_name ORDER BY log_id DESC)over everycollection_logrow the server had fordeadlocksandblocked_process_report— the shape #3496 named and deliberately left ("a window function cannot early-stop by ordering swap alone"). With 60 days of one server's log across 61 chunks (59 compressed), realistic cadences, its plan on the rig: every chunk decompressed, 103,682 rows Merge-Appended and numbered to keep two, 9,573 buffers, 76 ms + 62 ms planning — two to three orders of magnitude above every other per-server read on the pass, per alert pass, every 30 s, per server. It is now one chunk-orderableLIMIT 1per collector (ORDER BY cl.collection_time DESC, two literal arms,UNION ALL): 14 buffers, the newest chunk only, 120 of 122 chunk scans "never executed", horizon-independent. Nolog_idtiebreak, and the doc says why: each arm is one collector on one server, one row per run stampedDateTime.UtcNowat log time — no tie is possible — and the tiebreak's cost was measured (the(server_id, collection_time)index cannot serve the second key, so each arm top-N-heapsorts the whole newest chunk: 1,158 buffers vs 5). The SQL is hoisted toMissingCaptureSessionsSqlso the pins read the text the method executes.3. Every other alerting-side read, EXPLAINed on the same rig with 60–90 days of compressed history, so the issue's "find out what the read is doing" has an answer per site:
latest-CPU21 buffers / 0.5 ms;volume-free-space29 buffers / 6.5 ms, 90 of 91 chunks excluded at runtime — but 90 ms of PLANNING (the InitPlanMAXprevents plan-time exclusion, so the planner builds 182 chunk paths);pvs-pressure99 buffers / 5 ms / 104 ms planning (same shape);tempdb3 / 0.4 ms;anomalous-jobs39 / 1.6 ms;database-file-growth5 / 0.05 ms;force-plan-failures(#3586) 63 / 13 ms Index Only Scan; the fivedatabase-statesstatements ≤ 71 buffers / ≤ 3 ms;collection-health(#3496) 2,313 buffers / 4.7 ms / 70 ms planning;agentreads 1–3 buffers. Volume-free-space and pvs are already at execution the shape the brief asks for — the newest chunk, tens of hot buffers; what they cost is planning over a 91-chunk hypertable, and the only cure is a plan-time-excludablecollection_time > $2bound, which is a freshness decision the engine does not make today: an empty result on a stale snapshot wouldNotifyResolutionAsync("All volumes back above threshold")— a fabricated recovery, the exact failure the alert layer is designed never to produce. Declined here; reported as a residual with the numbers (43 servers × ~330 ms of planning per pass ≈ half a core, continuously).Levers declined, with why
work_memfor the policy job. Spill measured, effect on the rig not measurable, knob not available per job. Reported.What this does NOT do
It does not claim to have removed the 417 s. It removes the measured write amplification from every re-materialized bucket and takes the largest read off the alert pass; it leaves the dirty-bucket multiplier — the backfill's designed behaviour — exactly where it was. The post-install
job_historyseries for this refresh is the measurement that says how much of the average was per-bucket cost: the trough (276–286 s, when only the newly-closed bucket is dirty) is the number this should lower. Partial, so noCloses. The diagnostic with the production statements that count k is posted on #3597.Tests
IntervalDedupMaterializationIndexesTests— pinscreate_group_indexes = falseon L1 and its ABSENCE on every other registered aggregate (hourly, daily, baseline registries — a new aggregate is covered the day it is registered); the catalog read's by-shape selection on L1 only; the bounded lock timeout; and the gated live arm on a scratch database: a fresh L1 has exactly the bucket index (TimescaleDB honours the option), a planted pre-Alert reads die during the interval-hourly Query Store refresh: 26/26 runs over four minutes, 417 s average, breathing with load — the grid protects writers from locks, nothing protects readers from I/O #3597 group index is found and dropped (1 → 0 on the next call), the bucket index survives by shape, and the refresh plus the child corrected hourly still materialize seeded rows correctly (10 identities deduped to their last snapshot, 300 executions).CaptureDownChunkOrderTests— source pins (two arms, eachORDER BY cl.collection_time DESC LIMIT 1; noOVER (, noROW_NUMBER, nolog_id) and the gated plan test modelled onForcePlanFailuresAccessPathTests: eight seeded days across eight chunks, EXPLAIN of the shipped statement with its real bound parameter → noWindowAgg, ≤ 1 chunk executed per arm, and the NEWEST run decides (aSESSION_MISSINGthree days ago followed by a success is not reported; a success followed bySESSION_MISSINGis).CollectionSignalsChunkOrderTestsdoc updated: the capture-down read it left alone is now pinned by its own class.DARLING_TEST_PG→ the TimescaleDB 2.28.1 / PG 18.4 container): 651 green across the two new classes plusDarlingSelfAlertTests(its live capture-down arm included),AlertPassCommandTimeoutTests,TimescaleContinuousAggregateTests,TimescaleSupportTests,TimescaleAggregateCompressionTests,ForcePlanFailuresAccessPathTests,QueryStoreTrendRoutingLiveTests,QueryStoreCorrectedRollupLiveTests,RollupBackfillLiveTestsand more (the only failures were harness artifacts: out-of-treeRepoFilepaths and Windows-only upgrade tests). Full solution builds-c Release -p:EnableWindowsTargeting=truewith 0 warnings.