Skip to content

add the supporting index instead of scanning the whole fleet's newest chunk (#4196) - #4216

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/4196-anomaly-object-stats-read
Sep 25, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/4196-anomaly-object-stats-read

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #4196.

Why

PgAnomalyDetector.ObjectGrowthSql and ObjectContentionSql both open with a snaps CTE that finds a server's two newest distinct collection_time values in index_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-chunk collection_time index can drive the ORDER BY ... LIMIT 2 directly, using a SkipScan, but it has no server_id column. 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):

  • Before: Custom Scan (SkipScan) over the chunk's bare collection_time index, Filter: (server_id = $1), Rows Removed by Filter: 36000, Buffers: shared hit=92 read=972 (about 8.3 MB), 18.5 ms.
  • After (V142 index in place): Index Only Scan using 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 snaps CTE already has the ideal shape for a supporting index: DISTINCT plus ORDER BY plus LIMIT on 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:

  • Neither existing index_object_stats index can drive this lookup without a full per-server scan, because both have unconstrained columns before collection_time.
  • collect.collection_log already 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 into index_object_stats for 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:

  • V142 (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 new GetBoolean read, MapProbedSchemaVersion's new top arm, and the handoff edits to CollectionCaveatsRungTests now that V142 is the new top rung.

Locking and runtime. CREATE INDEX CONCURRENTLY is refused outright on a TimescaleDB hypertable, and the migration ladder wraps every rung in a transaction anyway, so this is a plain CREATE 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's query_store_stats index (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_stats is the daily object-stats collector: roughly half a million rows per day fleet-wide, per the issue's own measurement. CompressAfterDays = 1 means only about one day's chunk is ever uncompressed at migration time. Every older chunk's decompressed relation is an empty shell, so its CREATE INDEX cost is one 8 KB page regardless of the rows inside it, the same property PgTableTuning measured for query_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 in MigrationDataMovingRungCensusPins (SetsTheFloor: false) so the migration-lock-timeout census stays honest about it.

Lite parity (checked, not changed). Lite/Analysis/AnomalyDetector.cs has the textually identical snaps CTE 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 narrow collection_time column 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

  • Diagnosis confirmed on the rig with real EXPLAIN (ANALYZE, BUFFERS) numbers, before and after (see Why).
  • Regression pin, proven red against the pre-fix shape. AnomalyObjectStatsLatestSnapshotsLiveTests seeds 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 that EXPLAIN (COSTS OFF) of the snaps shape 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.
  • Correctness, including a tie. The same test asserts ObjectGrowthSql and ObjectContentionSql return 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.
  • New migration rung pinned. IndexObjectStatsServerTimeIndexRungTests (V142's own file, following the CollectionCaveatsRungTests/CheckpointsTimedRungTests pattern) pins the ladder density, the index-only DDL shape, the probe sentinel, the MapProbedSchemaVersion arm, and its ordering above V141's arm.
  • Fixed 4 pre-existing tests that the version bump broke on the first full-suite run: ViewerCollectionCaveatsGateTests needed its "last parameter" construction pinned to V141's specific ordinal now that V142 appended its own; MigrationDataMovingRungCensusPins needed the new rung declared; 2 DocCommentHygieneTests needed two cross-project doc-comment references switched from an unresolvable cref to plain <c> prose, since they name a real in-repo symbol the Storage project does not reference rather than a genuinely external one.
  • Build: dotnet build Darling/Darling.Tests/Darling.Tests.csproj. 0 Warning(s), 0 Error(s).
  • Every class in every touched test file run individually. All green.
  • Full Darling.Tests suite 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 a collection_log/blocked_process_report live 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).
  • The full suite has not been re-run a second time end to end. The coordinator can do that before merge if it wants extra assurance.

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

…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 erikdarlingdata changed the title DO NOT MERGE (anomaly object-stats read): add the supporting index instead of scanning the whole fleet's newest chunk (#4196) add the supporting index instead of scanning the whole fleet's newest chunk (#4196) Sep 25, 2026
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 03:40
@erikdarlingdata
erikdarlingdata merged commit 056523f into dev Sep 25, 2026
19 of 20 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4196-anomaly-object-stats-read branch 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant