Skip to content

Band a mid-sweep server Stale, not Offline: one shared collection-stopped window (Fixes #2794) - #2885

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/2794-offline-threshold-agreement
Sep 4, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/2794-offline-threshold-agreement

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2794.

One condition, two definitions

The display's freshness classifier banded a server Offline - no recent collection past a bare 15-minute OfflineThreshold, while the alert engine had deliberately decided collection is not "stopped" until 30 minutes (DarlingSelfAlertEvaluator.StaleWindow, configurable as collectionStaleMinutes). The tighter definition false-alarmed by design: the sweep skips relaunch while a server's body is still running, so a single long query_store cycle holds the whole body and nothing writes to collection_log for the duration - a healthy server legitimately goes quiet for 12-19 minutes with zero failures anywhere, and five production shards wore the red overlay at one snapshot while actively mid-cycle.

Measured before changing anything (the issue's own instruction)

The issue said: deploy #2792 first, re-measure, and only change the classifier if gaps still threaten the threshold. Measured on the production store over the last 24h (post-#2792 and the subsequent fetch/timeout fixes), via LAG(collection_time) OVER (PARTITION BY server_id ORDER BY collection_time):

window gaps >15m servers >15m worst legitimate gap
last 24h (current build) 4 0 real servers 12m12s
issue-day 12h (2026-09-02 12:00-24:00) 235 all 42 19m18s legit (2h47m incl. the box-resize hole)

The only >15m series today is server_id = 0 - the service's own bookkeeping rows, absent from collect.servers, whose gaps end exactly at the deploy restart stamps. So the 15-minute band currently produces zero false Offlines but sits 2.8 minutes above the routine worst case, issue-day load blew through it fleet-wide, and genuinely dark servers run hours (the incidents Offline exists for). 30 minutes separates the two populations with real margin on both sides - and it is the number the alert engine already committed to.

The change

ServerHealthThresholds.CollectionStoppedMinutesDefault = 30 is now the ONE default for "collection has stopped", and every spelling of that claim derives from it:

A server between stretched sweeps now bands Stale (amber, "collection has lagged") - the honest reading the enum already had.

Pins, proven red

CollectionStoppedThresholdAgreementTests (both apps) holds the definitions together by value across the seams a shared constant cannot reach (int config default vs TimeSpan window vs TimeSpan threshold). Proven red three distinct ways against the reverted tree via a net10.0 harness reading the built assembly (printed threshold confirming each rebuild took: 30 -> 15 -> 30):

  • display-vs-alert agreement fails 15m != 30m
  • the config-default and shared-constant assertions fail
  • the behavior case fails: the issue's measured 19m18s legitimate stretch reads Offline on the old tree, Stale on this one

Boundary tests updated to the new band edge (ServerHealthClassifierTests band table now names 19m18s as the must-never-band-Offline case; the sidebar-dot Offline exemplars move from exactly-30m - which the strict > now bands Stale - to 31m). Lite's ServerCollectionFreshnessTests are already fully symbolic over OfflineThreshold and pass unchanged, including the rendered "over N minutes" copy.

Read-side only: no schema rung, no StorageVersion.SchemaVersion change, no change to what collectors write, no per-surface edits - the four banding surfaces stay consistent by construction since they share the one classifier.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy

…pped window (Fixes #2794)

The display's freshness classifier called a server dark at a bare
15-minute constant while the alert engine had deliberately decided
collection is not stopped until 30 - one condition, two definitions,
and the tighter one false-alarmed by design: the sweep skips relaunch
while a body runs, so one long query_store cycle legitimately leaves
12-19 minutes of silence on a healthy server.

Measured first, per the issue's own instruction: post-#2792 the worst
legitimate fleet gap in 24h is 12m12s and zero real servers cross 15
minutes (the sole >15m series is server_id 0, the service's own
bookkeeping rows, breaking exactly at deploy restarts), while
issue-day load produced 235 crossings in 12 hours peaking at 19m18s -
and genuinely dark servers run hours. 30 minutes separates the
populations with margin on both sides and is the number the alert
engine already committed to.

OfflineThreshold now derives from a shared
CollectionStoppedMinutesDefault alongside every other spelling of the
claim: DarlingSelfAlertEvaluator.StaleWindow, the
AlertsConfig.CollectionStaleMinutes default, Lite's settings twin,
AgentStatusRow.StaleWindow (a numerically-equal independently-editable
copy - the #1562 drift shape), and the PG long-running-query recency
bound whose own comment derives it from this convention.
CollectionStoppedThresholdAgreementTests pins the agreement by value
on both apps; proven red against the pre-change tree (15m != 30m, and
the measured 19m18s stretch banding Offline).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
Comment thread CHANGELOG.md Outdated
### Fixed
- **A healthy server mid-sweep is no longer painted with the red Offline overlay** ([#2794]) - the display's freshness classifier banded a server `Offline - no recent collection` past a bare 15-minute constant, while the alert engine had deliberately decided collection is not "stopped" until 30 (`DarlingSelfAlertEvaluator.StaleWindow`, configurable as `collectionStaleMinutes`) - one condition, two definitions, and only the alert side configurable. The tighter one false-alarmed by design: the sweep skips relaunch while a server's collection body is still running, so one long `query_store` cycle holds the whole body and NOTHING writes to `collection_log` for the duration - a healthy server legitimately goes 12-19 minutes quiet with zero failures anywhere, and five production shards wore the red overlay at a snapshot while actively mid-cycle, which sent an investigation chasing phantom dark servers. **Measured before changing anything, per the issue's own instruction**: with [#2792] deployed, the worst legitimate inter-collection gap across the whole fleet in 24h is 12m12s and ZERO real servers cross 15 minutes (the sole >15m "server" is `server_id = 0`, the service's own bookkeeping rows, whose gaps end exactly at the deploy restarts) - but issue-day load produced 235 crossings on every server in 12 hours with legitimate stretch reaching 19m18s, so the margin between routine operation and the threshold was load-dependent and paper-thin, while genuinely dark servers run HOURS. `OfflineThreshold` now derives from a shared `CollectionStoppedMinutesDefault = 30` alongside every other spelling of the same claim - the evaluator's `StaleWindow`, `AlertsConfig.CollectionStaleMinutes`'s default, Lite's `AppAlertEngineSettings`, Lite's `AgentStatusRow.StaleWindow` (whose comment already said it "mirrors" the service's window, at a numerically-equal but independently-editable copy, the exact [#1562] drift shape), and `DarlingWorker`'s Postgres long-running-query recency bound, whose own doc comment derives it from the fleet staleness convention and which would otherwise have re-created the alert-blindness it warns about. A server between stretched sweeps now bands `Stale` - amber, "collection has lagged" - which is the honest reading; every surface follows because all four banding reads resolve through the one `ClassifyFreshness` ([#2473]), and `CollectionStoppedThresholdAgreementTests` pins the definitions together BY VALUE on both apps, proven red against the pre-change tree (15m != 30m, and the measured 19m18s stretch banding Offline).

### Fixed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this creates two adjacent ### Fixed headers under [Unreleased] (one new at line 113 with just the #2794 entry, then this pre-existing one). Keep-a-Changelog style elsewhere in this file uses one ### Fixed section per version — worth merging the new bullet into the section below rather than adding a second header.

/// running". Bounding "most recent" to a real window (rather than an unqualified <c>MAX(collection_time)</c>
/// that could resolve to a capture from hours ago) is what keeps an honest empty from silently going stale.
/// 15 minutes, matching the fleet's own <c>OfflineThreshold</c> staleness convention
/// The fleet's own <c>OfflineThreshold</c> staleness convention

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanical edit here (15 minutes, matching the fleet's own → The fleet's own) leaves this <para> without a subject/verb — it now reads "The fleet's own OfflineThreshold staleness convention ... rather than a tight multiple of the collector's 1-minute configured cadence — this fleet's delivered sweep cadence has been measured...", which doesn't parse as a sentence, and it also drops the only place that stated what recencyMinutes actually resolves to (30 minutes, via the shared constant). Compare with how the same rename was handled in DarlingFleetReader.cs (just dropped the "15-minute" adjective, sentence still parses) and in DarlingWorker.cs's PgLongRunningQueryRecencyMinutes doc comment (rewritten as full sentences) — this spot needs the same treatment, e.g. "Derives from the fleet's own OfflineThreshold staleness convention (...) rather than...".

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed. This is a clean, well-scoped change — one shared constant (ServerHealthThresholds.CollectionStoppedMinutesDefault) now backs every place that used to spell "collection has stopped" independently, in both Lite and Darling, and the boundary tests (ServerHealthClassifierTests, ViewerSidebarDotRendersTheCardStatusTests, the new CollectionStoppedThresholdAgreementTests in both test projects) correctly track the new 30-minute edge. No T-SQL, no schema rung, no security/input-handling surface touched. Traced all ClassifyFreshness call sites (Darling web/viewer/MCP, Lite) and the CollectionStaleMinutes/StaleWindow copies (DarlingConfig, DarlingSelfAlertEvaluator, AppAlertEngineSettings, AgentStatusRow) — didn't find any spot the PR's own audit missed, and Lite/Darling stay in parity throughout.

Three minor doc/changelog nits, left as inline comments where the diff allowed it:

  • CHANGELOG.md: the new ### Fixed entry creates two adjacent ### Fixed headers under [Unreleased] — should merge into one section.
  • Darling/PerformanceMonitor.Darling.Storage/DarlingPgSessionStatesReader.cs (~line 361): the "15 minutes, matching..." → "The fleet's own..." edit leaves the paragraph without a subject/verb and drops the only place that named the actual resolved value (30 min).
  • Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs:4896 (outside this PR's diff hunk, so no inline comment possible): a comment there still says "the fleet's 15-minute Offline threshold," which is now stale given this PR moves the threshold to 30 minutes. It reads as a historical note about the Darling: a heavy query_store run stalls the whole per-server collection body (BODY_OVERRUN cascades to real staleness) #2700 incident, but nothing marks it as historical, so it's likely to be misread as the current value — the same drift this PR was careful to clean up in nearby comments (DarlingFleetReader.cs, the PgLongRunningQueryRecencyMinutes doc comment a few lines above in the same file).

None of these affect runtime behavior.

…e boundary

ViewerServerSummaryDisplayTests carries its own copy of the band table
(it pins ServerSummaryItem's delegation, not the classifier), and two
ApplyFreshness facts fed 20- and 30-minute ages that now band Stale -
exactly 30 sits ON the boundary and the strict > keeps it amber. Swept
by shape this time: every ApplyFreshness/ClassifyFreshness call site
in test code, not the three files the first pass found.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
Comment thread CHANGELOG.md Outdated
### Fixed
- **A healthy server mid-sweep is no longer painted with the red Offline overlay** ([#2794]) - the display's freshness classifier banded a server `Offline - no recent collection` past a bare 15-minute constant, while the alert engine had deliberately decided collection is not "stopped" until 30 (`DarlingSelfAlertEvaluator.StaleWindow`, configurable as `collectionStaleMinutes`) - one condition, two definitions, and only the alert side configurable. The tighter one false-alarmed by design: the sweep skips relaunch while a server's collection body is still running, so one long `query_store` cycle holds the whole body and NOTHING writes to `collection_log` for the duration - a healthy server legitimately goes 12-19 minutes quiet with zero failures anywhere, and five production shards wore the red overlay at a snapshot while actively mid-cycle, which sent an investigation chasing phantom dark servers. **Measured before changing anything, per the issue's own instruction**: with [#2792] deployed, the worst legitimate inter-collection gap across the whole fleet in 24h is 12m12s and ZERO real servers cross 15 minutes (the sole >15m "server" is `server_id = 0`, the service's own bookkeeping rows, whose gaps end exactly at the deploy restarts) - but issue-day load produced 235 crossings on every server in 12 hours with legitimate stretch reaching 19m18s, so the margin between routine operation and the threshold was load-dependent and paper-thin, while genuinely dark servers run HOURS. `OfflineThreshold` now derives from a shared `CollectionStoppedMinutesDefault = 30` alongside every other spelling of the same claim - the evaluator's `StaleWindow`, `AlertsConfig.CollectionStaleMinutes`'s default, Lite's `AppAlertEngineSettings`, Lite's `AgentStatusRow.StaleWindow` (whose comment already said it "mirrors" the service's window, at a numerically-equal but independently-editable copy, the exact [#1562] drift shape), and `DarlingWorker`'s Postgres long-running-query recency bound, whose own doc comment derives it from the fleet staleness convention and which would otherwise have re-created the alert-blindness it warns about. A server between stretched sweeps now bands `Stale` - amber, "collection has lagged" - which is the honest reading; every surface follows because all four banding reads resolve through the one `ClassifyFreshness` ([#2473]), and `CollectionStoppedThresholdAgreementTests` pins the definitions together BY VALUE on both apps, proven red against the pre-change tree (15m != 30m, and the measured 19m18s stretch banding Offline).

### Fixed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this adds a second ### Fixed heading directly after the one just added a few lines up (line 113), so the [Unreleased] section now has two consecutive ### Fixed sections instead of one. Every other entry in this section (and every prior release) uses a single ### Fixed heading per category. The new #2794 bullet should be merged into the existing ### Fixed block above rather than starting a new one.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed. This is a well-scoped, thoroughly-tested threshold change (15m -> 30m for "collection has stopped"), and the parity work is solid:

  • ServerHealthThresholds.CollectionStoppedMinutesDefault is the single source of truth, and every prior independent copy (DarlingSelfAlertEvaluator.StaleWindow, AlertsConfig.CollectionStaleMinutes default, Lite's AppAlertEngineSettings.CollectionStaleMinutes, Lite's AgentStatusRow.StaleWindow) now derives from it instead of restating 30.
  • DarlingWorker.PgLongRunningQueryRecencyMinutes moving from a hardcoded 15 to the shared 30 is intentional and correctly reasoned (a tighter recency bound than the staleness convention would silently blind the alert on exactly the servers with stretched sweeps) — checked the query it feeds (DarlingPgSessionStatesReader.CurrentLongRunningSessionsSql) and it's an exception table (near-empty in the healthy case), so doubling the window isn't a real perf regression.
  • ClassifyFreshness's strict > boundary logic checks out against the new 900/1158/1800/1801/3600-second test table in both Darling test files (original + the W2a/viewer-chrome ones touched in the second commit).
  • Didn't find any missed call site — DarlingPgCpuUtilizationReader.Freshness (15m) is a genuinely separate constant (derived from that collector's own 5-minute cadence, not the fleet staleness convention), so it correctly wasn't touched.

Left one inline nit: the CHANGELOG edit creates two consecutive ### Fixed headings in [Unreleased] instead of merging into the existing one.

No SQL in this PR (pure C#), so the T-SQL style conventions don't apply here.

erikdarlingdata and others added 2 commits September 4, 2026 04:20
The inserted CHANGELOG bullet carried its own '### Fixed' heading
directly above the existing one; it now joins the existing section.
The PgSessionStatesReader recency comment lost its subject and the
statement of what the parameter resolves to in the mechanical rename -
rewritten as a sentence naming the shared constant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed the diff. This is a small, well-scoped change: ServerHealthThresholds.CollectionStoppedMinutesDefault = 30 becomes the single source of truth for "collection has stopped," and every place that previously spelled the number independently (OfflineThreshold, DarlingSelfAlertEvaluator.StaleWindow, AlertsConfig.CollectionStaleMinutes's default, Lite's AppAlertEngineSettings.CollectionStaleMinutes and AgentStatusRow.StaleWindow, DarlingWorker.PgLongRunningQueryRecencyMinutes) now derives from it.

Checked:

  • Correctness: ClassifyFreshness uses strict > against OfflineThreshold, and every updated test boundary (1800 → Stale, 1801 → Offline, viewer -30/-20 → -31) is consistent with that. The new 19m18s (1158s) case correctly bands Stale, matching the issue's own measured worst-case legitimate gap.
  • Lite/Darling parity: both apps' copies of the threshold were updated (AppAlertEngineSettings, AgentStatusRow.StaleWindow on Lite; DarlingConfig.AlertsConfig, DarlingSelfAlertEvaluator.StaleWindow on Darling), and a CollectionStoppedThresholdAgreementTests pin was added to both test projects, holding the definitions together by value across the int/TimeSpan seams a shared constant alone can't reach. No parity drift found.
  • Security/perf: none of the changes touch input handling, SQL text, or hot paths — purely constant/timespan wiring and doc comments. No missing-index-DMV suggestions applicable here.
  • Swept the repo for other hardcoded 15/30-minute offline/freshness references that might have been missed — the remaining hits are either historical incident narration (accurate as history, e.g. DarlingWorker.cs's "false-trip the fleet's 15-minute Offline threshold" comment describing a past incident when the threshold was 15) or unrelated windows (query-store lookbacks, CPU utilization freshness, alert cooldowns). Nothing else needed updating.

One minor, non-blocking observation (not on a changed line, so noting here rather than inline): Lite.Tests/AgentStatusHeaderHonestyTests.cs:93 (StaleWindow_MatchesTheHeadlessServicesRefusalWindow) still asserts Assert.Equal(TimeSpan.FromMinutes(30), AgentStatusRow.StaleWindow) against a bare literal rather than ServerHealthThresholds.CollectionStoppedMinutesDefault. It passes today only because the new default still happens to be 30, but it's the exact "numerically-equal, independently-editable copy" shape (#1562) this PR is fixing elsewhere — worth updating to the symbolic reference (or dropping as redundant now that CollectionStoppedThresholdAgreementTests pins the same fact) so it can't silently go stale on a future threshold change.

No blocking issues found.

@erikdarlingdata
erikdarlingdata merged commit dfff408 into dev Sep 4, 2026
6 checks passed
pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Sep 10, 2026
…ared

CHANGELOG.md cited 1083 distinct [#NNNN] shortcut references against
1048 link-reference definitions, so 37 of them had nothing to resolve
to and GitHub rendered them as literal "[erikdarlingdata#1696]" bracket text instead
of links - in prose where the reference IS the citation, e.g. "[erikdarlingdata#2760]
removed both hints on the theory that..." reads as a dead footnote.

Appended the 37 missing definitions in the existing form. Nothing in
the existing block was reordered or deduped: the duplicate [erikdarlingdata#828] and
[erikdarlingdata#887] definitions are left exactly as they were, and the file's own
bytes are unchanged ahead of the append - the diff is 37 insertions
and no deletions.

All 37 numbers were confirmed to exist in the repo (33 issues, 4 pull
requests). They are written uniformly as /issues/NNNN, which is what
recent practice already does for both kinds - the /pull/ form stops at
erikdarlingdata#2770, six of the 60 most recent /issues/ definitions are PRs, and
GitHub redirects /issues/N to /pull/N anyway.

Pre-existing, and found incidentally while landing erikdarlingdata#2885 rather than
caused by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata deleted the fix/2794-offline-threshold-agreement branch September 12, 2026 20:30
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