Repository navigation
FinOps sizes: database-size latest-snapshot reads take a plan-time chunk bound (#4245) - #4252
Merged
Merged
Conversation
…nd (#4245) DatabaseSizeLatestSql (viewer + the MCP get_database_sizes twin), DatabaseSizeSummarySql, and StorageGrowthSql all found their newest database_size_stats snapshot through a correlated MAX(collection_time) subquery with no bound of its own, forcing TimescaleDB to build a subplan for every retained chunk before it could even start (148 ms planning against 2.6 ms execution over 71 chunks, per #4245's field measurement). Each now resolves its snapshot time(s) through a windowed probe first (bounded so the planner can exclude chunks outside it), falling back to an unbounded probe only when the window is empty, then binds the result as a literal equality the planner can exclude every other chunk against. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
GetDatabaseSizeSnapshotAtOrBeforeAsync's windowed probe and fallback both bounded above by DateTime.UtcNow, the viewer machine's own clock. A snapshot the collector host stamped after that clock (clock skew between hosts) was invisible to both the window and the fallback, so the "latest" read could skip the actual newest row. A latest read has no "at or before" semantics, so it must not carry that bound. Split it into GetLatestDatabaseSizeSnapshotAsync (Viewer) and a trimmed GetLatestSnapshotTimeAsync (MCP twin): windowed probe is collection_time >= windowStart only, fallback is a fully unbounded MAX - the same shape the pre-#4245 correlated subquery had. The 7-day-ago and 30-day-ago comparison points in StorageGrowthSql keep the upper bound via GetDatabaseSizeSnapshotAtOrBeforeAsync, since "at or before a past cutoff" is what they actually mean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…#4245) DatabaseSizeLatestPlanShapeLiveTests drives the actual product entry points (ViewerDataService.GetDatabaseSizeLatestAsync/GetDatabaseSizeSummaryAsync/ GetStorageGrowthAsync, DarlingObjectStatsReader.GetLatestDatabaseSizesAsync) against four seeded server shapes - actively collecting, newest snapshot 20 days old (fallback probe), zero rows, and newest snapshot 5 minutes in the future - and compares every row against the pre-#4245 raw unbounded MAX(collection_time) oracle (git 55b21e4^), which has no upper-bound failure mode and is ground truth for all four shapes including the future-stamped one. Proved once by reverting Darling/PerformanceMonitor.Darling.Viewer/ ViewerDataService.FinOps.Storage.cs and the MCP twin to 55b21e4 (this PR's first commit, which still had the DateTime.UtcNow upper bound this PR's checkpoint commit removed): the test failed exactly on the future-stamped server's MCP-twin rows (picked the day-old snapshot instead of the future one), then passed again once the fix was restored. 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 07:11
erikdarlingdata
enabled auto-merge (squash)
September 25, 2026 07:11
…t probe SQL constants Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ
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 #4245.
Why
DatabaseSizeLatestSql(viewer) andget_database_sizes(MCP) found the newestdatabase_size_statssnapshot through a correlatedcollection_time = (SELECT MAX(collection_time) ... WHERE server_id = $1)subquery withno bound of its own. TimescaleDB builds a subplan for every retained chunk before deciding
which holds the answer. The read pays for every chunk at plan time, regardless of how few
it needs.
DatabaseSizeSummarySqlandStorageGrowthSql'slatestCTE had the sameshape.
StorageGrowthSql'spast_7d/past_30dCTEs had a partially-bounded version:their inner
MAXhascollection_time <= $N, which only excludes chunks newer than thecutoff, not older ones.
What changes
All four now resolve "the snapshot time" as its own round trip before the main read.
Two helpers do this, mirroring how
DatabaseSizeLatestSqlitself is duplicated between theviewer and the MCP reader:
GetLatestDatabaseSizeSnapshotAsync(viewer) andGetLatestSnapshotTimeAsync(MCP, trimmed): for the three latest-snapshot reads. These serve
GetDatabaseSizeLatestAsync,GetDatabaseSizeSummaryAsync,GetStorageGrowthAsync'slatestCTE, and the MCP twin.A windowed probe (
collection_time >= asOf - 2 days) lets the planner exclude every chunkoutside the window. It falls back to an unbounded
MAXonly when the window is empty.That happens when collection stopped more than two days ago. Neither probe has an upper bound.
A latest read has no "at or before" semantics. Bounding by the reader's clock is wrong.
A snapshot the collector stamped a few minutes ahead is still the latest one. The first
version of this fix dropped it. (See "Fixed after the first commit" below.) The fallback is
the unqualified
MAX(collection_time)the pre-fix code always ran. It has no failure modeto introduce.
GetDatabaseSizeSnapshotAtOrBeforeAsync(viewer only): forStorageGrowthSql'spast_7d/past_30dCTEs. These mean "at or before a past cutoff." They keep the upperbound:
collection_time >= asOf - 2 days AND collection_time <= asOf.collection_time = <the resolved literal>instead ofa subquery: the change that actually lets the planner exclude chunks.
Correctness argument: if the windowed probe finds any row, its
MAXis the true unboundedMAX. Nothing older than the window's start can be newer than something inside it. Thefallback only fires when the window is genuinely empty, never as an approximation. A server
with no database-size history returns null from both probes. The main read is skipped
entirely (same as the old behavior: equality against a NULL subquery result produced zero
rows).
DatabaseSizeSummarySql'stopNmoved from$2to$3(the resolvedcollection_timeisnow
$2). A pin (ViewerFinOpsSqlTests) checkedLIMIT $2and is updated toLIMIT $3.Lite: not touched. Lite reads DuckDB, which has no chunks, so this TimescaleDB plan-time
cost has no DuckDB equivalent.
Not fixed here:
IdleDatabasesSql'sdb_sizesCTE andTempdbSummarySqlalso read throughan unbounded correlated MAX. Same file, same bug shape, not named in the issue. Not measured
or touched.
Fixed after the first commit: latest reads must not upper-bound by the reader's clock
The first commit's
GetDatabaseSizeSnapshotAtOrBeforeAsyncbound every caller byasOf.This included the three call sites that mean "the latest snapshot, period"
(
GetDatabaseSizeLatestAsync,GetDatabaseSizeSummaryAsync,GetStorageGrowthAsync'slatestCTE) and the MCP twin'sGetLatestSnapshotTimeAsync. A snapshot the collectorstamped after the reader's clock (clock skew, which the old unbounded
MAXnever caredabout) was invisible to both probes. The "latest" read skipped the actual newest row.
Ruled: a latest read takes no upper bound. Only the 7-day-ago and 30-day-ago comparison
points in
StorageGrowthSqlkeep one: "at or before a past cutoff" is what they mean.Split into
GetLatestDatabaseSizeSnapshotAsync(viewer) andGetLatestSnapshotTimeAsync(MCP). Proven with a live test. It fails on the first commit's shape and passes on this one. See below.
Diagnosis, confirmed with numbers
Seeded the rig's
probedatabase: four server shapes (actively collecting, 20 days stale,zero rows, and 5 minutes into the future). 35-75 days of
database_size_statsper server,1-day chunks, 76 total after
ANALYZE. MeasuredEXPLAIN (ANALYZE, BUFFERS)through Npgsqlwith bound parameters, matching how the product actually binds them. The codebase does not
set
Max Auto Prepareanywhere (checked), so Npgsql's default applies (auto-preparedisabled). These are the un-prepared numbers the product actually gets.
DatabaseSizeLatestSql)get_database_sizes)Numbers are one run on a local dev rig (Windows, PostgreSQL 18.6 / TimescaleDB 2.30.1).
Planning time on "before" varied 2-3x run to run (6-58 ms, likely background
TimescaleDB workers/checkpoints). The after shape was consistently under 1 ms per statement.
The improvement held in every run. The numbers are lower than the issue's field numbers
(148 ms / 71 chunks): the seeded rig has 76 mostly-empty chunks. The mechanism
(plan-time chunk exclusion vs. plan-time chunk walk) is the same either way and scales with
chunk count, not row count.
Also confirmed directly: the stale-20d server's windowed probe misses and falls back
correctly. The future-5m server's unbounded fallback returns a stamp after "now"
(
is_after_now=true), the case the first commit's upper bound hid.What changes (tests)
ViewerFinOpsTests.cs: theFinOpsReads_PgDialect...theory gained the two new latest-onlyprobe constants (
DatabaseSizeLatestSnapshotWindowedProbeSql/...FallbackProbeSql) alongside the pre-existing bounded ones.DatabaseSizeLatestPlanShapeLiveTests([Collection("live-postgres")], gated onDARLING_TEST_PG): drives all four reader methods for all four seeded server shapes.Compares every row against the pre-WPF FinOps latest-database-sizes read plans for 148 ms over every database_size_stats chunk to execute in 2.6 ms #4245 unbounded
MAX(collection_time)oracle.The oracle has no upper-bound failure mode, so it is correct for the future-stamped server
too. Regression proof: reverted to the first commit's upper-bound shape and reran.
It failed on the future-stamped server's MCP rows. Restored the fix and reran clean.
Test plan
dotnet buildonPerformanceMonitor.Darling.Viewer,PerformanceMonitor.Darling.Service,and
Darling.Tests: 0 Warning(s), 0 Error(s).ViewerFinOpsSqlTests,DarlingMcpObjectStatsToolsSurfaceAndSqlTests,McpLatestSnapshotStampTests: 106 total, 0 failed.EXPLAIN (ANALYZE, BUFFERS)before/after, bound via Npgsql, on a 76-chunk seeded rig: seethe numbers table above.
DatabaseSizeLatestPlanShapeLiveTests): row-identity betweenthe pre-WPF FinOps latest-database-sizes read plans for 148 ms over every database_size_stats chunk to execute in 2.6 ms #4245 unbounded-MAX oracle and the current two-step shape, all four server shapes
including the future-stamped one. 1 total, 0 failed, against
darlingtest.(failed on the future-stamped server), restored the fix, reran (passed).
git merge origin/devand one fullDarling.Testsrun against a freshly recreateddarlingtest. Not done: this lane hit its context budget after finishing the itemsabove. Every other item in the original checklist is now done. This is the remaining
step before this PR leaves draft. The rig (
C:\GitHub\worktrees\rig-f1, port 55975,UTC, PostgreSQL 18.6 + TimescaleDB 2.30.1) is stopped but intact, with
darlingtestand
probestill present.What to double-check
GetDatabaseSizeSnapshotAtOrBeforeAsyncfor the 7d/30d comparison points. The live testand the revert-and-rerun proof both back this. Worth a second look: it is a correction to
this PR's first commit.
git merge origin/dev+ freshdarlingtest+ fullDarling.Tests)is still outstanding: someone needs to run it before this leaves draft.
collector's ~60-minute cadence, not a measured optimum.
IdleDatabasesSql/TempdbSummarySql(same file, same bug shape, not touched) areworth a follow-up issue.
CHANGELOG entry
SECTION: Fixed
ENTRY:
REF:
[FinOps sizes: database-size latest-snapshot reads take a plan-time chunk bound (#4245) #4252]: FinOps sizes: database-size latest-snapshot reads take a plan-time chunk bound (#4245) #4252
Generated with Claude Code
https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3