Skip to content

Count ABANDONED cycles in collection health and band them (Fixes #2804) - #2867

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/2804-abandoned-count
Sep 4, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/2804-abandoned-count

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2804.

The defect

#2803 gave a budget-abandoned cycle its own ABANDONED status so it would stop masquerading as SUCCESS. The write side landed; the read side never caught up, so the fact reached every surface as a sentence inside note_summary and never as a number.

An ABANDONED run incremented total_runs and nothing else — not success, error, permission denial or yield. So it grew the failure-rate DENOMINATOR while contributing nothing to its numerator, and never advanced last_success_time.

TOTAL abandonment does still reach FAILING through staleness, because no success lands at all. The gap is the PARTIAL case: a collector abandoning some cycles while succeeding often enough to stay fresh never ages into STALE or FAILING, has an error rate of exactly 0, and reads HEALTHY indefinitely while losing collections.

Measured on a production server, 3h window:

procedure_stats  status=HEALTHY  errors=0  note_count=24
  "wall-clock budget (120s) reached; cycle abandoned (24 of 1226 runs)"
query_stats      status=HEALTHY  errors=0  note_count=14

The change

abandoned and abandon_rate_pct on both MCP tools, an Abandoned column on the web grid and both WPF grids, and the count feeds the shared CollectorHealthClassifier, which bands WARNING above a 0.5% rate.

Read-side only — no new collection_log column, no schema version bump.

Why 0.5%, from the fleet rather than from taste

Abandonment is not a continuous rate phenomenon. Over 24h across 1,639 (server, collector) pairs and 520,455 runs, only four pairs abandoned anything at all — 28 runs, 0.005% fleet-wide — and the per-pair rate distribution is:

p50 p75 p90 p95 p99 max
0.000% 0.000% 0.000% 0.000% 0.000% 2.157%

So the real population is an empty body and a four-point tail spanning 0.60%–2.16%. 0.5 sits strictly below that observed floor (catches every genuinely-degraded collector on the fleet) and strictly above the 99th percentile (fires on nobody who is not abandoning). It also keeps a lone abandonment quiet in any window under ~400 runs — the "one in a thousand is noise, twenty-four is a finding" line.

Deliberately far below WarningFailureRatePercent (20): an error may be transient and is retried, where the 120 s budget is itself generous (#2673 chose it after measuring a 176 s tail), so reaching it means exceeding a bound already set well above normal.

Why WARNING rather than a new band

WARNING is already the rate-based "degrading but still running" verdict, which is what partial abandonment is — and a guard firing is not an ERROR. A new band string would have to be learned by four independent display mappings, and the two that do not switch on it fail in opposite directions: the web's statusToSev defaults to "Unknown", while the deprecated Dashboard's brush converter defaults to Transparent — the same brush it gives HEALTHY.

Attribution is not lost by sharing the band, because the abandoned count now sits in the same row as the error count.

The drift this caught

The fleet rollup builds its own CollectorHealth from FleetCollectionHealthSql and would have kept calling a partially-abandoning collector HEALTHY while every other surface called it WARNING — and it compiled, because an unset count defaults to 0. That is the #2779/#2784 shape: one surface fixed, its sibling quietly left behind.

So the pin is an invariant over all four banding reads by name (DarlingDataReader.CollectionHealthSql, DarlingFleetReader.FleetCollectionHealthSql, ViewerDataService.CollectionHealthSql, ViewerDataService.FleetCollectionHealthSql) rather than over the one that was broken.

Verification

The Windows suites cannot run on macOS, so both halves were verified against the real artifacts:

Decision table, red-first. A throwaway net10.0 harness ran 15 cases against the real built CollectorHealthClassifier. All 15 pass. With the threshold disabled, exactly the four abandonment cases go red and report HEALTHY — reproducing the bug:

FAIL  prod procedure_stats 24/1226 = 1.96%   want=WARNING  got=HEALTHY
FAIL  prod query_stats     14/1226 = 1.14%   want=WARNING  got=HEALTHY
FAIL  boundary 7/1226 = 0.571% (over)        want=WARNING  got=HEALTHY
FAIL  on-load 20/1000 = 2%                   want=WARNING  got=HEALTHY

SQL, against the real store. CollectionHealthSql was dumped from the built assembly (not retyped), PREPAREd verbatim and executed read-only against a production store. It returns 21 columns, and abandoned_count matches an independent GROUP BY cross-check exactly:

SHIPPED|procedure_stats|runs=511|abandoned=11|ncols=21
SHIPPED|query_stats    |runs=511|abandoned=5 |ncols=21
CHECK  |procedure_stats|511|11
CHECK  |query_stats    |511|5

At 2.15% and 0.98% both now band WARNING.

Invariant pin, red-first. Removing the fleet reader's abandoned_count makes Assert.Contains("AS abandoned_count", ...) fail; restoring it passes.

Notes

  • Both MCP result sets are read positionally and Lite mirrors Darling's ordinals, so abandoned_count is appended, never inserted — ordinal 20 on both.
  • The mirrored decision tables in Darling.Tests and Lite.Tests differ only in header and namespace, so the two SKUs cannot drift.
  • CHANGELOG.md staged as 2 insertions / 0 deletions with a plain git add, so Renormalize the five CRLF blobs to match .gitattributes (Fixes #2857) #2858's renormalization is holding.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy

#2803 gave a budget-abandoned cycle its own ABANDONED status so it would stop
masquerading as SUCCESS. The write side landed; the read side never caught up.

An ABANDONED run incremented total_runs and nothing else -- not success, error,
permission denial or yield -- so it grew the failure-rate denominator while
contributing nothing to its numerator, and never advanced last_success_time.
Total abandonment still reaches FAILING through staleness. The gap was the
PARTIAL case: a collector abandoning some cycles while succeeding often enough
to stay fresh has an error rate of exactly 0 and reads HEALTHY indefinitely.

Measured in production: procedure_stats at 24 abandoned of 1,226 runs and
query_stats at 14 of 1,226, both status=HEALTHY errors=0.

The count is now first-class (abandoned / abandon_rate_pct on both MCP tools,
an Abandoned column on the web grid and both WPF grids) and feeds the shared
CollectorHealthClassifier, which bands WARNING above a 0.5% rate.

The threshold is positioned by the fleet: across 1,639 (server, collector)
pairs and 520,455 runs, only four pairs abandoned anything at all and the
per-pair rate distribution is p50 = p75 = p90 = p95 = p99 = 0.000% with a max
of 2.157%. 0.5 sits below that observed floor and above the 99th percentile.

WARNING rather than a new band: a new string would need learning by four
display mappings that fail in opposite directions -- statusToSev defaults to
"Unknown", the deprecated Dashboard's converter defaults to Transparent, the
same brush it gives HEALTHY. Attribution survives because the abandoned count
sits beside the error count in the same row.

Read-side only: no new column, no schema rung.

Caught while wiring it: the fleet rollup builds its own banding row from
FleetCollectionHealthSql and would have kept calling an abandoning collector
HEALTHY while every other surface called it WARNING -- and it compiled, because
an unset count defaults to 0. That is the #2779/#2784 shape, so the pin asserts
over all four banding reads by name rather than over the one that was broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
TargetHasUserDatabases = !reader.IsDBNull(13) && Convert.ToInt64(reader.GetValue(13)) != 0,
/* Appended (#2804). Both reads above compute it, so unlike has_user_databases it is never a
NULL placeholder on the fleet side — an abandoned cycle is data loss on either surface. */
AbandonedCount = reader.IsDBNull(14) ? 0 : Convert.ToInt64(reader.GetValue(14)),

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: the doc comment on MapHealthRow just above (line 340: "Maps one row of the shared 14-column health projection...") and the one on FleetCollectionHealthSql (line 184: "the same 14-column per-collector aggregate") are now stale — appending abandoned_count here makes both projections 15 columns (ordinals 0–14, as this very mapping shows). DarlingDataReader.CollectionHealthSql's equivalent comment was correctly bumped to "21 columns since #2804" in this same PR; these two just got missed. Worth fixing given how much this codebase relies on exact counts in comments to keep positional-read surfaces honest.

the surface only inside note_summary's prose, so there was no number to threshold,
alert or trend on. */
abandoned = r.AbandonedCount,
abandon_rate_pct = Math.Round(r.AbandonRatePercent, 2),

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 get_collection_health tool's Description(...) blurb (a few lines above) — the contract an MCP/LLM client actually reads to interpret this tool's fields — walks through status, last_note/note_count, target_has_user_databases, sweep_pressure, avg/p95/max_duration_ms, and fanout in real detail, but never mentions the new abandoned / abandon_rate_pct fields added here, or that a WARNING can now come from abandonment rather than errors.

That undercuts this PR's own stated goal ("a WARNING can always be attributed to abandonment rather than to errors") for exactly the consumer — an LLM client calling this tool — that only sees the description text, not these C# comments. Lite's McpHealthTools.cs has the identical description text with the identical gap, so it's at least parity-consistent, but both descriptions should probably gain a sentence explaining abandoned/abandon_rate_pct and that abandonment bands WARNING at a much lower rate (0.5%) than the error rate (20%).

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review summary

Traced the fix end-to-end — this is well-scoped and well-tested. Verified by hand rather than by building (no cross-platform build available here):

  • Column ordinals are correct everywhere abandoned_count was appended: DarlingDataReader.CollectionHealthSql (21 cols, ordinal 20), DarlingFleetReader.FleetCollectionHealthSql (9 cols, ordinal 8, feeding ReadFailingCollectorCountsAsync), ViewerDataService.CollectionHealthSql/FleetCollectionHealthSql (15 cols, ordinal 14, shared MapHealthRow), and Lite's LocalDataService.CollectionHealthSql (21 cols, ordinal 20) — all match their respective reader mappings.
  • All four CollectorHealthClassifier.Classify(...) call sites were updated in lockstep (DarlingDataReader.CollectorHealth.HealthStatus, ViewerDataService.CollectorHealthRow.HealthStatus, LocalDataService's Lite twin, plus the fleet rollup which now reuses CollectorHealth.HealthStatus with AbandonedCount wired through) — closing exactly the "Offline/stale server shows green "Collectors OK" — the summary tile ignores STALE collectors #2779/Parity: WPF Darling Viewer Overview card also shows green "OK" collectors on an offline server (failing-count-only), same root cause as #2779 #2784 shape" drift the PR describes, and the new EveryBandingRead_SelectsTheAbandonedCount... test pins all four SQL strings by name.
  • Classifier precedence logic checks out against the new decision-table rows: NO_PERMISSIONS/STOPPED/FAILING/STALE all still take priority over the new abandon-rate check, and the 0.5% boundary (6 vs 7 of 1226 runs) lands exactly where the comments say.
  • Lite/Darling parity is intact: MCP JSON field names/order (abandoned, abandon_rate_pct) match between McpHealthTools.cs and DarlingMcpDataTools.cs; XAML column (header text, tooltip, width) matches between Lite/Controls/ServerTab.xaml and ViewerServerTab.xaml; both test suites (Lite.Tests/Darling.Tests) carry the same decision-table rows.
  • No SQL injection surface (all params positional/typed), no T-SQL/collector-query changes (so OPTION(RECOMPILE) doesn't apply here — this is Darling/Lite store-side SQL, not monitored-server T-SQL), no schema/migration change needed since this is read-side only as claimed.

Left two nit-level inline comments (doc-comment column counts gone stale in ViewerDataService.CollectionHealth.cs, and the get_collection_health MCP tool description not mentioning the new abandoned/abandon_rate_pct fields in either app). Nothing blocking.

Two findings from the review bot, both real.

The viewer's MapHealthRow and FleetCollectionHealthSql doc comments still said
"14-column"; appending abandoned_count makes both projections 15 (ordinals
0-14). DarlingDataReader's equivalent was bumped in the first commit and these
two were missed. The count is load-bearing here -- both projections are read
positionally through one shared mapper -- so the mapper's comment now says so
explicitly rather than just carrying a number.

The get_collection_health Description is the contract an MCP client actually
reads to interpret the tool's fields, and it walked through status, last_note,
target_has_user_databases, sweep_pressure and fanout in detail while never
mentioning the new abandoned / abandon_rate_pct fields or that a WARNING can
now come from abandonment rather than errors. That undercut this PR's own
attribution goal for the consumer least able to work it out. Added to both
SKUs' tool descriptions so they stay in parity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
Comment thread CHANGELOG.md
## [Unreleased]

### Added
- **A collector losing cycles to the wall-clock budget no longer reports `HEALTHY` with `errors: 0`** ([#2804]) - [#2803] gave a budget-abandoned cycle its own `ABANDONED` status so it would stop masquerading as SUCCESS. The write side landed; the READ side never caught up, so the fact reached every surface as a sentence inside `note_summary` and never as a number. An ABANDONED run incremented `total_runs` and **nothing else** - not success, error, permission denial or yield - so it grew the failure-rate DENOMINATOR while contributing nothing to its numerator, and never advanced `last_success_time`. TOTAL abandonment does still reach FAILING through staleness, because no success lands at all; the gap was the PARTIAL case, where a collector abandons some cycles and succeeds often enough to stay fresh, so staleness never fires, the error rate is exactly 0, and the row reads HEALTHY indefinitely while losing collections. Measured on a production server: `procedure_stats` at **24 abandoned of 1,226 runs** and `query_stats` at 14 of 1,226, both `status=HEALTHY errors=0`. The count is now first-class (`abandoned` / `abandon_rate_pct` on both MCP tools, an **Abandoned** column on the web grid and both WPF grids) and feeds the shared `CollectorHealthClassifier`, which bands **WARNING** above a 0.5% rate. **The threshold came from the fleet, not from taste**: across 1,639 (server, collector) pairs and 520,455 runs in 24h, only FOUR pairs abandoned anything at all - 28 runs, 0.005% fleet-wide - and the per-pair rate distribution is p50 = p75 = p90 = p95 = **p99 = 0.000%** with a max of 2.157%, so the real population is an empty body and a four-point tail spanning 0.60%-2.16%. 0.5 sits strictly below that observed floor and strictly above the 99th percentile, and keeps a lone abandonment quiet in any window under ~400 runs. **WARNING rather than a new band** because a new string would have to be learned by four display mappings that fail in opposite directions - the web's `statusToSev` defaults to "Unknown" while the deprecated Dashboard's brush converter defaults to `Transparent`, the same brush it gives HEALTHY - and attribution survives anyway, since the abandoned count now sits beside the error count in the same row. The fix is **read-side only**: no new column, no schema rung. Caught while wiring it: the fleet rollup builds its own banding row from `FleetCollectionHealthSql` and would have kept calling an abandoning collector HEALTHY while every other surface called it WARNING - and it **compiled**, because an unset count defaults to 0. That is the [#2779]/[#2784] shape, so the pin asserts over all FOUR banding reads by name rather than over the one that was broken.

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 line uses [#2803] as a reference-style link, but [#2803]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2803 is never defined anywhere in this file (unlike [#2804], [#2779], [#2784], which are all defined further down). As written, [#2803] will render as literal bracketed text instead of a link.

/// <summary>Maps one row of the shared 14-column health projection (per-server or fleet) to a <see cref="CollectorHealthRow"/>.</summary>
/// <summary>Maps one row of the shared 15-column health projection (per-server or fleet, ordinals 0-14) to a
/// <see cref="CollectorHealthRow"/>. The count is load-bearing: both projections are read POSITIONALLY through
/// this one mapper, so it must match them exactly (15 since #2804 appended abandoned_count at ordinal 14).</summary>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor formatting slip: the 2nd/3rd lines of this doc comment lost their leading indentation (/// <see cref=... and /// this one mapper... start at column 0 instead of being indented under MapHealthRow). Doesn't affect behavior, just doc-comment consistency with the rest of the file.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed the diff end-to-end (SQL ordinals, classifier precedence, Lite/Darling parity, MCP tool descriptions, XAML/web wiring). This is a well-verified change — the read-side plumbing checks out:

  • Column ordinals verified correct in all four positional readers: DarlingDataReader.GetCollectionHealthAsync (ordinal 20/21 cols), DarlingFleetReader.ReadFailingCollectorCountsAsync (ordinal 8/9 cols), ViewerDataService.MapHealthRow (ordinal 14/15 cols, shared by both the per-server and fleet SQL), and Lite's LocalDataService.GetCollectionHealthAsync (ordinal 20/21 cols, mirroring Darling's DuckDB/Postgres parity claim). All four abandoned_count columns are appended last as the comments claim.
  • CollectorHealthClassifier.Classify signature change is applied consistently at all 3 real call sites (Darling viewer, Darling MCP reader, Lite) plus both test suites — no stale caller left on the old 4-arg overload.
  • Lite/Darling parity confirmed: the get_collection_health MCP tool description strings are byte-identical between DarlingMcpDataTools.cs and Lite/Mcp/McpHealthTools.cs; the WPF XAML column markup (tooltip text, width, binding) is byte-identical between Lite/Controls/ServerTab.xaml and Darling/.../ViewerServerTab.xaml; the SQL/mapping additions in LocalDataService.CollectionHealth.cs mirror DarlingDataReader.cs exactly.
  • Web grid parity is automatic, not duplicated wiring: server-tabs.js's /api/read/get_collection_health dispatches straight to the same DarlingMcpDataTools.GetCollectionHealth, so there's no separate DTO that could have been left out of sync with the MCP tool.
  • status = 'ABANDONED' write path double-checked: it's written exclusively by EnumeratedCollectorDriver.ClassifyReturnedRun for the Bound each collector's per-server wall-clock footprint (tail is up to 555s on one server) #2673 whole-server wall-clock budget path. The unrelated "ABANDONED" wording in the query_store backfill-slice logging (RemoteCollectorService.QueryStoreBackfill.cs / DarlingWorker.cs) is log text only and never touches collection_log.status, so the new count isn't conflating two different phenomena.
  • Classifier precedence/threshold math in the new test tables checks out arithmetically (0.5% of 1,226 = 6.13, so 6→Healthy/7→Warning boundary is correct; on-load and STALE/FAILING/STOPPED precedence over WARNING is preserved).

Left two minor inline nits: a dangling [#2803] changelog reference link with no definition, and a doc-comment indentation slip in ViewerDataService.CollectionHealth.cs. No correctness, security, or parity defects found.

Two more nits from the review bot, both real.

The new CHANGELOG entry cites [#2803] reference-style but that ref was never
defined in the file, so it would have rendered as literal bracketed text
instead of a link. Every other ref the entry uses (#2804, #2779, #2784) was
defined; this one was missed.

The MapHealthRow doc comment I rewrote in the previous commit lost the leading
indentation on its continuation lines, which started at column 0 instead of
lining up with the rest of the file.

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

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review

Went through the full diff against CONTRIBUTING.md's parity and style rules. This is a clean, read-side-only change with no schema bump, and it's unusually well-verified.

Parity (Lite/Darling): Checked all four banding reads plus the two MCP tools and both WPF/web grids.

  • DarlingDataReader.CollectionHealthSql (21 cols, abandoned_count at ordinal 20) and Lite/Services/LocalDataService.CollectionHealth.cs's equivalent SQL are ordinal-identical, and both readers pull ordinal 20 for AbandonedCount — confirmed by walking both column lists side by side.
  • ViewerDataService.CollectionHealthSql / FleetCollectionHealthSql share one 15-column projection (ordinals 0–14) through MapHealthRow, and abandoned_count lands at 14 in both — also confirmed by direct inspection rather than trusting the comments.
  • All four CollectorHealthClassifier.Classify(...) call sites (DarlingDataReader, DarlingFleetReader's CollectorHealth construction, ViewerDataService.CollectionHealth.cs, Lite/Services/LocalDataService.CollectionHealth.cs) pass AbandonedCount — no leftover site defaulting to 0.
  • MCP tool descriptions and JSON shape (abandoned / abandon_rate_pct) are byte-identical between DarlingMcpDataTools.cs and Lite/Mcp/McpHealthTools.cs.
  • The web grid (server-tabs.js) reads through the same GetCollectionHealth MCP call the web endpoint delegates to, so no separate wiring was needed there — checked DarlingWebEndpoints.cs to confirm it's not a second, unpatched code path.
  • The write side (EnumeratedCollectorDriver.AbandonedStatus) is shared between Lite and Darling already, so the 'ABANDONED' string literal in every new read is guaranteed to match what's actually written.

Correctness: Traced the Classify precedence chain (NEVER_RUN → NO_PERMISSIONS → on-load rate-only → STOPPED → FAILING → STALE → WARNING → HEALTHY) against the new decision-table rows, including the 0.5%-boundary pair (6/1226 = 0.489% Healthy vs 7/1226 = 0.571% Warning) and the on-load branch, which needed its own abandon-rate check since it never reaches the staleness fallback. All check out. The fleet rollup's CollectorCounts(Healthy, Failing) binary counter simply stops counting a newly-WARNING collector as Healthy — the bug the PR describes — without miscounting it as Failing either, which is correct given that counter's existing binary shape.

Nit (non-blocking): In both Darling.Tests/CollectorHealthClassifierTests.cs and Lite.Tests/CollectorHealthClassifierTests.cs, the new "Precedence" block's last row — [InlineData(0, 0, 0, 0, 0, 999, 999, OneMinute, false, CollectorHealthClassifier.NeverRun)] — is byte-identical to the very first InlineData in the theory (also NeverRun, same values once the new abandonedCount column is inserted). Harmless (xUnit just runs it twice), but looks like a copy-paste leftover rather than an intentional second pin.

No security, injection, or SQL-style issues found — all new SQL is parameterized, uses AS aliasing consistent with surrounding code, and T-SQL-specific conventions (OPTION(RECOMPILE), etc.) don't apply since these are Postgres/DuckDB reads, not SQL Server collectors.

@erikdarlingdata
erikdarlingdata merged commit a54abd5 into dev Sep 4, 2026
6 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/2804-abandoned-count branch September 4, 2026 01:07
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