Skip to content

Give a wall-clock-budget-abandoned cycle its own collection_log status - #2803

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/abandoned-cycle-status
Sep 2, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/abandoned-cycle-status

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 2, 2026 •

Copy link
Copy Markdown
Owner

Fixes #2801

A collector cycle abandoned by the #2673 whole-server wall-clock budget was recorded as SUCCESS. Every one of the 36 abandonments in the use1 store's entire 17-day retention is SUCCESS with rows_collected = 0.

Why it inherited a success status

The abandonment returns rather than throwing, so it lands on the ordinary success path — whose status is a hardcoded "SUCCESS" literal, once in DarlingWorker and once in Lite's RunCollectorAsync. It carries its message through the Note / telemetry.Note channel, documented since #1837 as being for "a successful-but-empty run worth explaining" and explicitly not status-changing. #2673 reused that channel for a case that is not a successful empty run.

The wrong label was the smaller half. DarlingSelfAlertEvaluator.ReadCollectionSignalsAsync takes both last_success and recent_success from status IN ('SUCCESS', 'SKIPPED') — so a collector abandoning every cycle read as perpetually fresh. And the message landed in last_note / note_count, gated on status = 'SUCCESS'.

The status, and the alternatives rejected

ABANDONED, produced by one shared EnumeratedCollectorDriver.ClassifyReturnedRun so the hosts cannot drift — one hardcoded literal each is exactly how both inherited this.

  • SUCCESS — the bug. Nothing stored, no watermark advanced.
  • ERROR — would page on a guard doing precisely its job; feeds error counts, health bands and the collection-failure self-alerts.
  • YIELDED — documented as the 1s LOCK_TIMEOUT guard and read as evidence of lock contention on the target. Reusing it sends an operator hunting contention that is not there.
  • SKIPPED — a healthy no-op that counts as success. This is the opposite: work attempted and paid for that shipped nothing.

Safe to add. Every read buckets by explicit list — IN ('ERROR','PERMISSIONS'), = 'YIELDED', IN ('SUCCESS','SKIPPED') — never by complement, so a new value joins no bucket rather than silently joining the wrong one. collection_log.status carries no CHECK constraint, so no migration rung. And the self-alert's consecutive-failure path is server-scoped across every collector, so one collector abandoning among ~40 healthy ones cannot empty its success window.

What is deliberately NOT changed

Verification

Lite.Tests and Darling.Tests are net10.0-windows and cannot run on macOS, so the pins were also run through a throwaway net10.0 harness against the real built PerformanceMonitor.Collectors assembly. CI is the arbiter for the xUnit run.

Proven red first. Reverting ClassifyReturnedRun to the pre-fix hardcoded literal:

FAIL an abandoned cycle does not classify as SUCCESS
FAIL an abandoned cycle is outside the freshness-success family
PASS an abandoned cycle is not ERROR or PERMISSIONS
PASS an abandoned cycle does not reuse YIELDED
PASS an ordinary returned run still classifies SUCCESS
PASS an ordinary returned run is inside the freshness-success family
PASS the shared note names the budget
2 PIN FAILURE(S)

and with the fix restored, ALL PINS PASS.

The first attempt at that proof was contaminated — the saved copy of the file was written into the harness project directory and compiled into it, shadowing the real assembly (CS0436), so the pins passed for the wrong reason. Re-run with the backup outside the project, which produced the red above.

The pins assert over the freshness-success family rather than against the "SUCCESS" literal, because the defect was a status silently joining a set — a pin naming one member would miss the next status added to it.

The underlying slowness is not new

Worth stating, because the fix makes these newly visible. procedure_stats on the affected server, full retention window:

day runs p50 p95 max over 60s
08-17 32 8.3 s 178.3 s 246.4 s 6
08-19 129 7.4 s 90.6 s 278.1 s 8
08-27 106 7.6 s 18.0 s 176.6 s 2
09-02 126 8.4 s 120.3 s 156.9 s 11

Slow runs happened every day, and the worst maxima predate #2673 (2026-08-28). The budget did not create the slowness, it capped it — which is why max falls from ~250–278 s to ~121–157 s afterwards. Abandonment counts rise 4→15/day, but the first abandonment is 08-29, one day after the instrument was installed, so that rise is not evidence of a regression. Fleet control (the affected server excluded) is 0–13 over-60 s runs/day across ~5,000 daily runs with no trend, and 1 on 09-02.

No separate performance issue filed: claiming a trend off an instrument installed mid-window is the false-trend mistake.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy

#2801)

A cycle the #2673 whole-server budget gives up on stores nothing and advances
no watermark, but reaches the logging site by RETURNING rather than throwing.
It therefore took the ordinary success path, whose status is a hardcoded
"SUCCESS" literal in both hosts, and travelled through the #1837 Note channel
that is explicitly documented as not changing status. Every one of the 36
abandonments in the use1 store's 17-day retention is SUCCESS with
rows_collected = 0.

The label was the smaller half. ReadCollectionSignalsAsync takes last_success
and recent_success from status IN ('SUCCESS', 'SKIPPED'), so a collector
abandoning every cycle read as perpetually fresh, and the message landed in the
note channel whose whole claim is that the run succeeded.

Now ABANDONED, produced by one shared EnumeratedCollectorDriver.ClassifyReturnedRun
so the two hosts cannot drift on it -- one hardcoded literal each is exactly how
both inherited the bug. Not ERROR (pages on a guard doing its job), not YIELDED
(documented as the 1s LOCK_TIMEOUT guard, read as target lock contention), not
SKIPPED (a healthy no-op counted as success). Safe because every read buckets by
explicit list rather than complement, and collection_log.status has no CHECK
constraint, so no migration rung.

The per-item #2150 abandonment is deliberately untouched: it ships the rows for
the databases it got through, so that cycle really did collect and stays SUCCESS
with its note.

Pins assert over the freshness-success FAMILY rather than the "SUCCESS" literal,
since the defect was a status silently joining a set. Proven red first: reverting
ClassifyReturnedRun to the pre-fix hardcoded literal fails 2 of 7 harness pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
Comment thread CHANGELOG.md Outdated
- **Lite's portable ZIP is self-contained, which HALVED it** ([#2501]) - `Publish Lite` is now `-r win-x64 --self-contained` in both `build.yml` and `nightly.yml`, so neither Lite artifact has a .NET prerequisite any more and the failure [#2489] documented stops existing: a tester who unzips onto a stock Windows Server no longer meets the .NET host's bare `You must install .NET to run this application` before a line of our code runs. **The size went the opposite way from what bundling a runtime suggests.** The old publish was RID-agnostic, so it copied every platform its packages ship - **537 MB of `runtimes\` on a 565 MB tree** (osx 130, linux-x64 116, linux-arm64 70, win-arm64 56, then win-x86, musl, loongarch64 and riscv64), of which only the **52 MB `win-x64`** folder could ever load on Windows. `DuckDB.NET.Bindings.Full` is most of it, SkiaSharp and SqlClient behind it. Dropping ~485 MB of unloadable native payload beats the cost of bundling .NET, WPF and ASP.NET Core by roughly two to one: measured on one commit and one SDK, **565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB**. It matters most for the **nightly** ZIP, which is the UAT download and is not offered as a `Setup.exe` at all. **A RID-specific publish needed two more files than the flag.** `Lite/packages.lock.json` had only a `net10.0-windows7.0` target, and a RID restore adds `net10.0-windows7.0/win-x64` to it - after which the `dotnet restore --locked-mode` that BOTH workflows run before the publish fails `NU1004: the project's runtime identifiers have changed`, because locked mode compares the PROJECT's RID set (empty) against the lock file's (win-x64). Reproduced locally; that is a red CI run on every PR, not the future `--no-restore` trap it was filed as. The fix is `<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>` in `PerformanceMonitorLite.csproj`, so the project itself asks for that graph and one committed lock file satisfies the RID-less locked-mode restore and the RID publish alike; `RuntimeIdentifiers` (plural) sets no RID on the build, so a plain `dotnet build` stays RID-agnostic and `Lite.Tests` is untouched. **SignPath needed nothing** - the `Lite` artifact-configuration slug already receives both shapes today, and the signed re-zip reads `signed/Lite/*`, inheriting whatever shape `publish/Lite` has. Auto-update is unaffected; the ZIP is not a Velopack channel. `LiteRuntimePrerequisiteDocsTests` went red on the flag alone (3 of its 7 facts) and was rewritten to state every claim BOTH ways round: [#2499]'s version asserted only that the docs DID name the runtimes, so two of its facts stayed green while the prose went stale. It now also derives the lock file's RID coverage from the `-r` flags in the workflows, and every new assertion was proven red with its fix reverted.

### Fixed
- **A wall-clock-budget-abandoned cycle no longer records as `SUCCESS`** ([#2801]) - a collector cycle the #2673 whole-server budget gives up on stores nothing and advances no watermark, but reached the log site by RETURNING rather than throwing, so it took the ordinary path and inherited that path's hardcoded `"SUCCESS"` in both hosts. Every one of the 36 abandonments in the use1 store's 17-day retention was `SUCCESS` with `rows_collected = 0`. Beyond the wrong label it claimed a collection that never happened: `ReadCollectionSignalsAsync` takes `last_success` and `recent_success` from `status IN ('SUCCESS', 'SKIPPED')`, so a collector abandoning every cycle read as perpetually fresh, and its message landed in the #1837 note channel whose whole claim is that the run succeeded. Now its own `ABANDONED` status, shared by both hosts through `EnumeratedCollectorDriver.ClassifyReturnedRun`. Not `ERROR` (that would page on a guard doing its job), not `YIELDED` (documented as the 1s `LOCK_TIMEOUT` guard and read as target lock contention), not `SKIPPED` (a healthy no-op that counts as success). Safe to add because every read buckets by explicit list rather than by complement, and `collection_log.status` carries no CHECK constraint, so no migration rung. The underlying slowness is NOT new - ayr-01 had 2-11 `procedure_stats` runs over 60s every day of the window, with its worst maxima (246-278s) predating #2673.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This entry references [#2801] as a Markdown reference-style link, but there's no [#2801]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2801 definition added at the bottom of the file (where every other issue reference lives, e.g. [#2795], [#2791]). Without it, [#2801] will render as literal bracketed text instead of a link, unlike the rest of this section.

/// had their own copy of this literal, so the wording an operator greps for could drift between them.
/// </summary>
public const string WholeCycleBudgetNoteFormat =
"wall-clock budget ({0}s) reached; cycle abandoned";

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: every other format-string constant in this file (PartialDatabaseFailureNoteFormat, ProbeFailureNoteFormat, WallClockBudgetErrorFormat, UnreadableFailureSetErrorFormat) is paired with a cached CompositeFormat field to satisfy CA1863 ("parsed once"), but WholeCycleBudgetNoteFormat isn't, and both of its call sites (DarlingCollectorRunner.cs, RemoteCollectorService.DefinitionRunner.cs) go through string.Format directly. That's inconsistent with the established pattern here and will likely produce a CA1863 warning at both call sites, in tension with the CONTRIBUTING.md checklist item "C# code compiles without warnings."

channel, whose whole claim is that the run succeeded. Same reasoning as the RDS/PI
authorization arms below: nothing was read, so it must not be recorded as a successful empty
read. */
var status = EnumeratedCollectorDriver.ClassifyReturnedRun(result.Abandoned);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Coverage gap: this line (and DarlingCollectorRunner.cs's Abandoned: true return on budget expiry) has no direct test. Lite.Tests already has an end-to-end wiring test for the #2673 abandonment path (RunAsync_AnItemThatExceedsItsBudget_IsAbandoned_AndTheRestStillCollect), and this PR adds pure-function pins for ClassifyReturnedRun in Lite.Tests, but nothing in Darling.Tests exercises Darling's own wiring — that DarlingCollectorRunner actually sets Abandoned: true on budget expiry, or that this line turns it into status ABANDONED in the logged row. Given the whole point of this PR is that both hosts previously held their own hardcoded "SUCCESS" and silently drifted, it'd be worth a Darling-side regression test so this specific line can't silently regress the same way.

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review summary

Focused, well-scoped fix: a single EnumeratedCollectorDriver.ClassifyReturnedRun shared by both hosts turns a #2673 whole-cycle-budget abandonment into its own ABANDONED status instead of the hardcoded SUCCESS each host previously wrote independently. Verified:

  • Parity held: exactly one call site per host sets Abandoned = true (DarlingCollectorRunner.cs, RemoteCollectorService.DefinitionRunner.cs), and exactly one call site per host classifies it (DarlingWorker.cs, RemoteCollectorService.cs) — both going through the same shared EnumeratedCollectorDriver constant/method, so the two hosts can't drift on the status or the note wording again.
  • State handling is correct: CollectorRunResult.Abandoned defaults false so the other 7 CollectorRunResult construction sites in DarlingCollectorRunner.cs are unaffected; Lite's mutable RunTelemetry.Abandoned is explicitly reset to false at the top of RunCollectorAsync, and the if (telemetry.Abandoned) override sits inside the success try block, so it can never clobber a status set by a catch (ERROR/PERMISSIONS/etc.).
  • Downstream reads check out: ReadCollectionSignalsAsync and all four collection-health SUM(CASE WHEN status = ...) reads bucket by explicit allow-list, never by complement, so ABANDONED rows fall into no existing bucket rather than a wrong one — matching the PR's own reasoning. An abandoning collector now correctly ages out of last_success_time/staleness bands instead of reading perpetually fresh.
  • Left 3 inline comments: a missing changelog link-reference definition for [#2801], a CA1863-cached-format inconsistency on the new WholeCycleBudgetNoteFormat constant, and a Darling-side test-coverage gap (the wiring in DarlingCollectorRunner.cs/DarlingWorker.cs has no direct test, unlike Lite's existing end-to-end abandonment test). None are blocking — the core logic and Lite/Darling parity are sound.

Two CI findings, both correct.

DarlingEmptyEnumerationNoteTests pinned the literal "SUCCESS" argument on the
LogCollectionAsync call this change deliberately replaced. Repaired to assert
the property rather than the literal: the note still rides the returned-run
write (the original claim, unchanged) and that write's status now comes from
the shared classifier, so it cannot quietly revert to a hardcoded literal.

FleetIdentifierScrubTests caught a real fleet tenant name in the CHANGELOG and
in a Lite.Tests doc comment. This repository is public; both now describe the
server without naming it. The guard did exactly its job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
succeeded. Darling's twin is the same one-line branch in DarlingWorker. */
if (telemetry.Abandoned)
{
status = EnumeratedCollectorDriver.ClassifyReturnedRun(abandoned: true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This status flows into a complement-based bucket the PR's own design rationale says doesn't exist.

status here can now become "ABANDONED", which is then passed to RecordCollectorResult (line 737 below). That method (lines 305-348) is an if/else‑if/else chain: "SUCCESS" resets ConsecutiveErrors, "PERMISSIONS" and "YIELDED" get their own no-op branches — and everything else, including the new "ABANDONED", falls into the final else at lines 342-346, which sets LastErrorMessage and increments ConsecutiveErrors.

Before this PR, an abandoned cycle was hardcoded "SUCCESS" and hit the first branch (reset, healthy). After this PR, it hits the catch-all failure branch on the very first abandonment. GetHealthSummary treats ConsecutiveErrors > 0 as "erroring" (line ~256), and MainWindow.xaml.cs (lines 673-681) turns that into an orange-red "Collectors: N erroring" status-bar message with a tooltip literally labeled "Failing: {name}" — on a server slow enough to trip the wall-clock budget every cycle (per the PR body, abandonments went 4→15/day on the affected server), this immediately reads as the collector "failing" in Lite's live UI.

That's exactly the "would page on a guard doing precisely its job" outcome the PR rejected ERROR for — restated here for Darling's self-alert consecutive-failure path (EnumeratedCollectorDriver.cs doc comment), but Lite has its own, separate in-process consecutive-failure tracker that nothing in this PR accounts for. Darling has no equivalent per-collector in-memory tracker, so this is Lite-only — but real, and it undermines the whole point of introducing a non-paging status.

Suggest either adding an explicit "ABANDONED" branch in RecordCollectorResult that behaves like YIELDED (record the message, don't touch ConsecutiveErrors), or documenting why the Lite UI showing this as a failure is acceptable.

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Reviewed. The core change is well-designed: a shared EnumeratedCollectorDriver.ClassifyReturnedRun keeps Lite and Darling from drifting on the new ABANDONED status, both hosts thread Abandoned/CollectorRunResult.Abandoned through correctly, the note-format string is deduplicated into WholeCycleBudgetNoteFormat, status text/VARCHAR columns have no length constraint to worry about, and the six existing IN ('SUCCESS', 'SKIPPED') freshness reads across both hosts (DarlingSelfAlertEvaluator, ViewerDataService.CollectionHealth, DarlingFleetReader, DarlingDataReader, LocalDataService.CollectionHealth) already correctly exclude the new status, matching the PR's stated design.

One real bug found — see inline comment on Lite/Services/RemoteCollectorService.cs:631: Lite's in-process RecordCollectorResult health tracker (lines 305-348) is an if/else-if/else chain, and the new "ABANDONED" status falls into the catch-all else that increments ConsecutiveErrors and marks the collector "erroring" in the Lite desktop UI (orange-red status bar, "Failing: {name}" tooltip) — on the very first abandonment. That's the exact "pages like a fault" outcome the PR rejected ERROR for, restated in the shared driver's doc comment only for Darling's self-alert path. Darling has no equivalent in-memory per-collector tracker, so this is Lite-only, but it undermines the PR's own stated goal for the new status.

Two minor, non-blocking notes:

  • The PR body/doc comments claim "every read buckets by explicit list ... never by complement." That's not quite true: ViewerDataService.Postgres.cs:66 has COUNT(*) FILTER (WHERE status <> 'SUCCESS') AS failed_runs. Effect is benign here (displayed status is LastStatus ?? "Failing", so ABANDONED just renders as itself), and this is likely the exact site the PR intends to "restore visibility" to — but it's worth tightening the claim since it also already counts YIELDED/SKIPPED as failed (pre-existing, unrelated to this PR).
  • EnumeratedCollectorDriver.FreshnessSuccessStatuses (new) is only consumed by its own test (Lite.Tests/EnumeratedCollectorDriverTests.cs) — the six SQL sites still hardcode their own status IN ('SUCCESS', 'SKIPPED') literal independently, so the constant doesn't structurally prevent the four/six-way drift the doc comment says it protects against. Not a defect, just a gap between the doc's claim and what's actually wired up.

…2801)

The review found a real defect that falsified this PR's own safety argument. I
checked that every SQL read buckets status by explicit list and concluded a new
value could not silently join the wrong bucket -- but never checked the
in-memory health path. RemoteCollectorService.RecordCollectorResult is an
if/else-if/ELSE chain, so ABANDONED landed in the error arm, set
LastErrorMessage and incremented ConsecutiveErrors, showing the collector
FAILING. That is exactly the false-error outcome the status was chosen to
avoid. It now has its own arm with the YIELDED semantics, which apply verbatim:
the guard worked so the streak must not grow, and nothing was collected so it
must not reset either. Darling has no ConsecutiveErrors equivalent, so this was
Lite-only -- verified rather than assumed.

Also from review: parse the note format once (CA1863), matching the four
sibling constants. Exposed as a method rather than the bare format string,
because unlike those siblings both HOSTS render this one, so publishing the
string would leave each to parse it and choose its own culture -- which also
removes the duplicated string.Format and the using each host had grown.

Added the Darling.Tests pin the review asked for: nothing on that side asserted
this host MARKS the return as abandoned, and the worker classifies from the
flag rather than the note text, so a reworded note with an unset flag would
silently restore SUCCESS.

And the CHANGELOG link-reference definition for #2801.

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

Copy link
Copy Markdown
Owner Author

All four review findings addressed in 3ccd56ae.

The RecordCollectorResult one was a real defect and it falsified this PR's own safety argument. I checked that every SQL read buckets status by explicit list — IN ('ERROR','PERMISSIONS'), = 'YIELDED', IN ('SUCCESS','SKIPPED') — and concluded a new value could not silently join the wrong bucket. I never checked the in-memory health path, which is an if/else-if/**else** chain, so ABANDONED landed in the error arm, set LastErrorMessage and incremented ConsecutiveErrors — showing the collector FAILING, precisely the false-error outcome this status was chosen to avoid.

It now has its own arm with the YIELDED semantics, which apply verbatim: the guard worked as designed so the streak must not grow, and nothing was collected so it is not proof the collector works and must not reset it either. I checked whether Darling has the same shape rather than assuming — it has no ConsecutiveErrors equivalent, so this was Lite-only.

CA1863 — parsed once now. Exposed as a method rather than the bare format string: unlike the four sibling constants, whose callers are all in-file, both hosts render this one, so publishing the string would leave each host to parse it and pick its own culture. That also removed the duplicated string.Format and the using System.Globalization each host had grown.

Darling.Tests coverage — added Runner_Marks_The_BudgetExpiry_Return_As_Abandoned. Your framing was the useful part: the worker classifies from the flag, not the note text, and those are separately editable, so a note reworded while the flag went unset would silently restore SUCCESS with nothing failing. Source-text pinned like the rest of that class, because the abandonment sits in a catch filter on a live provider cancellation that no unit test there can reach.

CHANGELOG — [#2801] link-reference definition added.

Separately, FleetIdentifierScrubTests caught a real fleet tenant name in the CHANGELOG and a doc comment. Fixed in 7e30328e, and I also scrubbed it from the issue and PR bodies, since this repository is public.

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Reviewed. This is a clean, well-scoped fix — no correctness, parity, or security issues found.

Verified specifically:

  • CollectorRunResult.Abandoned / RunTelemetry.Abandoned both default false and are appended as the last parameter, so all 7+ existing positional construction sites are unaffected.
  • Only the whole-cycle server-scoped budget-expiry catch sets Abandoned: true in both hosts (DarlingCollectorRunner.cs:1214-1232, RemoteCollectorService.DefinitionRunner.cs:785-798) — the separate per-database ([BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150) fan-out abandonment path is untouched in both hosts, matching the PR's stated "deliberately not changed" scope.
  • Lite's telemetry.Abandoned is reset to false alongside SqlMs/StorageMs/Note at the top of RunCollectorAsync, so no stale-state leakage across the per-server ConcurrentDictionary<int, RunTelemetry> reuse.
  • RecordCollectorResult's new AbandonedStatus arm mirrors the YIELDED arm exactly (no streak growth, no streak reset) — correct, and matches the reasoning given in the PR description.
  • Checked every consumer of collection_log.status for complement-based (<>/!=) reads that could silently misclassify the new ABANDONED value: DarlingSelfAlertEvaluator.ReadCollectionSignalsAsync and CollectorHealthClassifier.Classify's inputs (LocalDataService.CollectionHealth.cs, ViewerDataService.CollectionHealth.cs) all bucket by explicit list as claimed, so a collector abandoning every cycle now correctly ages into FAILING via the hoursSinceLastSuccess path rather than being miscounted as an error. The one place that is complement-based, ViewerDataService.Postgres.cs's failed_runs (status <> 'SUCCESS'), is explicitly the intended new visibility per the PR description, not a bug.
  • Test source-text pins (Darling.Tests, Lite.Tests) match the actual code strings they assert against.
  • No T-SQL touched by this PR, so the T-SQL style conventions don't apply here; the C# follows existing house patterns (CompositeFormat caching, XML doc style, shared-constant placement in EnumeratedCollectorDriver).

No inline comments to add.

@erikdarlingdata
erikdarlingdata merged commit 2640315 into dev Sep 2, 2026
6 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/abandoned-cycle-status branch September 2, 2026 23:34
erikdarlingdata added a commit that referenced this pull request Sep 4, 2026
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
erikdarlingdata added a commit that referenced this pull request Sep 4, 2026
… (#2867)

* Count ABANDONED cycles in collection health and band them (Fixes #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.

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

* Address review: stale column counts and the MCP tool contract

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

* Address review: undefined CHANGELOG link ref and a doc-comment indent

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Sep 10, 2026
…lingdata#2926)

collection_log is append-only, so a retention window can still hold
cycles the wall-clock budget abandoned BEFORE erikdarlingdata#2803 gave abandonment its
own status. Those rows carry status = 'SUCCESS' beside rows_collected = 0
and the abandonment's own error_message, so all five Collection-Health
banding reads counted them as zero abandonments and as successes: the
abandon rate stayed under its 0.5% threshold and the collector banded
HEALTHY while losing cycles.

All five now key on the era-invariant conjunction - nothing stored plus
the budget note - shared as EnumeratedCollectorDriver.AbandonedRunPredicateSql
and derived from WholeCycleBudgetNoteFormat, since the budget is
interpolated and the shipped values differ (120s against query_store's
600s). The same rows leave success_count so the two adjacent grid columns
cannot both claim one run, and rows_collected is projected through the
three reads whose aggregates sit outside a column-enumerating subquery.

The write path is unchanged and the schema stays at V110.
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