Give a wall-clock-budget-abandoned cycle its own collection_log status - #2803
Conversation
#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
| - **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. |
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
Review summaryFocused, well-scoped fix: a single
|
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); |
There was a problem hiding this comment.
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.
|
Reviewed. The core change is well-designed: a shared One real bug found — see inline comment on Two minor, non-blocking notes:
|
…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
|
All four review findings addressed in The It now has its own arm with the 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 Darling.Tests coverage — added CHANGELOG — Separately, |
|
Reviewed. This is a clean, well-scoped fix — no correctness, parity, or security issues found. Verified specifically:
No inline comments to add. |
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
… (#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>
…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.
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 isSUCCESSwithrows_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 inDarlingWorkerand once in Lite'sRunCollectorAsync. It carries its message through theNote/telemetry.Notechannel, 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.ReadCollectionSignalsAsynctakes bothlast_successandrecent_successfromstatus IN ('SUCCESS', 'SKIPPED')— so a collector abandoning every cycle read as perpetually fresh. And the message landed inlast_note/note_count, gated onstatus = 'SUCCESS'.The status, and the alternatives rejected
ABANDONED, produced by one sharedEnumeratedCollectorDriver.ClassifyReturnedRunso 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 1sLOCK_TIMEOUTguard 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.statuscarries 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
SUCCESSwith its note. Only the whole-cycle Bound each collector's per-server wall-clock footprint (tail is up to 555s on one server) #2673 case, which returns0rows before the storage phase, is reclassified. The "partial vs zero" distinction is a property of the code, not a judgement call: the Bound each collector's per-server wall-clock footprint (tail is up to 555s on one server) #2673 site hardcodes0rows and its comment notes it skips the storage phase and the state-persistence block.abandoned_countcolumn in the four collection-health reads. That would touch ~13 sites including four ordinal-based readers, where inserting a column shifts every subsequent ordinal — a silent-corruption risk not worth taking for a display nicety, especially with a fleet roll in flight. Filed as a follow-up rather than bundled. The status change alone already restores the visibility the issue is about: anystatus <> 'SUCCESS'query now sees these.Verification
Lite.TestsandDarling.Testsarenet10.0-windowsand cannot run on macOS, so the pins were also run through a throwawaynet10.0harness against the real builtPerformanceMonitor.Collectorsassembly. CI is the arbiter for the xUnit run.Proven red first. Reverting
ClassifyReturnedRunto the pre-fix hardcoded literal: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_statson the affected server, full retention window: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
maxfalls 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