Repository navigation
Build a BRIN index on query_store_interval_wide (collection_time) in the background - #4862
Merged
Merged
Conversation
…the background (Refs #4605) A Custom Views Query Store panel over 12 hours or more reads the table on collection_time, which nothing serves, so it scans the whole table. The index is a BRIN because the upsert rewrites collection_time on every update and a btree there would make each update non-HOT. It is built CONCURRENTLY about 20 minutes after start, skipped below PostgreSQL 16 and on a hypertable, and an INVALID leftover from an interrupted build is dropped and rebuilt.
erikdarlingdata
marked this pull request as ready for review
September 30, 2026 15:57
…a stop during the start delay at Debug A catalog pin parses the upsert's DO UPDATE SET columns from its own SQL and asserts no non-BRIN index on the table covers any of them (key, INCLUDE, expression and predicate columns), so the update stays HOT for the whole SET list. The hand-written HOT subset drops its no-op column. The class doc says the measured figures are from before the 9-day purge horizon and what to expect after it. A stop during the 20-minute delay is logged at Debug; only a stop after the delay keeps the Information line.
This was referenced Sep 30, 2026
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.
Refs #4605
What
A Custom Views Query Store panel over 12 hours or more reads
collect.query_store_interval_wideoncollection_time. Nothing serves that column, so the read scans the whole table. On a large production monitoring store (28 GB, 3.1 of 9 days held) a 24-hour fleet panel read 3.18M blocks, 98.8 s cold.This PR adds
QueryStoreIntervalWideBrinIndex(new file in the Storage project) and schedules it from the worker. It buildscollect.ix_query_store_interval_wide_collection_time_brin(USING brin (collection_time) WITH (autosummarize = on)) withCREATE INDEX CONCURRENTLY. It changes no read, no migration rung and no existing file except a launch and a drain inDarlingWorker.cs.Measured on that store with a temporary copy of the index (
random_page_cost1.1, warm): 24 h 4.3 s (1.14M blocks), 12 h 1.6 s, 6 h 0.8 s, 48 h 9.2 s. Cold, the 24 h read is about 8.7 GiB, roughly 37 s at the volume's throughput cap, against 98.8 s cold for the full scan. The build took 108 s on the 25 GB heap, the index was 856 kB, and no writes were blocked.These figures are from before the table reached its retention horizon (the store held 3.1 of its 9 days). Once the 9-day purge starts, freed pages near the start of the heap are reused by new rows, and a BRIN range's summary only ever widens. Windows that end at the present, which is what the panels read, should stay selective. A window several days back will match more ranges, and the planner's
collection_timecorrelation falls, which can move some windows back to a sequential scan for part of the purge cycle. Results stay exact, no read gets slower than without the index, and the writer's cost is unchanged. If the steady state needs it, the remedy is a periodicREINDEX INDEX CONCURRENTLYor re-summarizing the recycled ranges; this PR does neither.Most of the read gain needs a low
random_page_cost. On that store, at the default of 4, a 12-hour window still sequential-scans with the index present, while a 24-hour window already uses the index. This threshold is a production measurement: on the 150k-row test fixture the planner picks the BRIN at 4 as well, so pin (c) proves the index is usable, not where the cost threshold sits. The 12-hour and shorter windows gain at about 1.1 or lower: on the managed store once therandom_page_costchange lands (#4863), or on a bring-your-own store whose operator sets it.Design choices
collection_timeon every update, and 99.9% of this table's updates are HOT today. A btree oncollection_timewould make each one non-HOT (the 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 class). PostgreSQL 16 and later keep an update HOT when only summarizing (BRIN) columns change. On that store, in one rolled-back transaction: updatingcollection_timeandexecution_counton 20 rows was 20/20 HOT, and the control,first_execution_time(btree-indexed), was 0/20. Physical correlation ofcollection_timeis 0.9998.Decide(serverVersionNum, tableIsHypertable)is a pure function. Below 160000 the ensure skips the build and logs at Information: the version and "a BRIN index on collection_time would make the upsert non-HOT below PG 16". A bring-your-own store on 14 or 15 keeps today's plans and HOT rate.CONCURRENTLYon a hypertable. Iftimescaledb_information.hypertableslists the table, the ensure skips with a Warning and never retries. The view is read only afterto_regclass('timescaledb_information.hypertables')says it exists, so a store without TimescaleDB never parses it.CONCURRENTLYfails with 25001 there), and a plain in-rung build would block the writer for the whole heap read. An applied rung also never changes. So the ensure is idempotent and runs each start. A fresh store builds an empty table's index instantly; no table-creation change is needed. The schema-equivalence tests (fresh vs migrated index sets) are unaffected because no migration changes; those live in Lite.Tests, which cannot run on macOS, and nothing in this diff touches Lite orPgMigrations.cs.pg_index.indisvalidfirst. Valid: nothing (Debug). Invalid (an interrupted build):DROP INDEX CONCURRENTLY IF EXISTS, a Warning, then build. PlainIF NOT EXISTSwould keep an invalid leftover.RunDelayedAsync(cancellable); tests callEnsureAsyncdirectly. One attempt per start; a failure is a Warning and is retried at the next start. The task is launched after migrations, is never awaited on the startup path, and is drained only at shutdown.Pins
Live pins ran on a local TimescaleDB 2.30.1 / PG 18 rig (
QueryStoreIntervalWideBrinIndexLiveTests, 5 tests), the unit pins inQueryStoreIntervalWideBrinIndexTests(15 cases). Each mutation is a temporary code change, reverted.first_execution_timeupdate): 0 HOT. Mutation, swap the BRIN for a btree oncollection_time: 0 HOT, the assert fails. RED with the ensure building a btree ((collection_time)instead of brin): FAIL. GREEN: pass.SET LOCAL random_page_cost = 1.1,EXPLAIN (FORMAT JSON)of a 12 hcollection_timewindow. Before the ensure: Seq Scan and no Bitmap Index Scan (asserted; this is the dev behaviour). After: Bitmap Heap Scan over Bitmap Index Scan, no Seq Scan. This pins the planner's use of the index; the product reads' end-to-end effect is the before/after on 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.UPDATE pg_index SET indisvalid = falsefakes an interrupted build (the rig and CI roles are superuser). The ensure rebuilds: valid, brin definition, new oid. Mutation, skip the validity read: FAIL. GREEN: pass.Decide: build at 160000 and 180006; skip at 159999, 150013, 140000; hypertable skip at 160000 and 180006; version wins over hypertable at 150000; reasons named. SQL containsCONCURRENTLY,IF NOT EXISTS,USING brin (collection_time),autosummarize = on. Source pin: the hypertable view is read only behindhasHypertableView, and the skip logs a Warning. Worker pin: launch afterPgMigrations.MigrateAsync(migrateConnection, oneawaitat shutdown after the collection loop. Migration pin: no index inPgMigrations.cson the wide table includescollection_time.NoNonBrinIndex_CoversAColumnTheUpsertSets_SoTheUpsertStaysHotForItsWholeSetListparses theDO UPDATE SETcolumns fromQueryStoreIntervalWide.UpsertSqlitself (at least 50 names,collection_timeandexecution_countamong them, no identity column) and asserts that no non-BRIN index on the table covers any of them: key and INCLUDE columns fromindkey, expression and predicate columns frompg_depend. In-test probes show it sees what it must: a btree onexecution_countis reported, anINCLUDE (query_hash) WHERE plan_type IS NOT NULLindex reports both columns, a BRIN onlast_execution_timeis not. Mutation, a btree onexecution_countbefore the scan: RED (Assert.Empty() Failure: ["execution_count"]). A later btree on any SET column therefore fails a test instead of silently making every upsert non-HOT; pin (b) keeps its hand-written subset, now without the no-opquery_text = query_text.Also run and green:
DocCommentHygieneTests,StorageCommandTimeoutTests,StartupCommandTimeoutTests,McpReadCommandTimeoutTests,CommentFilterAdoptionTests,RepoFileAdoptionTests,CommandDeadlineScannerAdoptionTests,LiveCleanupConversionRatchetTests,McpPayloadContractCensusTests,McpToolsListBudgetTests,QueryStoreIntervalWideRungTests,QueryStoreIntervalWideMinSchemaVersionPinTests, and the DarlingWorker source-pin classes I found by grep (StartupFailureTriageTests,ScheduledPurgeLaunchTests,StoreObjectConvergenceTests,SharedBaselineCacheTests,RawPurgeNowWiringTests,AlertReadFailureSurfaceTests, and seven more). Build: 0 warnings, 0 errors.Not run:
LivePostgresCollectionHygieneTests.EveryClassUsingTheSharedStore_IsSerializedOrDocumentsWhyNotloads WPF types and cannot run on macOS (10 of its 11 tests pass). The new live class carries the own-store comment, so CI should pass it. Lite.Tests was not changed.Not changed
random_page_costchange; both belong to the managed-conf PR.Findings: 0 substantial. 5 hygiene or polish fixed here: a source pin used the LF reader, which the adoption census restricts; the HOT guard covered only a hand-written subset of the upsert's SET list (now the catalog pin (f)); a no-op column in that subset; the shutdown log (g); and the measured figures now say they predate the purge horizon. 0 dropped.
CHANGELOG
None:
collect.query_store_interval_wideis new in 3.9 (#4341; not in v3.8.0), so no 3.8 user has read it and nothing a 3.8 user has seen changes. The 3.9 entry for #4341 carries this index: built in the background about 20 minutes after the service starts, without blocking writes; skipped on PostgreSQL older than 16 and on a hypertable; used by 12-hour-plus Query Store windows whererandom_page_costis about 1.1 or lower.