Skip to content

get_fleet_overview: one collection-health scan per minute per host, however many overview calls race (#3735) - #3738

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3735-fleet-health-rollup-singleflight
Sep 19, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/3735-fleet-health-rollup-singleflight

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Mechanism

DarlingFleetReader.GetFleetOverviewAsync fans 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-snapshot DISTINCT ON reads that cost one index descent per server. One does not: ReadFailingCollectorCountsAsync runs FleetCollectionHealthSql over v_collection_log with $1 = now - 7 days, whatever hours_back was. Three overview calls with three windows were three identical copies of one 7-day GROUP BY server_id, collector_name aggregate, 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 in DarlingFleetReader, one per NpgsqlDataSource through a ConditionalWeakTable): concurrent callers share ONE in-flight scan; a completed scan younger than CollectionHealthMemoLifetime = 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 band CollectorHealth.HealthStatus computes 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 lag get_collection_health, which reads the same rows uncached.
  • Per data source, not per process. The MCP host and the web host each build their own NpgsqlDataSource (different roles, different pools), so each holds its own memo and one scan per minute per host is the bound — the same reason ComposeStoreAvailability keys its cache this way, and the same protection for gated-live tests spinning several stores in one process.
  • The caller's nowUtc is the memo's clock. It is already the reader's freshness reference (the instant generated_at carries) and both production callers pass DateTime.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 whose now reads behind the memo's is served at age 0 — it is holding the freshest reading there is.
  • The shared scan runs on CancellationToken.None, bounded by the 20 s McpCommandDeadlines.ReadSeconds and the role's 15 s statement_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 (McpCommandDeadlines documents it), so on the surface the photo was taken the scan already ran to its deadline whatever the caller did; the web surface threads RequestAborted, 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 under None is bounded to one orphaned statement per host per minute, which is the bound this change promises anyway.
  • A failed scan is not memoized, and cannot become an unobserved exception. The shared task carries its outcome (ScanOutcome with an ExceptionDispatchInfo) 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/fleet serialize the same object through the same options): 0 when this call ran the scan, else the memo's age in whole seconds. generated_at minus this is the collector-health half's own reference instant. Its doc names the half it qualifies (per-card healthy_collector_count / failed_collector_count / collector_count / collector_severity / deadlock_collector_band / deadlock_source; the fleet's servers_with_collection_failures and deadlock_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

Why this shape

  • Single-flight rather than a background refresher: the read is on-demand on both surfaces and a host nobody is asking should not scan a 7-day window every minute forever.
  • A memo on the one window-independent read rather than on the whole overview: the other twelve reads change with the caller's window or with every collection cycle, and a fleet band that lagged a minute behind a fresh collection_time would trade a real signal for a cheaper one. This read's answer genuinely cannot move inside a minute.
  • Outcome-as-value rather than a faulting shared task: the departed-waiter case is the one the cancellation design creates, and a faulted task nobody awaits is exactly what UnobservedTaskException is for.
  • Func seam 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

  • Does not change FleetCollectionHealthSql, McpCommandDeadlines.ReadSeconds, the role's statement_timeout, or hours_back semantics — pinned by source anchors in the new test file.
  • Does not memoize any other overview read.
  • Does not move the compression band or the collectors' flush band — the issue's phase-grid hypothesis was the first reading; the second comment's source read superseded it and this PR builds on that.
  • Does not change the web page: fleet.js ignores 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:

  • five concurrent callers → one statement, one shared dictionary, age 0 for all;
  • served with its age at +30 s and +59.9 s (age 59), re-read at +60 s with a fresh dictionary and age 0, CollectionHealthMemoLifetime asserted equal to 60 s;
  • a caller whose now is behind the memo is served at age 0 with no statement;
  • a failed scan: every waiter present gets the ORIGINAL exception (same instance), and the next caller at the same instant runs statement Lite overview: show Online/Offline status for servers #2;
  • a failed RE-read does not evict the last good reading (a caller inside its minute still gets it; a caller past it starts Add check for updates feature #3 rather than inheriting the failure);
  • the first caller cancels while two wait: thrown to at once, the scan was handed a token with CanBeCanceled == false, the two complete on the same statement, a fourth caller 20 s on is a hit, ScansStarted == 1;
  • a scan every waiter abandoned still completes and is still memoized;
  • one memo per NpgsqlDataSource (same instance → same memo; second instance → different memo);
  • the age rides BuildRollup (default 0, explicit 42), serializes as "collection_health_age_seconds":42, and is the LAST key;
  • source anchor: the reader reaches ReadFailingCollectorCountsAsync at exactly one site, inside the memo's scan lambda; the SQL text and the CommandTimeout = McpCommandDeadlines.ReadSeconds line are byte-unchanged.

RepoFileAdoptionTests.s_lfReaders gains 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 (gated DARLING_TEST_PG, live-postgres collection, plants nothing): three real GetFleetOverviewAsync calls raced with windows 1/6/24 h → CollectionHealthMemoFor(postgres).ScansStarted == 1 and 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 DarlingFleetReaderTests class including the two live ones, FleetCardPostgresCpuLivePostgresTests (the other live GetFleetOverviewAsync caller), McpPayloadContractCensusTests, McpReadCommandTimeoutTests + CommandDeadlineScannerAdoptionTests, DocCommentHygieneTests (whole tree, cref resolver over the new doc blocks), StoreSqlClockDisciplineTests, FleetPageAttentionFilterTests, FleetCardCollectionStaleNamesItsPopulationTests, AbandonedRunEraInvariantReadTests, NothingReadBandsTests, DarlingMcpToolsTests, DarlingMcpFleetSweepToolsTests, RepoFileAdoptionTests. PerformanceMonitor.Darling.Service builds -c Release -p:EnableWindowsTargeting=true with 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 beyond BuildRollup's unchanged positional prefix.

Closes #3735

…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
erikdarlingdata merged commit 533c979 into dev Sep 19, 2026
4 of 7 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3735-fleet-health-rollup-singleflight branch September 19, 2026 14:17
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.
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>
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