#2148: one wedged ladder step must never stop all collection - #2149
Conversation
Field failure (TrudAX, Azure elastic pool): ~12 minutes after upgrading to 3.4.0, EVERY collector's last success froze and all collection stopped permanently — with every ladder step's exception armor intact, because armor bounds throws and nothing bounded a HANG. The two steps that could hold the sequential loop without bound — the Query Store backfill tick (new in 3.4.0, the timeline's prime suspect) and the connection check — now run under AbandonableStep: the ladder's own scheduled-analysis idiom (deadline + in-flight guard cleared only on TRUE task completion), extracted into PerformanceMonitor.Common and made reusable. An abandoned run is quarantined (never overlapped by a relaunch) and the step self-restores the moment the wedged task actually ends. Abandonment and still-wedged skips log at ERROR naming #2148 — the deadlines (180s backfill, 90s connection check) are generous multiples of healthy behavior, so either line is a defect signal, and it is the difference between a diagnosable field report and 'the charts just stopped'. Pinned: all five outcomes, the quarantine (no overlap while wedged), the release (step runs again when the wedged task ends), abandoned-then- faulted observed without UnobservedTaskException, sync throws contained, and shutdown reading as Cancelled rather than a false ERROR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewSolid, well-tested fix for the core symptom (Lite's ladder permanently wedging on a hung backfill/connection-check step). 1. Lite/Darling parity gap: the identical bug still exists in Darling, unfixed
Notably, Darling's own scheduled-analysis pass already hand-rolls this exact 2. Exceptions from an abandoned task that later actually faults are silently discardedIn Neither of these blocks the immediate 3.4.1 patch goal (which is explicitly scoped to Lite's field failure), but both should be tracked before calling the underlying bug closed. |
| _ = work.ContinueWith( | ||
| static (t, state) => | ||
| { | ||
| _ = t.Exception; /* observe */ |
There was a problem hiding this comment.
_ = t.Exception; only marks the exception observed to avoid UnobservedTaskException — it's never logged or surfaced anywhere. If a step is abandoned and its task later actually faults, that exception is now permanently lost: the caller already logged the generic "was ABANDONED" line at abandonment time and has no way to learn the eventual real failure reason. Given this PR's stated goal is "survivable and diagnosable," consider giving callers a way to observe the late fault (e.g. an optional Action<Exception> callback parameter, or exposing it via an event) rather than discarding it silently.
…iew catches) 1. Darling had the identical failure class unfixed: a wedged slice in RunQueryStoreBackfillLoopAsync's foreach stalled backfill for the entire fleet, forever, with the exception armor intact. Per-SERVER AbandonableSteps now quarantine only the wedged server (deadline 300s — a healthy slice is one 60s-capped statement plus writes); its neighbors keep backfilling, and it self-restores when the wedged task dies. 2. An abandoned run's LATE fault — the one exception that explains the wedge — was observed and discarded. RunAsync gains onLateFault; both Lite call sites and the Darling loop log it as the wedge's own exception. Fires only for faults the awaited path did not already return (never doubles ordinary failures); a fault landing in the microseconds before the abandonment flag can miss the callback, which costs one log line, not correctness. A throwing callback still releases the guard. Pinned: late fault surfaces through the callback with the original exception; within-deadline faults never double-fire it; a throwing callback still releases the guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both findings taken in 6803474 — and both were right to insist on:
Three new pins cover the callback arms. |
| /* #2148: abandonable — the field failure. A backfill slice that wedges (elastic pool, big | ||
| Query Store, first contact after upgrade) previously held the ENTIRE ladder: exception armor | ||
| was intact, but nothing bounded a hang, so all collection stopped permanently and the CPU | ||
| chart going blank was just where the user noticed. The deadline returns the loop to | ||
| collecting; the in-flight guard keeps the wedged tick from being relaunched on top of | ||
| itself and lets backfill resume the moment the stuck task actually ends. */ | ||
| var tick = await _backfillStep.RunAsync( | ||
| () => _collectorService.RunQueryStoreBackfillTickAsync(stoppingToken), | ||
| BackfillTickDeadline, stoppingToken, | ||
| onLateFault: ex => _logger?.LogError(ex, | ||
| "Query Store backfill tick faulted AFTER being abandoned — this is the wedge's own exception (#2148)")); | ||
| LogStepOutcome(tick, "Query Store backfill tick", BackfillTickDeadline); |
There was a problem hiding this comment.
Correctness / Lite-Darling parity: this wraps the whole multi-server tick, not one server, so it doesn't actually give Lite the per-server isolation Darling gets.
RunQueryStoreBackfillTickAsync (Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs:54) internally foreaches over every enabled server and runs one slice per server, unchanged by this PR:
public async Task RunQueryStoreBackfillTickAsync(CancellationToken cancellationToken)
{
foreach (var server in _serverManager.GetEnabledServers())
{
...
await RunQueryStoreBackfillSliceAsync(server, cancellationToken); // still just try/catch, no per-slice timeout
}
}Here, _backfillStep is a single shared AbandonableStep instance wrapping that entire multi-server call, not one instance per server the way Darling does it (_backfillSliceSteps keyed by ServerId in DarlingWorker.cs, per the comment on that dictionary: "a wedged server never blocks its neighbors"). The comment on this call even cites the exact field failure ("elastic pool, big Query Store, first contact after upgrade") that this PR is supposed to fix.
Two consequences:
- A hang in one server's slice still stalls the whole fleet's backfill — permanently.
RunQueryStoreBackfillSliceAsync's hang isn't caught by the inner try/catch (it's a hang, not a throw), so the outer call wedges exactly like before. After 180s it gets reportedAbandoned, but because_backfillStepis single/shared, its guard doesn't release until that whole wedgedTask(stuck on server Add check for updates feature #1) truly finishes — which, if it's a genuine wedge, is never. Every subsequent due-check then returnsSkippedStillRunningforever, so Query Store backfill silently dies for every server in the fleet, not just the wedged one, until the service restarts. That's a narrower version of the original [BUG] Regression on 3.4.0 - no CPU Data on Elastic Pool #2148 symptom, just scoped to backfill instead of all collection. - False-positive abandonment as fleet size grows, even with no wedge at all: the 180s deadline is sized for "one 30s-capped slice per server plus writes" (per the comment two lines up), but it's applied to the sum across all enabled servers in one tick. A healthy fleet of ~7+ servers, each taking close to the 30s cap, can legitimately blow the 180s budget and get spuriously
Abandoned/SkippedStillRunningwith no wedge involved.
Darling's fix (per-server ConcurrentDictionary<int, AbandonableStep> inside the foreach, keyed by ServerId) is the right shape and is even labeled "#2148 parity (review catch on the Lite fix)" — but that insight doesn't appear to have been carried back into Lite. Consider moving the AbandonableStep down into RunQueryStoreBackfillTickAsync's per-server loop (one step per server.ServerId, deadline sized to a single slice) rather than wrapping the whole tick here.
Review summaryReviewed the Main finding (left inline on Secondary/minor: The PR frames Things that look correct:
No security concerns (no new external input, file, network, or process surface — this is purely internal task-orchestration plumbing). |
… catch) The round-2 review was right twice: the shared tick-level AbandonableStep (a) stalled EVERY Lite server's backfill behind one wedge — a narrower rerun of the original #2148 symptom — and (b) would false-trip as fleet size grows, since a deadline sized for one slice was applied to the sum of all of them. The guards now live inside the tick, per server, in exactly the Darling loop's shape (same dictionary, same deadline semantics, same log vocabulary); the ladder call reverts to plain fault-armor because the tick is bounded by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Round-2 main finding taken in 3774ae5 — correct on both counts: the shared tick-level guard was a narrower rerun of the very symptom this PR fixes (one wedge → all Lite servers' backfill quarantined forever) AND a false-abandonment risk scaling with fleet size. Lite's guards now live inside the tick, per server, in exactly the Darling loop's shape; the ladder-level wrapper is gone because the tick is bounded by construction. The secondary point (migrating both apps' hand-rolled scheduled-analysis guards onto AbandonableStep so there aren't three copies of the pattern) is real but out of scope for the 3.4.1 candidate — noting it on #2148 as follow-up. |
The prior commit was pushed with a broken Lite build (my chain printed the error count and did not gate on it): ServerConnection.Id is a string in Lite, unlike Darling's int ServerId. Ordinal-keyed dictionary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
||
| ### Fixed | ||
|
|
||
| - **One wedged background step can no longer stop all collection** ([#2148], reported on an Azure elastic pool minutes after upgrading) - Lite's collection ladder runs its steps sequentially, and while every step's exceptions were contained, nothing bounded a HANG: one stuck step (the new Query Store backfill tick was the prime suspect on the reporter's timeline) silently froze every collector, and the CPU chart going blank was just where it got noticed. The backfill tick and the connection check now run under an abandonment deadline with an in-flight guard (the ladder's own scheduled-analysis idiom, extracted as a reusable primitive): a wedged run is abandoned so collection continues, quarantined so it is never relaunched on top of itself, and the step self-restores when the stuck task actually ends. Abandonment logs at ERROR naming the issue - the deadlines are generous multiples of healthy behavior, so that line is always a defect signal worth reporting. |
There was a problem hiding this comment.
This entry is stale relative to where the PR ended up (commit 3, "Lite backfill guards go per-server, tick-level guard removed"):
- "The backfill tick ... now run[s] under an abandonment deadline with an in-flight guard" is no longer accurate. The tick-level
AbandonableStep/BackfillTickDeadlinefrom commit 1 was deliberately removed —RunQueryStoreBackfillIfDueAsyncnow wraps the whole tick in a plain try/catch (no deadline) because the guard moved inside the tick, per server (RemoteCollectorService.QueryStoreBackfill.cs,_backfillSliceSteps). A reader diagnosing a future wedge from this changelog would look for a tick-level abandonment log line that no longer exists. - The entry only describes the Lite side. The identical failure class was also fixed in Darling (
DarlingWorker.RunQueryStoreBackfillLoopAsync, per-server_backfillSliceStepskeyed byint ServerId, 300s deadline) in the same PR (commit 2), but that's not mentioned at all — other entries in this file (e.g. the PVS alert, the alert-threshold entry above) explicitly call out "both apps" or "Darling only" when a fix spans editions, so this looks like an oversight rather than a deliberate Lite-only framing.
Worth updating to say the guard lives per-server (inside the tick / inside the fleet loop) rather than on the tick itself, and to note the Darling-side fix.
Review summaryReviewed the Correctness ( Lite/Darling parity — confirmed correct on the specific thing that broke earlier in this PR's own history: Darling's One finding posted inline on No security, SQL-injection, or missing-index concerns apply here — this PR is pure C# task-orchestration, no query surface or user input touched. |
…nd-3 catch) The entry still described the first-commit tick-level guard; it now says what shipped — per-server quarantine in both Lite and Darling, the late- fault surfacing, and the connection-check treatment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Round-3 finding taken in a2cbced: the changelog entry now describes the shipped architecture — per-server quarantine in both apps, late-fault surfacing, and the connection-check treatment — instead of the first-commit tick-level shape. Agreed on the reasoning: this entry is the field-diagnosis reference for exactly this incident class, so it describing a superseded design would have cost someone an hour. |
|
Reviewed the diff against `origin/dev` (6 files, +526/-34). Note: `CLAUDE.md` is gitignored in this repo and not present in the working tree, so I reviewed against `CONTRIBUTING.md`'s C# style section and the existing code conventions instead. Summary: solid fix, no correctness bugs or parity gaps found.
Nothing blocking. Nice writeup in the PR description tying this back to the specific field failure. |
Fix for the live 3.4.0 field regression (TrudAX, Azure elastic pool): all collection stopped permanently ~12 minutes after upgrading — every collector's last success frozen at the upgrade minute, last runs frozen shortly after, 40+ minutes of silence at screenshot time.
Root cause class
Lite's collection ladder runs its steps sequentially, and every step's exception armor was intact — but armor bounds throws, and nothing bounded a hang. Two steps could hold the loop without bound: the Query Store backfill tick (new in 3.4.0, and the prime suspect on the reporter's timeline — first contact after upgrade queues a backfill tail for every database in the pool) and the connection check (un-timeboxed Azure token/network paths). One wedge = every chart silent, permanently, with nothing in the log.
The fix
Both steps now run under AbandonableStep (new, in PerformanceMonitor.Common) — the ladder's own scheduled-analysis idiom extracted and made reusable:
Task.WhenAnyagainst a deadline + an in-flight guard cleared only when the task TRULY ends.Pins
All five outcomes; the quarantine (second run skipped while wedged, never overlapped); the release (step runs again when the wedge ends); abandoned-then-faulted is observed (no UnobservedTaskException) and still releases; synchronous delegate throws contained; cancellation semantics.
Notes
Closes nothing automatically — #2148 stays open pending the reporter's log confirmation.