Repository navigation
get_fleet_overview: one collection-health scan per minute per host, however many overview calls race (#3735) - #3738
Merged
Conversation
…owever many overview calls race (#3735) The 7-day collection-health rollup inside DarlingFleetReader.GetFleetOverviewAsync is window-independent — FleetCollectionHealthSql's $1 is always now - 7 days, and hours_back bounds only the blocking and deadlock reads — so a caller racing three overview calls with three windows had the store run three identical copies of one 7-day aggregate concurrently. Photographed 2026-09-19 13:36Z on the largest production store: three copies, two parallel workers each, nine backends on collection_log in pure I/O waits inside the collectors' flush band, the slowest crossing the mcp role's 15 s statement_timeout. Solo the plan runs in 680 ms. CollectionHealthMemo single-flights that read per NpgsqlDataSource: concurrent callers share one in-flight scan, a completed scan under 60 s old (the fastest collector cadence — nothing the rollup bands can move faster) is served from memory, a failed scan is never memoized, and the shared scan runs on CancellationToken.None bounded by the 20 s command deadline so one caller's departure releases only that caller. FleetOverviewResult gains a trailing collection_health_age_seconds (0 on a fresh scan) on both JSON surfaces. The SQL text, the other twelve reads, McpCommandDeadlines and hours_back semantics are unchanged. Tests: the memo through its Func seam (one scan for five racing callers; served with its age under a minute and re-read at 60; failure not memoized and not evicting the last good reading; the first caller's cancellation releasing only that caller; abandoned scans still memoized; one memo per data source; the age riding BuildRollup and serializing trailing; the reader reaching the statement only through the memo) plus a gated live test racing three real GetFleetOverviewAsync calls against one store and reading ScansStarted == 1.
erikdarlingdata
deleted the
fix/3735-fleet-health-rollup-singleflight
branch
September 19, 2026 14:17
This was referenced Sep 19, 2026
erikdarlingdata
added a commit
that referenced
this pull request
Sep 19, 2026
… PRs across the #3653, #3542 and #3691 waves, #3704, #3710, #3712 and #3735, spliced once (#3751) Sixty-five PRs merged to dev since #3672 (the first splice) across two seats - the coordinator's #3653 brains-review residue lanes and the wave-2 worker's #3542 / #3691 PostgreSQL-target engine lanes - and by rule none touched this file; each returned its entry to a buffer. This is the one splice. Text-only; one file; nothing outside ## [Unreleased] and the definition block moves. [Unreleased] gains a ### Changed between ### Added and ### Fixed (Keep-a-Changelog order) for the nine entries whose subject is a designed behaviour that now behaves differently - routing (#3712 confidence chooses the channel), gating (#3692 tempdb persistence, #3734 blocking/LRQ gates, #3724 peak-and-mean), the wire shape (#3719 one error envelope), the review guard's drift arm (#3673), lineage metadata (#3723) and two grading changes (#3693, #3747). Twenty-two new capabilities append to ### Added (the sixteen PostgreSQL-target fact families and rungs, CONFIG_CHANGED, the recurrence label, the V134/V135 columns, the review traps file, the measurement census); thirty-two defects append to ### Fixed. Order within each section is ascending PR number, as #3672 did. Every entry is one line and verbatim from its buffer (trailing whitespace only). Four PR pairs fold into one entry as the buffers wrote them (#3738+#3746, #3683+#3686, #3681+#3685, #3726+#3729); #3695 carries three entries. #3673 has no issue and cites none, like the file's ninety-six other ref-less entries. Five definitions added ([#3691], [#3704], [#3710], [#3712], [#3735]) at the end of the [Unreleased] run, which stays one ascending run (3517..3735). No archive touched; the split was not run. tools/changelog/changelog_archive.py verify: all PASS (1,483 bold entries, floor 1,329; 1,363 distinct refs resolve; 1,372 definitions; CRLF throughout; 409,263 bytes under the ceiling). Client-hygiene grep over the file: zero hits.
This was referenced Sep 20, 2026
erikdarlingdata
added a commit
that referenced
this pull request
Sep 22, 2026
…d twin, so it gets the same single-flight memo rather than a share of the rollup's (#3856) (#3860) * The per-server collection-health read is the fleet rollup's unmemoized twin, so it gets the same single-flight memo rather than a share of the rollup's (#3856) #3738 gave get_fleet_overview's 7-day collection-health rollup one scan per minute per host, because three racing copies of that aggregate were dying at the mcp role's 15 s statement_timeout inside the store's write bands. The per-server twin was left running on every call: get_collection_health's read is the same class of scan over the same table and the same seven days, and on 2026-09-21 21:3xZ it was cancelled by that same cap (57014) for the first time on record, during the hourly successor materializations' write burst. One caller, so no fan-out amplifier — the exposure is the band alone, which is why it took a week to appear once, and why the retry succeeded. #3856 preferred deriving this read from the scan the fleet memo already holds, gated on the two windows being the same fixed now - 7 days. They are: this tool cuts DateTime.UtcNow.AddDays(-7) and the rollup's reader cuts now.AddDays(-7). The derivation is still unavailable, for a reason underneath the window — the two statements are different reads of the same rows. FleetCollectionHealthSql is twelve plain aggregates that the memo holds as four per-server counts plus two band words, every per-collector fact summed away; CollectionHealthSql is twenty-nine columns per collector over a subquery carrying four window functions. There is nothing to filter, and widening the fleet statement to carry them is what its own comment refuses in terms: a fleet-wide sort of a week of collection_log in front of a GROUP BY that hashes today would spend the headroom #3735 bought. So this is #3856's first shape, with the window kept in the key rather than ignored: PerServerCollectionHealthMemo, keyed (server id, window start), the same 60 s lifetime read off the fleet reader's own declaration so one cadence argument governs both, and DarlingFleetReader.CollectionHealthMemo's primitive copied rather than re-derived — the caller's nowUtc as the memo's clock, the shared scan on CancellationToken.None so a departing caller releases only itself, a failed scan never memoized and never an unobserved exception, a good reading surviving a failed re-read. A caller asking for a different window gets its own key and its own statement instead of a reading cut from someone else's window: the direct read stays alive, it is simply never reached with a key that would lie. The payload gains collection_health_age_seconds, the field, name and semantics get_fleet_overview has carried since #3735, published unconditionally including on the 0 — a memo whose hit cannot be told from a fresh read has not reported — and trailing, so nothing an existing consumer indexes by position moved. * The per-server memo's pins, its roster entry, and the tool doc's age sentence (#3856) Eleven pins in PerServerCollectionHealthMemoTests, written beside the fleet twin's suite: N concurrent per-server calls plus a fleet call cost one scan each side, a call inside the 60 s lifetime is a memo hit whose collection_health_age_seconds is positive, a failed scan is never memoized and never an unobserved exception, and a different window start misses the memo by key rather than by luck. RepoFileAdoptionTests gains the file in both lists - its anchors span line breaks, so it reads through ReadRepoFileLf like the fleet suite it was written beside. get_collection_health's instruction row now says what the fleet row has said since #3735: the age field names how old the answer is and why repeat calls inside a minute do not race the scan. --------- Co-authored-by: erikdarlingdata <erikdarlingdata@users.noreply.github.com>
This was referenced Sep 22, 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.
Mechanism
DarlingFleetReader.GetFleetOverviewAsyncfans out thirteen bounded cross-server reads. Twelve of them depend on the call — they carry the caller's blocking/deadlock window, or they are latest-snapshotDISTINCT ONreads that cost one index descent per server. One does not:ReadFailingCollectorCountsAsyncrunsFleetCollectionHealthSqloverv_collection_logwith$1 = now - 7 days, whateverhours_backwas. Three overview calls with three windows were three identical copies of one 7-dayGROUP BY server_id, collector_nameaggregate, and because the web fleet page reaches the same read through/api/fleet, a browser's auto-refresh and an agent's tick stack the same way.This PR single-flights that one read and memoizes it for a minute, per data source:
CollectionHealthMemo(nested inDarlingFleetReader, one perNpgsqlDataSourcethrough aConditionalWeakTable): concurrent callers share ONE in-flight scan; a completed scan younger thanCollectionHealthMemoLifetime= 60 s is served from memory. Sixty because the rollup counts per-collector RUNS over seven days and the fastest collector cadence in the ladder is one minute — between two reads a minute apart at most one run per collector has landed, and no bandCollectorHealth.HealthStatuscomputes over a week of them can have moved. Shorter re-runs a 7-day aggregate to learn nothing; longer makes the fleet's collector dots lagget_collection_health, which reads the same rows uncached.NpgsqlDataSource(different roles, different pools), so each holds its own memo and one scan per minute per host is the bound — the same reasonComposeStoreAvailabilitykeys its cache this way, and the same protection for gated-live tests spinning several stores in one process.nowUtcis the memo's clock. It is already the reader's freshness reference (the instantgenerated_atcarries) and both production callers passDateTime.UtcNow, so the age published beside the payload is measured from the instant the payload itself publishes, and a test can drive expiry without sleeping. A caller whosenowreads behind the memo's is served at age 0 — it is holding the freshest reading there is.CancellationToken.None, bounded by the 20 sMcpCommandDeadlines.ReadSecondsand the role's 15 sstatement_timeout; a caller's own token releases only that caller (Task.WaitAsync(token)). When the first caller cancels while two others wait: that caller is thrown to at once, the statement keeps running for the two, and its result is memoized for everyone who arrives inside the next minute. Reasons, in weight order: the MCP surface never hands a token down at all (McpCommandDeadlinesdocuments it), so on the surface the photo was taken the scan already ran to its deadline whatever the caller did; the web surface threadsRequestAborted, and a tab that navigates away mid-refresh must not discard a scan two other waiters or the next tick are about to use — the alternative, a linked token cancelled when the last waiter leaves, turns an auto-refreshing browser into cancel-and-restart churn, N sequential copies of the statement instead of N concurrent ones; and the worst case underNoneis bounded to one orphaned statement per host per minute, which is the bound this change promises anyway.ScanOutcomewith anExceptionDispatchInfo) rather than faulting: waiters present rethrow the original with its own stack; the memo keeps whatever it last read successfully (still served while under a minute old — one timed-out re-read must not turn into a burst of retries from every caller of the next minute); the next caller past that starts a fresh scan. A scan every waiter had abandoned completes quietly instead of surfacing on the finalizer thread.FleetOverviewResult.collection_health_age_seconds— trailing, additive, on both JSON surfaces (the MCP tool and/api/fleetserialize the same object through the same options): 0 when this call ran the scan, else the memo's age in whole seconds.generated_atminus this is the collector-health half's own reference instant. Its doc names the half it qualifies (per-cardhealthy_collector_count/failed_collector_count/collector_count/collector_severity/deadlock_collector_band/deadlock_source; the fleet'sservers_with_collection_failuresanddeadlock_coverage), and the tool description and the MCP instructions row say the same in agent-facing words, plus the caller-side hygiene: call once with the widest window and derive, or sequence the calls.Evidence
get_fleet_overviewcall a single caller had raced with differenthours_backvalues — each with two parallel workers, nine backends oncollection_login pure I/O waits while the collectors' flush band was mid-COPY. The slowest crossed themcprole'sstatement_timeout = 15 s; the retry landed after the flush drained and succeeded. Solo, the plan runs in 680 ms as a healthy parallel aggregate. Hit-vs-miss hours track write-band intensity (3 of 9).pg_stat_statementsloaded: the gated live test's sixGetFleetOverviewAsynccalls (three raced with windows 1/6/24 h, one at +30 s, one at +61 s, one on a second data source) leftpg_stat_statementsreading 6 calls of the window-bound siblingFleetLastCollectionSql(unchanged: per call) and 3 calls ofFleetCollectionHealthSql— one for the three raced calls, one for the re-read past the minute, one for the second host's memo. Before this change that row would have read 6.Why this shape
collection_timewould trade a real signal for a cheaper one. This read's answer genuinely cannot move inside a minute.UnobservedTaskExceptionis for.Funcseam rather than a mocked Npgsql: the memo takes the statement as a function of the instant and the token it decides, so the unit tests count executions on the same object the reader routes through, and the live test counts them on the real store.What it does NOT do
FleetCollectionHealthSql,McpCommandDeadlines.ReadSeconds, the role'sstatement_timeout, orhours_backsemantics — pinned by source anchors in the new test file.fleet.jsignores the new field, which is what trailing-and-additive is for.Tests
FleetCollectionHealthMemoTests(new, Darling.Tests), through the memo's seam with a gated counting scan so N callers can be parked before it produces anything:CollectionHealthMemoLifetimeasserted equal to 60 s;nowis behind the memo is served at age 0 with no statement;CanBeCanceled == false, the two complete on the same statement, a fourth caller 20 s on is a hit,ScansStarted == 1;NpgsqlDataSource(same instance → same memo; second instance → different memo);BuildRollup(default 0, explicit 42), serializes as"collection_health_age_seconds":42, and is the LAST key;ReadFailingCollectorCountsAsyncat exactly one site, inside the memo's scan lambda; the SQL text and theCommandTimeout = McpCommandDeadlines.ReadSecondsline are byte-unchanged.RepoFileAdoptionTests.s_lfReadersgains the new file (one entry, with its reason): the source pin's anchors span line breaks, so the file reads LF-normalised and the bounded roster has to name it — the one red of 11,472 on the first CI run, executed green here before the push.FleetCollectionHealthMemoLivePostgresTests(gatedDARLING_TEST_PG,live-postgrescollection, plants nothing): three realGetFleetOverviewAsynccalls raced with windows 1/6/24 h →CollectionHealthMemoFor(postgres).ScansStarted == 1and every payload at age 0; +30 s → still 1, age 30; +61 s → 2, age 0; a second data source over the same store → its own memo at 1, the first still at 2.Executed here, not only in CI: a net10.0 xunit harness compiling the test sources against the built Service dll (the mactest pattern), against PostgreSQL 18 + TimescaleDB in Docker — 363/363 across the new file, every
DarlingFleetReaderTestsclass including the two live ones,FleetCardPostgresCpuLivePostgresTests(the other liveGetFleetOverviewAsynccaller),McpPayloadContractCensusTests,McpReadCommandTimeoutTests+CommandDeadlineScannerAdoptionTests,DocCommentHygieneTests(whole tree, cref resolver over the new doc blocks),StoreSqlClockDisciplineTests,FleetPageAttentionFilterTests,FleetCardCollectionStaleNamesItsPopulationTests,AbandonedRunEraInvariantReadTests,NothingReadBandsTests,DarlingMcpToolsTests,DarlingMcpFleetSweepToolsTests,RepoFileAdoptionTests.PerformanceMonitor.Darling.Servicebuilds-c Release -p:EnableWindowsTargeting=truewith 0 warnings. The classes that instantiate the WPF Viewer (UnmeasuredMetricsAreNotHealthyTests,FleetCardPostgresCpuTests,DeadlockRateBandRungTests, …) cannot run on this Mac and are CI's; none of them touches the changed lines beyondBuildRollup's unchanged positional prefix.Closes #3735