Skip to content

Make the viewer's fleet-timer freshness reads single-flight (#2907) - #2910

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/2874-freshness-read-guards
Sep 4, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/2874-freshness-read-guards

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2907

OnRefreshTimerTick fired RefreshServerStatusAsync and RefreshStoreSizeAsync unawaited with no in-flight guard, while the two siblings in that same fan-out both had one (_alertPollInFlight, and _refreshInFlight plus its _refreshRequested replay). A read that outlived the interval was simply joined by the next tick's.

Three numbers, all in the shipped code, make that expensive rather than untidy:

  • the interval is NocRefreshIntervalSeconds — default 30, clamped to a floor of 10 (ViewerAppSettings.cs:201);
  • the store connection string sets MaxPoolSize = 10 deliberately (Cap the viewer's store pool + document the postgres.exe process anatomy (round-4 field follow-up) #1566), so read eleven waits ConnectionTimeoutSeconds for a slot and then throws a connect error that misattributes a slow store to the network;
  • the fan-out is not the "cheap pair of single-query reads" its comment claimed. RefreshServerStatusAsync awaits a second read through UpdateCollectorHealthTextAsync; PollAlertsAsync awaits a third through UpdateServerSilencedAsync. One tick is five store reads before RefreshVisibleAsync starts — six while the AG tab is still hidden, which is the ship default on a non-AG fleet.

#2901 bounded each of these at InteractiveReadSeconds (15 s) and deliberately left the guarding alone. That was the right split: a deadline caps how long a stacked read can hold a permit, which is a strictly smaller claim than not stacking.

The two guards are different shapes on purpose

Not a style choice — a dropped request is recoverable in one case and not the other.

RefreshStoreSizeAsync gets _alertPollInFlight's plain drop. It takes no scope: one store-wide pg_database_size, one status-bar field. The in-flight read paints that field with an answer no staler than a fresh read's, so a dropped request is genuinely redundant rather than merely late, and no caller is left waiting on a value the running read will not produce.

RefreshServerStatusAsync gets the coalescing replay, opt-in per call site. The freshness dictionary is fetched before an add/edit/remove and applied to _fleet.All after it, so a server registered mid-read is simply absent from the dictionary and paints as never-collected. Dropping the connect/reload caller would leave a just-added server's dot wrong for a whole interval. But the replay is opt-in, because a periodic caller must still drop: the next tick is already its retry, and replaying one would delete the interval's gap exactly when the store is slowest, which is the state being fixed rather than a fix.

The Overview double-fire

OnOverviewTimerTick stopped duplicating the fan-out. It ran at the same interval and OnRefreshTimerTick's Overview early-return sits below its fan-out, so with the Overview selected — the tab that ships selected — the viewer issued two concurrent freshness read-pairs per cycle.

Moving OnRefreshTimerTick's fan-out below the early-return instead would have been a regression: it would silence the alert poll and the store-size read entirely while the Overview or a per-server tab is up, which is exactly what "regardless of the visible tab" is there for. And once the guard exists the duplicate is worse than wasteful — a periodic second call can only be dropped, and one that asked to replay would guarantee the extra pass the guard exists to prevent.

Comments corrected

Three, not two — the third is one #2901 left behind:

  • MainWindow.xaml.cs fan-out: "a cheap pair of single-query reads" → the real count, and why all three are single-flight.
  • MainWindow.xaml.cs _overviewTimer: "they never double-refresh the same grid" was true of the grid and false of these reads, because the early-return that guarantees it sits below the fan-out. Now says so narrowly.
  • ViewerCommandDeadlines.InteractiveReadSeconds: its permit argument cited "an unguarded fleet-timer read can re-fire every 10 s", which was true when it landed and is not now. The ten-way panel fan-out is the binding permit argument on its own, and it is not a guarding problem — those ten reads are one deliberate Task.WhenAll, not an overlap.

Pinning

ViewerFleetTimerGuardTests walks the shipped source. The defect was never in any method's logic — every one of these methods was individually correct, and #2901 reviewed these same methods without the gap surfacing. What was wrong is that a fan-out site and a guard were written in two different places with nothing tying them together. There is also nowhere to stand a behavioural test up: this is MainWindow code-behind on a DispatcherTimer, no seam, no injectable clock.

The invariant: no path from a fleet-timer fan-out target to a _dataService read may exist without a single-flight guard on it. The walk stops at the first guard it finds, which is what lets it be honest about a guard that legitimately lives one level down — the AG probe's is AvailabilityGroupsTab.LoadAsync's _loading, not in MainWindow at all. The guard is matched by shape, never by field name: the three that predate the pin are _refreshInFlight, _alertPollInFlight and _loading, so a name pattern would have needed an exception on day one.

Stacking and leaking are checked separately, and that split is load-bearing. Folding the finally release into the stacking predicate looked tidier and was wrong twice over: it made the release assertion unreachable (it only ever saw bodies that had already satisfied the release check) and it pointed a leaked guard at the stacking message, sending the reader to look for a guard that is sitting right there. The first version of this pin had exactly that bug; the mutation run below is what found it.

Proven red five ways, each mutation failing exactly one assertion with the message that names its own fix:

mutation fails message
delete RefreshServerStatusAsync's guard stacking RefreshServerStatusAsync -> ReadAndApplyServerStatusAsync
delete RefreshStoreSizeAsync's guard stacking RefreshStoreSizeAsync
release the guard outside its finally release _statusRefreshInFlight is not reset in a finally
put the call back on the Overview tick double-fire fired by BOTH fleet timers
have a fleet timer pass replayIfBusy replay turns a slow store into back-to-back reads

The first row is the one that matters most: the fix moves the freshness read into a helper, so a naive "does this method touch _dataService" check would have excused an unguarded RefreshServerStatusAsync — it touches no store at all now. The walk descends and names the helper.

Verification

  • PerformanceMonitor.Darling.Viewer and Darling.Tests both build clean on macOS (EnableWindowsTargeting), 0 errors, no new warnings.
  • The Windows suites cannot execute on macOS, so the four [Fact]s were run against the real source by compiling the actual test file, unmodified into a throwaway net10.0 console harness with a ~30-line xUnit shim. That is the shipped assertion running on the shipped source, not a retyped copy. 4/4 pass; all five mutations above were run through the same harness.
  • Not covered locally: the test file's execution under xunit.v3 / MTP, and whether the [CallerFilePath] repo-root walk resolves in CI's checkout. ViewerCommandTimeoutTests already relies on that same walk, so it should — but CI is the arbiter.

Known adjacent gap, filed not fixed

UpdateCollectorHealthTextAsync is reachable from two places — inside RefreshServerStatusAsync, and awaited directly by MainTabs_SelectionChanged because the health text's scope flips with the active tab. Guarding RefreshServerStatusAsync does not serialize those against each other, and the method reads MainTabs.SelectedItem before its await, so a tab switch during a timer pass can have the timer's copy repaint the pre-switch scope after the tab handler already painted the correct one. That is a last-writer-wins scope race, not read stacking; it is user-gesture-bounded rather than timer-driven; and it predates this change. Written up in #2907 rather than fixed here.

OnRefreshTimerTick fired RefreshServerStatusAsync and RefreshStoreSizeAsync
unawaited with no in-flight guard, while the two siblings in the same fan-out
both had one. With the interval clamped to a 10s floor, a 15s per-read deadline
and MaxPoolSize = 10, a slow read was joined by the next tick's rather than
being the only one outstanding.

The two guards are different shapes on purpose: store size takes no scope, so a
dropped request is redundant and it gets _alertPollInFlight's plain drop. The
freshness read is fetched before an add/edit/remove and applied to the fleet
after it, so a server registered mid-read paints as never-collected - that
caller gets the coalescing replay, opt-in per call site so the periodic callers
still drop rather than deleting the interval's gap.

OnOverviewTimerTick also stopped duplicating the fan-out, which ran two
concurrent freshness read-pairs per cycle on the tab that ships selected.
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed the diff (CHANGELOG.md, MainWindow.ServerManagement.cs, MainWindow.xaml.cs, ViewerCommandDeadlines.cs comment-only, and the new ViewerFleetTimerGuardTests.cs source pin).

Correctness — checked out clean:

  • _statusRefreshInFlight/_statusRefreshRequested mirrors the existing _refreshInFlight/_refreshRequested shape at MainWindow.xaml.cs:1071-1089 exactly (check-then-set before the first await, reset in finally, coalescing do/while replay). Since every caller (OnRefreshTimerTick, OnOverviewTimerTick no longer, LoadServersAsync) runs on the UI thread and the check-then-set happens before any await, there's no interlock needed — confirmed no call site invokes it off-dispatcher.
  • ReadAndApplyServerStatusAsync already catches and logs its own read failures (catch (Exception ex) ... return;), so a throwing read can't leave _statusRefreshInFlight stuck — the finally always runs and the do/while loop can't spin forever off a failing read.
  • replayIfBusy: true is wired to exactly the one caller that needs it (LoadServersAsync, i.e. connect + add/edit/remove), and the two fleet-timer call sites correctly omit it — matches the stated rationale (a periodic caller's next tick is already its own retry).
  • Removing RefreshServerStatusAsync() from OnOverviewTimerTick doesn't lose freshness-dot updates: OnRefreshTimerTick still fans it out unconditionally above its Overview early-return, and both timers are constructed from the same _refreshInterval field back-to-back in OnLoaded, so the claim that they land in the same cycle holds by construction.
  • RefreshStoreSizeAsync's plain-drop guard (no replay) is consistent with _alertPollInFlight's shape and the stated reasoning (scopeless read, dropped request is genuinely redundant).

Lite/Darling parity — no drift. This fan-out (fleet timers, MaxPoolSize = 10 connection pool, multi-seat viewer reading a central store) is Darling-viewer-specific; Lite's MainWindow.xaml.cs has a single _statusTimer against a local DuckDB file with no analogous connection-pool contention, so there's no counterpart change needed there.

Security / T-SQL style — N/A, no SQL or input-handling surface touched.

Performance — this is a net improvement (eliminates stacked/duplicate store reads under load), no regression.

No findings to flag.

… right reason

The pin claimed it reached a guard living one level down. A mutation aimed at
that claim rather than at the fix - stripping AvailabilityGroupsTab's _loading -
did not fail it, which is how the false negative surfaced.

CalledNames only matched the fan-out's own two shapes (_ = X() and await X()),
and RefreshAgAsync is => LoadAsync(): a plain call in an expression body,
matching neither. The walk ran out of edges there and called the AG path safe
without ever reaching the _loading that makes it safe.

It now follows any invocation that resolves to a method this project declares,
and reports RefreshAvailabilityGroupsAsync -> RefreshAgAsync -> LoadAsync when
that guard is removed. Deciding which methods a tick FIRES still uses the narrow
matcher - a tick's fan-out is its unawaited and awaited calls, not the loop
conditions around them.
@erikdarlingdata
erikdarlingdata force-pushed the fix/2874-freshness-read-guards branch from 49c37be to b2f2c52 Compare September 4, 2026 15:30
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed. This is Darling-viewer-only code (MainWindow.ServerManagement.cs, MainWindow.xaml.cs, ViewerCommandDeadlines.cs, plus the new source-walking pin test) — no Lite counterpart exists for this fan-out/guard pattern, so no parity drift here.

Walked the new guard logic carefully:

  • _statusRefreshInFlight / _statusRefreshRequested in RefreshServerStatusAsync — check-then-set happens entirely before the first await, release is in finally, and the coalescing do-while correctly buys at most one extra pass regardless of how many replay requests stack up while busy. Single-threaded/dispatcher-affine reasoning holds since nothing yields between the guard check and the finally.
  • _storeSizeRefreshInFlight in RefreshStoreSizeAsync — plain drop, released in finally, matches the _alertPollInFlight shape as documented.
  • The two RefreshServerStatusAsync call sites are correctly split: the periodic fleet-timer call passes no replayIfBusy (drops), the connect/add/edit/remove path passes replayIfBusy: true. Confirmed via grep there are only these two call sites.
  • OnOverviewTimerTick no longer double-fires RefreshServerStatusAsync; OnRefreshTimerTick's fan-out is unchanged in shape, just now guarded.

No correctness, security, or performance issues found. The new ViewerFleetTimerGuardTests source-walk (regex-based method body extraction, comment/string stripping, brace-balanced body extraction, same-file-first resolution) checked out against the actual guard shapes in the two changed methods — the field-tested-before-claimed / bail-return-before-first-await / release-in-finally logic all correctly identifies _statusRefreshInFlight and _storeSizeRefreshInFlight as guards. Nothing to flag inline.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed the diff (MainWindow.ServerManagement.cs, MainWindow.xaml.cs, ViewerCommandDeadlines.cs, ViewerFleetTimerGuardTests.cs, CHANGELOG.md). No blocking issues found.

Correctness — RefreshServerStatusAsync's guard/replay logic checks out: the _statusRefreshInFlight claim + bail-out return sit before the first await, the flag is released in a finally (so a throwing GetServerFreshnessAsync can't wedge it permanently), and the do { _statusRefreshRequested = false; await ...; } while (_statusRefreshRequested) loop correctly coalesces N concurrent replay requests into one extra pass rather than N. RefreshStoreSizeAsync mirrors _alertPollInFlight's plain-drop shape correctly. Confirmed there are exactly two call sites for RefreshServerStatusAsync (the periodic fan-out with no replay, and the connect/reload path with replayIfBusy: true), matching the design described in the PR body. ReadAndApplyServerStatusAsync and UpdateCollectorHealthTextAsync both catch their own store-read exceptions internally, so the fire-and-forget _ = RefreshServerStatusAsync(...) call sites won't produce unobserved task exceptions.

Lite/Darling parity — Lite's MainWindow uses a single fixed 30s DispatcherTimer with no configurable interval, no fleet/connection-pool concept, and no sibling in-flight guards, so this isn't parity drift — the underlying architectures differ enough that the bug class doesn't apply there.

Test file (ViewerFleetTimerGuardTests.cs) — the hand-rolled source-walking regex parser is unusually elaborate but internally consistent; traced GuardField/IsSingleFlight against the actual guard code in MainWindow.ServerManagement.cs and it correctly identifies _statusRefreshInFlight as the guard field despite the confounding _statusRefreshRequested = true; assignment appearing earlier in the same prelude. One minor, non-blocking observation: StripCommentsAndStrings's SkipRegularString treats the full span of an interpolated string ($"...{expr}...") as string content and blanks out any {expr} inside it, so a call embedded in a string interpolation wouldn't be picked up by CalledNames/s_storeRead. None of the guarded methods in this PR actually call through string interpolation, so it doesn't produce a false negative here, but it's a latent gap in the walker worth knowing about if a future guard/call site is written that way.

Security / SQL — no SQL or network/process/file boundary changes in this diff; nothing flagged.

Comments and CHANGELOG entry follow the house style; XML docs are well-formed.

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