Repository navigation
AG reads: share one floor-bounded newest-snapshot read across Viewer and Service (#4228) - #4255
Merged
Merged
Conversation
…s Viewer/Service (#4228) The Viewer's and Service's AG replica/database reads each found "the newest snapshot per server" by joining to an unbounded MAX(collection_time) GROUP BY server_id, which reads every retained row for every server (2,803 ms / 441k buffers for 398 rows measured on a production store). Move the reads into one shared PerformanceMonitor.Darling.Storage.DarlingAgStatesReader (the Viewer has no route to the Service assembly), split each into a per-server newest-instant step (ordered LATERAL descent, driven from servers, not scoped to is_enabled per the #4236 lesson) and a floor-bounded step two identical to the shipped statement plus collection_time >= $floor on both arms. A staleness horizon (one chunk width) keeps a server whose AG rows stopped a day ago from dragging the shared floor back; it is read on its own instead, so results stay identical to today for every server, including a disabled or removed one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…ader move Adds DarlingAgStatesReaderLiveEqualityTests: ten seeded server shapes (six current, one stopped 2 days ago, one stopped 2 hours ago, one disabled with current rows, one removed from config_monitored_servers but still present in servers) proving every entry point (viewer, /api/ag, /api/ag/count, get_ag_health with and without a server filter) matches the pre-#4228 unbounded statements row for row, plus an EXPLAIN pin proving the shared floor and the stale server's own point read both plan meaningfully fewer chunks than the unbounded oracle. Verified once that the equality test fails when the stale-server merge is neutralized, then reverted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…t shape G1's PR body dismissed Lite parity because "a Lite install monitors one server" - wrong, Lite can monitor several, and LocalDataService.AvailabilityGroups.cs reads per server with WHERE server_id = $1 and an unbounded per-server MAX(collection_time), which can scan that one server's whole retained history regardless of fleet size. Timed against a DuckDB table seeded at the real ScheduleManager defaults for both AG collectors (1-minute frequency, 30-day retention, several servers interleaved by collection tick): the old correlated-MAX query measured 47-93 ms, over the ~20 ms parity bar. GetLatestAgReplicaStatesAsync / GetLatestAgDatabaseReplicaStatesAsync now use the same two-step newest-instant split DarlingAgStatesReader uses: an ordered-descent step one (ORDER BY collection_time DESC LIMIT 1), then a step two bound by that exact instant. Lite's read is already scoped to one server per call, so step two uses a plain equality bound instead of Darling's >= floor aggregate (no fleet-wide shared floor to protect). Query time dropped to 2-4 ms in the same seed. New AgAvailabilityGroupNewestSnapshotEqualityTests.cs pins the new code against the old SQL text, row for row, across a tie (several rows sharing the newest collection_time) and a NULL (a WSFC-quorum-loss row with NULL ag_name/replica_server_name that both old and new code must drop). Proved once by hand that loosening the new bound to <= makes the test fail (NODE-OLD leaks in); reverted, git diff confirms the reader matches this commit. Filed #4262 for a separate, pre-existing finding: every LocalDataService call opens a fresh DuckDB connection (37-50 ms alone), which is why the full method call still isn't under 20 ms even after this fix. That's orthogonal to #4228's unbounded-scan diagnosis and a much larger, systemic change - out of scope for this lane. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
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 09:11
This was referenced Sep 25, 2026
get_ag_health returns ~266 KB fleet-wide with no cap: too large for an MCP client's first call
#4471
Closed
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 #4228.
Why
Both Availability Group reads (replica states and database-replica states) found each server's newest
snapshot by joining to
SELECT server_id, MAX(collection_time) ... GROUP BY server_idwith no bound oncollection_time. That inner aggregate reads every retained row for every server before it can answer.TimescaleDB cannot exclude a single chunk: 2,803 ms and 441k buffers for 398 rows on a production store
(#4228's own measurement). The same shape appeared in four places: the Viewer's AG tab,
get_ag_health,/api/ag, and Lite's ownalert read (found in this pass, see G3).
What changes
Darling/PerformanceMonitor.Darling.Storage/DarlingAgStatesReader.cs: one shared reader, the sameprecedent as
DarlingQueryStoreClutterReader. The Viewer's AG tab,/api/ag,/api/ag/countandget_ag_healthall call it now instead of carrying their own copy of the statement text.(
ORDER BY collection_time DESC LIMIT 1), driven fromservers. It uses an index range scan per server,not an unbounded aggregate. Step two is the shipped statement's exact shape (inner aggregate, join back on
the found instant, the
servers ... AND is_enabledjoin). It addscollection_time >= $floorto both theinner aggregate and the outer scan.
TimescaleSupport.ChunkIntervalDays). A server whose newest AG snapshotis older than that does not feed the shared floor. It is read on its own instead, with its own instant as a
single-server floor.
is_enabledfilter on step one, on purpose. Every step-two statement keeps the exactJOIN servers AS s ON s.server_id = ... AND s.is_enabledthe shipped statements always had, unchanged. Adisabled server is excluded from the output exactly as it is today.
search_path = collect, config, public).last_hardened_lsn,last_commit_lsn)./api/ag/countnav-probe aggregate too (GetAvailabilityGroupCountAsync), the identicalunbounded shape one function away from the two named in the issue.
DarlingAgStatesReaderTests.cs. Added a source pin asserting nopublic SQL constant runs
MAX(collection_time) ... GROUP BY server_idunbounded, and pure unit tests forthe staleness split.
G2 verification
Ran on a PostgreSQL rig (port 55975, UTC, TimescaleDB), against
darlingtest, after G1's PR sat with everylive/EXPLAIN box unchecked.
Darling/Darling.Tests/DarlingAgStatesReaderLiveEqualityTests.cs.Seeded with 10 server shapes across several daily chunks. Six current servers. One stopped 2 days ago
(staleness-horizon path). One stopped 2 hours ago (inside the horizon, still feeds the floor). One
disabled with current rows. One removed from
config_monitored_serversbut still inservers.-
AgStatesReader_MatchesTheOldUnboundedStatements_AcrossEveryServerShape_AgainstDevPostgres:compares
GetReplicaStatesAsync,GetDatabaseReplicaStatesAsync, andGetReplicaGroupCountAsyncrow-for-row against the pre-The Availability Group read finds each server's newest snapshot by aggregating every retained AG row: 2.8 s and 441 k buffers for 398 rows; a two-step read returns the same rows in ~3 ms #4228 unbounded statement text. Runs both unfiltered (scoped to the
test's own servers) and filtered per shape. Also drives the entry points directly:
ViewerDataService.GetAvailabilityGroupsAsync,DarlingAgReader.GetAgHealthAsync,GetAvailabilityGroupCountAsync, andDarlingMcpAgTools.GetAgHealthwith and without a servername. Every shape matches: disabled excluded, stale/inside-horizon/removed included at their true
instants. The tie at each server's newest snapshot (2 replicas, 2 databases sharing one
collection_time, the normal shape of any AG snapshot) round-trips identically.collection_timeisNOT NULLon both AG tables (schema-enforced since V34). The tie is the onlyordering edge case there is, and it is covered.
- Proved the pin catches a regression, once. Temporarily neutralized the "stale" merge loop in
GetReplicaStatesAsync(theforeach (var server in stale)block). A stopped-2-days-ago server'srows silently vanished from the read. Reran: the equality test failed
(
Assert.Equal() Failure: Collections differ). Reverted.git diffon the reader is empty.Both facts pass again.
The issue's EXPLAIN assertion, same file,
AgStatesReader_StepTwoStaysBounded_ForCurrentAndStaleServers_AgainstDevPostgres: runs the realstep one +
DarlingAgStatesReader.SplitCurrentAndStaleto get the production floor, thenEXPLAIN (COSTS OFF)old vs. new. Made relative, not an absolute ceiling.darlingtestis sharedwith the rest of the suite. Another live class's own near-"now" seed can add a chunk this seed did
not create. The same reasoning
FleetReadsAreBoundedByTheFleetTestsandEventWindowedReadsAreBoundedLivePostgresTestsalready use for the identical risk. Measured once byhand (forcing the assertions to fail momentarily to print the counts, then reverting.
git diffconfirms the file matches this PR's committed version):
A real finding: the stale server's own point read is not literally a single-chunk read for every case.
Its
WHERE collection_time >= floorhas no upper bound. The shared table keeps gaining new chunks everyday regardless of this one server's staleness. Its plan touches every chunk from its own floor's day
through today (3 real chunks for a 2-days-stale server in this seed, not 1). It is still strictly
cheaper than the unbounded oracle: bounded below by its own data, not scanning from the dawn of
retention. Results are unchanged either way. This is a plan-shape and doc-precision note, not a
correctness bug. The reader's code and doc comments are left alone per this lane's verify-only mandate.
Worth a one-line doc tweak in a future pass.
G3 verification (this pass)
Picked up the three boxes G2 left unchecked. Rig reused at port 55975 (UTC, TimescaleDB),
RIG_DIRC:\GitHub\worktrees\rig-f1.1. Lite parity: was wrongly dismissed, now fixed
G1's premise ("a Lite install monitors one server") is wrong. Lite can and does monitor several servers
(
Lite/Services/LocalDataService.AvailabilityGroups.csreads per server withWHERE server_id = $1). Anunbounded per-server
MAX(collection_time)can scan that one server's whole retained history regardless offleet size. The cost driver is retention, not fleet size.
Seeded a Lite DuckDB (
SharedDuckDbFixturepattern) at the realScheduleManagerdefaults for both AGcollectors (
ag_replica_states/ag_database_replica_states: 1-minute frequency, 30-day retention). Used 5servers with rows interleaved by collection tick, 2 replicas and 2 databases per snapshot: 432,010 and
864,020 rows. This is the shape a multi-server Lite install actually writes, not clustered per server. Timed
GetLatestAgReplicaStatesAsync/GetLatestAgDatabaseReplicaStatesAsyncfor one server:overhead, was the dominant cost: 40-90 ms for the old correlated
MAXsubquery.Gave both reads the same two-step newest-instant shape
DarlingAgStatesReaderuses. Step one is anordered-descent (
ORDER BY collection_time DESC LIMIT 1, answered by the existing(server_id, collection_time)index). Step two is bound by that exact instant. Lite's read is already scoped to oneserver per call, so step two uses a plain equality bound instead of Darling's
>= flooraggregate. There isno fleet-wide shared floor to protect here, so the simpler bound is correct and sufficient.
consistent with
DarlingAgStatesReader's fix.LocalDataService.OpenConnectionAsync) still measures~40-55 ms. Every single call opens a brand-new
DuckDBConnection(
_duckDb.CreateConnection(); await connection.OpenAsync();), and that alone costs 37-50 ms with no queryat all. This is pre-existing across roughly 184 read call sites in
LocalDataService, perSharedDuckDbFixture's own doc comment. It is not something The Availability Group read finds each server's newest snapshot by aggregating every retained AG row: 2.8 s and 441 k buffers for 398 rows; a two-step read returns the same rows in ~3 ms #4228 or this fix introduced. Fixing it is amuch larger cross-cutting change: connection pooling or reuse, with a real design question about the
existing
ReaderWriterLockSlimlocking. Filed Lite: every DuckDB read opens a fresh connection (LocalDataService.OpenConnectionAsync) #4262 rather than fixing it in this verify-only lane.New
Lite.Tests/AgAvailabilityGroupNewestSnapshotEqualityTests.cs(
AgAvailabilityGroupNewestSnapshotEqualityTests) pins the new two-step code against the OLD (pre-#4228)correlated-subquery SQL text, row for row, for both grains. Each seed covers:
collection_time, the normal shape of any AG snapshot.shape (
ag_namefor the database grain,replica_server_namefor the replica grain). Old and new codeboth drop that row without affecting which instant is "newest".
collection_timeitself isNOT NULLin theDuckDB schema, matching what G2 found on the Postgres side.
Both facts pass, 2 of 2. Proved the pin catches a regression, once. Loosened the new code's step-two bound
from
collection_time = $2tocollection_time <= $2temporarily, rebuilt, and reran.The tie/NULL test failed (
Assert.Equal() Failure: Collections differ, the stale row leaking in). Revertedthe change.
git diffon the reader now matches this PR's committed shape. Both facts pass again.2. Live EXPLAIN (ANALYZE, BUFFERS)
Seeded the rig's
probedatabase (migrated, TimescaleDB-converted) with G2's exact same 10-server-shapeseed (
DarlingAgStatesReaderLiveEqualityTests's shapes). RanEXPLAIN (ANALYZE, BUFFERS)on today'sstatements (the same pre-#4228 oracle text G2 verified against
origin/dev) and on the new two steps.Parameters were bound the way Npgsql binds them:
NpgsqlParameterwith an explicitNpgsqlDbType/TypedValue, not string interpolation.Chunk-scan counts match G2's
COSTS OFFfinding exactly: 12 to 2 for the shared/current case, 12 to 6 forthe stale server's own read. The ms/buffer deltas at this seed's scale are small in absolute terms. This is
the same 10-server seed G2 used for the chunk-count assertion, not a production-scale seed.
At that size most pages are already cache-resident (
read=0almost everywhere). Wall-clock and buffer countsare dominated by fixed per-statement overhead rather than data volume. The structural win is chunk exclusion.
At production volume that is the difference between the issue's 2,803 ms and 441k buffers, and the few
milliseconds a bounded read costs. The
probedatabase was not re-seeded at production scale. G2's shapeswere what this item asked for. The chunk-count story they produce is the mechanism, confirmed twice
(
COSTS OFFandANALYZE, BUFFERS) on two different seeds.3. Full suite, merged with
origin/devMerged
origin/devclean, no conflicts (84 files changed upstream, mostly FinOps, JobHistory and QueryStorework unrelated to this PR).
Darling.Tests, freshly recreateddarlingtest: 13887 total, 3 failed, 47 skipped, 1 not run (799.8s).The 3 failures are
CaptureDownChunkOrderTests,PgTargetAnomalyTests, andServerListAndSummaryPlanShapeTests. These are in files this PR never touches (Lite and Darling AG codeonly). Re-ran all three, alone, on a freshly recreated
darlingtest: 48 of 48 pass, 0 failed. Thatconfirms they are the known shared-store, order-sensitive live-test flakes. Not caused by this change, and
not filed, per the "passes fresh and alone" rule.
Lite.Tests, full suite, since this pass touched Lite: 5344 total, 0 failed, 0 skipped (370.3s).Rig stopped after this run (
pg_ctl stop -m fast).Test plan (from G1, still standing)
dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Debug:Build succeeded, 0 Warning(s), 0Error(s) (reconfirmed by G2 and G3).
DarlingAgStatesReaderTests,DarlingAgReaderTests,AgTopologyCardsTests,AvailabilityGroupCountReadTests,DarlingMcpAgToolsSurfaceTests,DarlingMcpAgToolsLivePostgresTests.McpPayloadContractCensusTests,StartupCommandTimeoutTests,StorageCommandTimeoutTests,DocCommentHygieneTests,DarlingWebEndpointsTests,McpToolGuideTests.Darling.Testssuite once, merged withorigin/dev, freshly recreateddarlingtest: 13887total, 3 failed, 47 skipped, 1 not run (G3). The 3 failures pass alone on a fresh database and are
confirmed unrelated.
Lite.Testssuite once: 5344 total, 0 failed (G3).CHANGELOG entry
What to double-check
files this PR never opens.
DarlingAgStatesReader's type doc is optimistic for a server stale bymore than a day. Still strictly cheaper and correct, but worth a one-line correction next time the file is
touched.
Generated with Claude Code
https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ