Repository navigation
add the supporting index instead of scanning the whole fleet's newest chunk (#4196) - #4216
Merged
Merged
Conversation
…dex (#4196) The snaps CTE in PgAnomalyDetector.ObjectGrowthSql/ObjectContentionSql had no index leading with (server_id, collection_time), so it fell back to a fleet-wide SkipScan that filtered out every other server's rows in the newest chunk before finding the two it wanted - about 350 MB read per server per analysis pass. V142 adds idx_index_object_stats_server_time; no SQL text change was needed since the read already had the ideal shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata
marked this pull request as ready for review
September 25, 2026 03:40
erikdarlingdata
added a commit
that referenced
this pull request
Sep 25, 2026
…PlanAgeDays gate (#4208) PR #4216 merged to dev using V142, so #4208's migration is renumbered to V143. All references updated: PgMigrations.cs (Migration ctor + const name + doc comment), StorageVersion.SchemaVersion, ViewerDataService.cs (probe comment + arm comment + return value), and all pin/rung tests (QueryStoreIntervalLatestRungTests, QueryStoreIntervalLatestWriterTests, CollectionCaveatsRungTests, PostmasterStartTimeRungTests, ViewerCollectionCaveatsTests). The PR body's CHANGELOG entry now includes a second bullet for the force-plan bot age gate (ForcePlanBotPolicy.MaxBestPlanAgeDays = 4), pinned by Assert.Equal(4, ForcePlanBotPolicy.MaxBestPlanAgeDays) in ForcePlanBotPolicyTests. Build: 0 warnings, 0 errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ
erikdarlingdata
added a commit
that referenced
this pull request
Sep 25, 2026
…#3953) (#4208) * Keep the latest Query Store snapshot per interval as it is written (#3953): V140 and the writer V140 adds collect.query_store_interval_latest (one row per Regular interval identity), its per-server coverage row, and a pending-replay table. The Query Store COPY applies each batch in its own transaction behind a savepoint with a 5 s lock_timeout; an apply fault rolls back to the savepoint, records the batch for replay and lets raw commit, so raw ingestion never depends on the table. Coverage creation and the hourly gap check run before the transaction with bare-parameter bounds, so nothing inside it reads raw except by the batch's collection_time. The viewer probe gets the V140 sentinel and top arm. Checkpoint: the readers do not use the table yet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * PLAN_REGRESSION and its drill-down read the interval table where its coverage holds the raw read's snapshots (#3953) Both regression reads get a table twin that shares everything above the dedup with the shipped raw SQL (split into shared constants, byte-identical). The fact decides the source per server and pass (coverage start, pending batches, raw's chunk floor, the table's floor) and records it on the analysis context, so the drill-down reads the same source. Any fault in the decision reads raw. A live test seeds real regressions through the write path and pins the fact and drill-down rows identical from both sources, then shows only the table still reaching a best plan after raw is purged below it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * The interval table and its pending rows purge at 15 days (#3953) DarlingRetention deletes the latest-snapshot table's intervals past QueryStoreIntervalLatestRetentionDays (15, on first_execution_time) and pending-replay rows past the same horizon, failure-isolated like every sibling. It stays out of RawTierCoverage and RetentionPolicies. The batched DELETE is the whole path until the table's hypertable conversion lands; drop_chunks joins then, in collection_log's shape. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * Regressed queries carry the best plan's last run, and the force bot skips best plans older than 4 days (#3953) With the interval table the PLAN_REGRESSION window really reaches 14 days, so a best plan can be two weeks old. Both drill-downs (Darling's two twins through the shared tail, and Lite's) append best_plan_last_seen; the shared extractor carries it onto ForcePlanTarget, and ForcePlanBotPolicy blocks a target whose best plan last ran more than MaxBestPlanAgeDays (4, raw's retention) before the pass, with the new reason best_plan_stale. That keeps the would-force journal in the regime it has been scored in. A target with no age (a pre-#3953 finding) is not gated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * PLAN_REGRESSION says how old the faster plan is, and which source it read (#3953) Both fact reads (Darling's raw and table twins through the shared suffix, and Lite's) append the best plan's last run. The fact metadata gains best_plan_age_days and plan_regression_source (1 = interval table, 0 = raw; Lite is always 0), and the advice states the age: "the faster plan on record (it last ran 9 days ago)". The 10 CPU-second floor stays absolute. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * Pin that nothing inside the Query Store batch transaction reads raw except by the batch's collection_time (#3953) Review finding 2 as a test: every statement the apply runs inside the raw COPY's transaction reads query_store_stats only under server_id = $1 AND collection_time = $2; the two wider reads run before it with bare-parameter bounds; the conflict target and batch key render from one identity constant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * V140 carries the top-rung pins, and raw Query Store rows have one write path (#3953) QueryStoreIntervalLatestRungTests takes the "I am the top rung" claims off V139's test: the dense ladder (Scripts[^1]), the DDL (three engine-plain tables, one NULLS NOT DISTINCT unique index whose columns are the writer's conflict target), and the viewer probe's top arm. It also pins the coverage claim's first guard: no product source spells a write into query_store_stats, and no other generic-COPY site handles the Query Store collector; the #1912 slice repair is the named exception. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * Fix two tests the merge's V140->V142 renumbering broke CollectionCaveatsRungTests.TheRungIsRegisteredAtTheTopOfADenseLadder still asserted it was the top rung (RungVersion == SchemaVersion, Same(V141, Scripts[^1])), which stopped being true once V142 landed above it - mirroring the same fix CheckpointsTimedRungTests already carries from when V141 landed on it. QueryStoreIntervalLatestWriterTests had one hardcoded Scripts.Single(m => m.Version == 140) reference (re-running the rung's own SQL after dropping its index mid-test) that the standard version-bump obligations list doesn't cover. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 * Fix ViewerCollectionCaveatsGateTests for the new V142 top rung The test built its "V141 sentinel absent" argument array with `i < arity - 1`, which meant "every parameter except the last one" back when V141 was the newest. V142 (#3953) appended its own parameter after hasCollectionCaveats, so `arity - 1` now names hasQueryStoreIntervalLatest instead, and the old test left hasCollectionCaveats itself true while turning off the wrong sentinel -- masked further by MapProbedSchemaVersion answering 142 outright once the V142 sentinel is true, regardless of V141's state. Finds hasCollectionCaveats by name via reflection and requires every sentinel from it up to be false, not just the literal last parameter. Also normalizes two files' line endings back to the repo's CRLF convention (git's clean filter had already silently fixed the committed blobs on the merge commit; only the on-disk working copy needed it, which is what a literal-CRLF pin elsewhere caught). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 * Renumber V142->V143 (conflict with #4216); CHANGELOG mentions MaxBestPlanAgeDays gate (#4208) PR #4216 merged to dev using V142, so #4208's migration is renumbered to V143. All references updated: PgMigrations.cs (Migration ctor + const name + doc comment), StorageVersion.SchemaVersion, ViewerDataService.cs (probe comment + arm comment + return value), and all pin/rung tests (QueryStoreIntervalLatestRungTests, QueryStoreIntervalLatestWriterTests, CollectionCaveatsRungTests, PostmasterStartTimeRungTests, ViewerCollectionCaveatsTests). The PR body's CHANGELOG entry now includes a second bullet for the force-plan bot age gate (ForcePlanBotPolicy.MaxBestPlanAgeDays = 4), pinned by Assert.Equal(4, ForcePlanBotPolicy.MaxBestPlanAgeDays) in ForcePlanBotPolicyTests. Build: 0 warnings, 0 errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ * Trigger CI: Build did not run after V143 renumber Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ * Tighten ReadBody end marker to exclude CollectConfigAuditFactsAsync scorer (#4206) The parity test extracted from CollectAndScoreFactsAsync up to ComparePeriodsAsync. CollectConfigAuditFactsAsync sits between those two methods in DarlingAnalysisService.cs. After #4206 added _scorer.ScoreAll to the narrow pass, the extraction included two scorer calls instead of one, failing Assert.Equal(1, CountOf(code, Score)). ReadBody now stops at CollectConfigAuditFactsAsync when present (Darling only), falling back to ComparePeriodsAsync when not (Lite). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ * Revert "Tighten ReadBody end marker to exclude CollectConfigAuditFactsAsync scorer (#4206)" This reverts commit fc1055f. * ci: trigger build on current HEAD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ * Fix duplicate collectionCaveatsOrdinal: remove const shadowing the var from dev Merging dev into this branch left two declarations of `collectionCaveatsOrdinal` in ViewerCollectionCaveatsGateTests: the `var` added by V142's dev merge and the `const int 116` from this branch's original test. Remove the const; use the Array.FindIndex result throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ * Fix V142 and V143 rung tests after dev merge added V142 between them V142 (#4196) landed on dev after the fix/3953-qs-interval-latest branch was cut. Merging dev bumped V143's ProbeOrdinal from 117 to 118 and made V142 no longer the top rung. IndexObjectStatsServerTimeIndexRungTests (V142): - Remove "I am the top rung" assertions (Assert.Equal(RungVersion, SchemaVersion) and Assert.Same(V142, Scripts[^1])). Replace with Assert.True(RungVersion < SchemaVersion). - Change Assert.Equal(ProbeOrdinal, arity - 1) to Assert.True(ProbeOrdinal < arity - 1). - Add nextArm check that V143's arm sits above V142's in MapProbedSchemaVersion. - Update class summary and comments to say claims moved to QueryStoreIntervalLatestRungTests. QueryStoreIntervalLatestRungTests (V143): - Bump ProbeOrdinal from 117 to 118 (V142 is now at 117). - Bump PreviousVersion from 141 to 142 (V142 is the rung directly below V143). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
Closes #4196.
Why
PgAnomalyDetector.ObjectGrowthSqlandObjectContentionSqlboth open with asnapsCTE that finds a server's two newest distinctcollection_timevalues inindex_object_stats. No index leads with(server_id, collection_time). The V1 index is(server_id, database_name, object_id, index_id, collection_time). The V22 index is(server_id, database_id, object_id, index_id, collection_time DESC). Both put unconstrained columns between the equality filter and the sort key. TimescaleDB's automatic per-chunkcollection_timeindex can drive theORDER BY ... LIMIT 2directly, using a SkipScan, but it has noserver_idcolumn. Every other server's rows in the newest chunk get read and rejected by a Filter before the two rows this server wanted turn up. The object-stats collector runs once daily per server, so the newest snapshot is always in the newest chunk alongside the rest of that day's fleet.I reproduced this on the rig (PG18 and TimescaleDB, seeded at the issue's own reported scale: 43 servers, about 12,000 index/table rows each in the busiest chunk):
Custom Scan (SkipScan)over the chunk's barecollection_timeindex,Filter: (server_id = $1),Rows Removed by Filter: 36000,Buffers: shared hit=92 read=972(about 8.3 MB), 18.5 ms.Index Only Scanusing the new index,Index Cond: (server_id = $1) AND (collection_time < ...), no Filter line,Buffers: shared hit=33 read=4, 0.4 ms.That is roughly 29x fewer buffers and 44x faster on this seed. The old plan's cost scales with the whole fleet's newest-chunk row count. The new plan's does not, so the ratio widens on a bigger fleet, consistent with the issue's own production numbers of 44,591 blocks read for one server's lookup on a 43-server store.
I confirmed the diagnosis is right. I did not find any part of it to be wrong.
What changes
No SQL text change. The
snapsCTE already has the ideal shape for a supporting index:DISTINCTplusORDER BYplusLIMITon exactly the sort key, filtered on exactly the leading equality column. It only lacked the index. I looked for a read shape that an existing index served instead of adding one:index_object_statsindex can drive this lookup without a full per-server scan, because both have unconstrained columns beforecollection_time.collect.collection_logalready has a(server_id, collection_time)index and records each collector run, so sourcing the two latest times from there was tempting. I rejected it. It needs to trust that a logged run's timestamp exactly matches the timestamp actually written intoindex_object_statsfor every run outcome, including partial failures, zero-row runs, and retries. It also touches the collector's write path rather than only the read. That is more blast radius, and a harder guarantee that results stay identical to today's, to save one small index.So this PR adds the pre-approved migration:
Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs):CREATE INDEX IF NOT EXISTS idx_index_object_stats_server_time ON collect.index_object_stats (server_id, collection_time DESC);. It carries the version-bump obligations:StorageVersion.SchemaVersion,ViewerDataService.StoreSchemaProbeSql's new sentinel,GetStoreSchemaVersionAsync's newGetBooleanread,MapProbedSchemaVersion's new top arm, and the handoff edits toCollectionCaveatsRungTestsnow that V142 is the new top rung.Locking and runtime.
CREATE INDEX CONCURRENTLYis refused outright on a TimescaleDB hypertable, and the migration ladder wraps every rung in a transaction anyway, so this is a plainCREATE INDEX IF NOT EXISTS. It takes the ordinary ShareLock on the hypertable root for its build, the same shape V22's index on this same table already uses.PgTableTuning'squery_store_statsindex (8-16 million rows per day) is big enough that its build was moved out of the ladder into the runtime Tuning stage.index_object_statsis the daily object-stats collector: roughly half a million rows per day fleet-wide, per the issue's own measurement.CompressAfterDays = 1means only about one day's chunk is ever uncompressed at migration time. Every older chunk's decompressed relation is an empty shell, so itsCREATE INDEXcost is one 8 KB page regardless of the rows inside it, the same propertyPgTableTuningmeasured forquery_store_stats. Measured on the rig: 228 ms end to end for the whole build, covering one 516,000-row uncompressed chunk at real fleet-daily scale plus two smaller chunks, one compressed. That is small enough to stay in the ladder rather than needing the Tuning-stage treatment. I declared this reasoning inMigrationDataMovingRungCensusPins(SetsTheFloor: false) so the migration-lock-timeout census stays honest about it.Lite parity (checked, not changed).
Lite/Analysis/AnomalyDetector.cshas the textually identicalsnapsCTE twice, at lines 165 and 216, over DuckDB. I did not change it. DuckDB has no TimescaleDB-style chunking or SkipScan; it is a vectorized columnar engine that scans a narrowcollection_timecolumn efficiently on its own. The specific pathology here, a shared hypertable chunk holding many other servers' rows, has no DuckDB equivalent, so this is not the same small change the brief allows fixing in this PR.Test plan
EXPLAIN (ANALYZE, BUFFERS)numbers, before and after (see Why).AnomalyObjectStatsLatestSnapshotsLiveTestsseeds 3 servers under test (one plain, one sharing the same two snapshot times with a growth tie and a contention delta, one with only a single snapshot ever) plus 8 filler servers sharing the newest snapshot time, reproducing the fleet-contamination shape. It asserts thatEXPLAIN (COSTS OFF)of thesnapsshape names the new index. Proven red by temporarily dropping the index and unregistering V142 on the rig:Assert.Contains() Failure: Sub-string not found... Not found: "idx_index_object_stats_server_time". Restored and reran green.ObjectGrowthSqlandObjectContentionSqlreturn exactly the expected prior, current, and growth values for the plain server. It also checks that the growth tie (+40mb on two objects) resolves to the tied delta, that the contention delta is exactly right, and that the single-snapshot server returns zero facts from both statements, since it has no prior snapshot to compare against and does not crash.IndexObjectStatsServerTimeIndexRungTests(V142's own file, following theCollectionCaveatsRungTests/CheckpointsTimedRungTestspattern) pins the ladder density, the index-only DDL shape, the probe sentinel, theMapProbedSchemaVersionarm, and its ordering above V141's arm.ViewerCollectionCaveatsGateTestsneeded its "last parameter" construction pinned to V141's specific ordinal now that V142 appended its own;MigrationDataMovingRungCensusPinsneeded the new rung declared; 2DocCommentHygieneTestsneeded two cross-project doc-comment references switched from an unresolvablecrefto plain<c>prose, since they name a real in-repo symbol the Storage project does not reference rather than a genuinely external one.dotnet build Darling/Darling.Tests/Darling.Tests.csproj. 0 Warning(s), 0 Error(s).Darling.Testssuite run once: 13,745 tests, 5 failed on the first pass. 4 were the version-bump fallout above, fixed and reconfirmed green. The 5th,CaptureDownChunkOrderTests, is acollection_log/blocked_process_reportlive test unrelated to anything this PR touches. It failed only against the shared rig database's accumulated state from earlier runs that day, and it passed clean when re-run alone against a freshly created database, confirming it is pre-existing. Given the deadline, I did not re-run the full 13,745-test suite a second time after the fixes. Instead I reran every affected class individually (all green) plus a combined run of every touched class (114 tests, all green).Rig: PG18 and TimescaleDB on port 55962, deleted after this PR's work finished.
CHANGELOG entry
None. This is an internal read-path performance fix with no user-visible behavior change: same facts, same thresholds, same detector output.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3