Repository navigation
Serialize the Query Store tick and backfill per server (#2165) - #2272
Conversation
| continue; | ||
| } | ||
|
|
||
| using var backfillGate = gate; |
There was a problem hiding this comment.
The gate is released on Abandoned, even though the guarded statement is still running. This contradicts the stated design ("held for the WHOLE slice... an abandoned-but-still-wedged slice keeps the gate closed") and the PR description's testing claims.
using var backfillGate = gate; disposes at the end of this loop iteration's block — i.e., right after the switch below, regardless of result.Outcome. But per AbandonableStep.RunAsync (PerformanceMonitor.Common/AbandonableStep.cs:132-143), Abandoned means the underlying task (work) is still executing in the background; the step's own _inFlight guard is only cleared later, in work.ContinueWith when the task truly ends.
So on abandonment here:
backfillGatedisposes immediately, releasing the per-serverQueryStoreServerGate.- The zombie backfill statement keeps running against the monitored server.
- On the very next tick (~1 minute later),
RunOneAsync'sTryAcquire()on the same gate succeeds and launches a freshquery_storecollection against the same server.
That's exactly the concurrent-heavy-QS-extraction scenario this PR exists to prevent — and it's most likely to happen precisely when a slice is large enough to hit the 300s abandonment deadline, i.e. exactly the case that matters most.
The gate's lease needs to be handed to the abandoned task and released only when it truly finishes (e.g. threaded through AbandonableStep's own completion, similar to how _inFlight is cleared in work.ContinueWith), not disposed based on RunAsync merely returning.
Note the new Darling_BothLoopsShareOneGateRegistry test only asserts textual ordering ("gate acquired before the step") — it doesn't exercise an actual abandonment, so it doesn't catch this.
Same pattern exists in the Lite counterpart (RemoteCollectorService.QueryStoreBackfill.cs), so this isn't a parity drift — it's a shared bug in both apps.
| continue; | ||
| } | ||
|
|
||
| using var backfillGate = gate; |
There was a problem hiding this comment.
Same issue as the Darling counterpart (DarlingWorker.cs, RunQueryStoreBackfillLoopAsync): using var backfillGate = gate; releases the gate at the end of this loop iteration, which happens as soon as step.RunAsync returns — including on AbandonableStepOutcome.Abandoned, where the underlying task is still running (see AbandonableStep.RunAsync, PerformanceMonitor.Common/AbandonableStep.cs:132-143: work keeps executing; _inFlight only clears later via work.ContinueWith).
So a wedged/slow backfill slice that trips the abandonment deadline releases the gate immediately, and the tick's CollectQueryStoreAsync can then acquire it and run query_store collection concurrently with the still-running backfill statement — the exact overlap this PR is meant to eliminate, and most likely exactly when a slice is big enough to be abandoned in the first place.
The lease needs to transfer to the abandoned background task and release only when that task truly completes, not when RunAsync returns control to the loop.
ReviewThis is a clean, well-scoped PR — the One correctness bug, left as inline comments, present identically in both apps: In both That means exactly the scenario this PR is built to prevent — a large/slow Query Store slice overlapping the tick's own Query Store collection — is the one scenario where the fix silently doesn't hold: an abandoned slice releases the gate immediately, and the very next tick can acquire it and run concurrent heavy QS extraction against the same server, recreating the original bug. This directly contradicts both the PR description's claim ("the backfill takes the gate outside its The new Suggested fix direction: transfer the gate lease to the abandoned background task (e.g., dispose it inside |
e42ce89 to
ab858d6
Compare
| continue; | ||
| } | ||
|
|
||
| using var backfillGate = gate; |
There was a problem hiding this comment.
The gate is released as soon as RunAsync returns, not when the slice truly finishes — which reopens exactly the overlap this PR exists to close.
AbandonableStep.RunAsync returns AbandonableStepOutcome.Abandoned while work keeps running in the background; the in-flight guard only clears later, inside work.ContinueWith(...), when the task actually ends (see AbandonableStep.cs lines 101-131). backfillGate here is a using var scoped to this foreach iteration. When the 300s deadline fires, the switch below logs Abandoned and falls off the end of the iteration — disposing backfillGate immediately, while the abandoned backfill statement is still executing against runtime's server.
The very next tick can then acquire the now-free gate (RunOneAsync, line ~3925) and start query_store collection against that same server while the wedged backfill slice is still running underneath it — the ~128 MB-concurrent-extraction scenario from the PR description, reintroduced specifically in the one failure mode (a wedge) where it's most damaging.
The doc comment right above ("an abandoned-but-still-wedged slice keeps the gate closed") and the Abandoned log message both describe behavior the code doesn't actually have.
AbandonableStep doesn't currently expose a "truly finished" hook to release into — only onLateFault, which fires solely on exceptions after abandonment, not on a clean late completion. Fixing this needs either a new completion callback on RunAsync, or capturing work and disposing the gate lease in a ContinueWith on it, rather than via using on the synchronous return path.
Same pattern, same bug, in the Lite twin: Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs around line 99.
| continue; | ||
| } | ||
|
|
||
| using var backfillGate = gate; |
There was a problem hiding this comment.
Same issue as the Darling twin — the gate releases when RunAsync returns, not when the slice truly finishes.
AbandonableStep.RunAsync returns AbandonableStepOutcome.Abandoned after the 180s deadline while work (RunQueryStoreBackfillSliceAsync) keeps running in the background — the in-flight guard only clears later, inside work.ContinueWith(...), when the task actually ends (AbandonableStep.cs lines 101-131). backfillGate is a using var scoped to this foreach iteration, so when the switch below logs Abandoned and the iteration falls through to the next server, backfillGate.Dispose() runs immediately — while the abandoned slice is still executing against server.
The next tick's CollectQueryStoreAsync (RemoteCollectorService.QueryStore.cs) can then acquire the now-free gate for that same server and start live query_store collection while the wedged backfill slice is still running underneath it — the double-heavy-extraction scenario this PR exists to prevent, reintroduced in exactly the failure mode (a wedge) where it's most damaging.
The comment above ("an abandoned-but-still-wedged slice keeps the gate closed") describes the intended behavior, not the actual one. Releasing correctly needs the gate lease disposed off the true completion of work, not off RunAsync's return — e.g. a completion callback on AbandonableStep.RunAsync (today only onLateFault exists, and that fires solely on exceptions), or capturing work here and chaining the gate's Dispose() onto it instead of a using.
Identical bug in Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs around line 1922 — this isn't parity drift, both apps share the same flaw.
Review summaryReviewed the One correctness bug, present identically in both apps (left as inline comments): The backfill side takes the gate with
This isn't Lite/Darling drift — both apps have the same flaw, from the same Everything else checked out:
The new tests are solid for the primitive itself and for static wiring facts (same registry, gate-before-step ordering) but don't exercise the abandonment-then-release timing, which is how this slipped through. |
The per-tick query_store collection and the #2058 first-contact backfill were independent loops with no per-server coordination, and both do heavy QS text extraction. Dogfood, 4-core multi-tenant box mid-consolidation: a 64MB backfill slice for a freshly restored database ran concurrently with the tick's collection of a sibling database (12:50:58 ship against a 12:51:09 tick completion) -- ~128MB of extraction in flight on the box least able to afford it. Not bad luck: a big catalog arriving is what triggers BOTH loops, so they collide when the server is already drowning. A per-server gate now excludes them in both apps, over one shared QueryStoreServerGate primitive sitting beside AbandonableStep -- that one bounds how long a step may hold a loop, this one bounds what runs beside it. Nothing ever waits. Both sides try-acquire with a zero timeout and skip: an in-flight slice runs to a 180-300s abandonment deadline, so a blocking acquire would let one slow server stall the whole fleet's collection, which is the #2148 wedge arriving through a lock instead of a hang. Skipping is safe HERE because this collector's window is a watermark (#1960) -- the next pass resumes from the same boundary, so rows defer rather than drop. That is also why this gate must not be reused for a wall-clock-windowed collector. "Tick wins, backfill defers" comes from cadence, not preemption: stopping a statement already running on the monitored server would mean killing it, and the tick retries every ~minute against the backfill's five. The backfill takes the gate OUTSIDE its AbandonableStep, so an abandoned-but-wedged slice keeps it closed -- that statement is still running and the tick must keep yielding. Interlocked flag, not a SemaphoreSlim: a gate that never waits needs nothing a semaphore adds, and would otherwise own an undisposed kernel object per server forever. Leases are idempotent on dispose, since a stray second Dispose() would clear a flag the other loop had since taken -- the exact condition being prevented, reached backwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ab858d6 to
22fe798
Compare
| /* #2165: the other half of the gate. Held for the WHOLE slice, and taken outside the | ||
| AbandonableStep so an abandoned-but-still-wedged slice keeps the gate closed — the tick must | ||
| keep yielding while that statement is genuinely still running on the server, which is exactly | ||
| the case the abandonment leaves behind. Zero-wait, so a tick already collecting simply defers | ||
| this server's slice to the next five-minute cycle. */ | ||
| var gate = _queryStoreGates.GetOrAdd(runtime.ServerId, static _ => new QueryStoreServerGate()).TryAcquire(); | ||
| if (gate is null) | ||
| { | ||
| _logger.LogInformation( | ||
| "query_store backfill slice on '{Server}' deferred — the tick's Query Store collection is running (#2165)", | ||
| runtime.Config.DisplayName); | ||
| continue; | ||
| } | ||
|
|
||
| using var backfillGate = gate; |
There was a problem hiding this comment.
The gate does not actually stay held through an abandonment — it releases at the exact moment the comment says it must not.
The comment above (and the PR description) claims: "an abandoned-but-still-wedged slice keeps the gate closed... the tick must keep yielding while that statement is genuinely still running on the server."
But AbandonableStep.RunAsync returns Abandoned as soon as the deadline elapses — it does not wait for work to finish:
var finished = await Task.WhenAny(work, Task.Delay(timeout, cancellationToken));
if (finished != work)
{
...
return new AbandonableStepResult(AbandonableStepOutcome.Abandoned); // work is still running
}work only truly completes later, in the .ContinueWith continuation that clears AbandonableStep's own _inFlight flag (PerformanceMonitor.Common/AbandonableStep.cs:108-130).
using var backfillGate = gate; is scoped to this loop iteration, so it Dispose()s as soon as RunAsync returns — i.e. immediately on Abandoned, not when the wedged statement actually finishes. Concretely:
- A slice wedges past
BackfillSliceDeadline(180s) → outcome isAbandoned, loop iteration ends,backfillGate.Dispose()runs —QueryStoreServerGate._takengoes back to 0 — while the abandoned task is still executing the QS extraction query against the monitored server. - On the next tick (~1 min later),
RunOneAsync'sIsQueryStoreCollectorgate callsTryAcquire()on the same per-server gate and succeeds, because it was already released. - The tick's
query_storecollection now runs concurrently with the still-wedged backfill statement — the exact double-QS-extraction collision ([PERF] Tick QS collection and QS backfill run concurrently against the same server — serialize per server #2165) this PR exists to prevent, and it happens specifically in the case (a genuinely stuck/slow statement) where the collision is worst.
Note _backfillSliceSteps' own IsInFlight guard correctly stays set until work truly ends (that's the whole point of AbandonableStep) — but nothing propagates that same "still running" fact to QueryStoreServerGate, so the new gate doesn't inherit the protection the old guard already had.
The wiring tests (Darling_BothLoopsShareOneGateRegistry / Lite_BothLoopsShareOneGateRegistry in Lite.Tests/QueryStoreServerGateTests.cs) only assert that the gate acquisition line appears before the step textually — they don't (and structurally can't, being text-pin tests) catch that the lease is released too early on the abandoned path. Same issue exists in Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs (the using var backfillGate = gate; there has the identical shape) — flagging both for parity.
A fix likely needs the gate's Dispose() to ride the same completion signal as AbandonableStep's _inFlight clear — e.g. transfer the lease into the onLateFault/continuation path (or a new completion hook on AbandonableStep) instead of disposing it when RunAsync merely returns control to the loop.
| /* #2165: the other half of the gate. Taken OUTSIDE the AbandonableStep on purpose — an | ||
| abandoned-but-still-wedged slice keeps the gate closed, which is right, because the statement | ||
| is genuinely still running on the monitored server and the tick must keep yielding to it. */ | ||
| var gate = _queryStoreGates | ||
| .GetOrAdd(server.Id, static _ => new QueryStoreServerGate()) | ||
| .TryAcquire(); | ||
|
|
||
| if (gate is null) | ||
| { | ||
| _logger?.LogInformation( | ||
| "query_store backfill slice on '{Server}' deferred — the tick's Query Store collection is running (#2165)", | ||
| server.DisplayName); | ||
| continue; | ||
| } | ||
|
|
||
| using var backfillGate = gate; |
There was a problem hiding this comment.
Same premature-release bug as the Darling twin (DarlingWorker.cs around RunQueryStoreBackfillLoopAsync's using var backfillGate = gate;) — flagging here for parity since the same fix will need to land in both.
step.RunAsync (PerformanceMonitor.Common/AbandonableStep.cs) returns AbandonableStepOutcome.Abandoned the moment the deadline elapses, without awaiting the underlying work task — work keeps running and only clears AbandonableStep's own _inFlight guard later, from a .ContinueWith continuation.
using var backfillGate = gate; is scoped to this foreach iteration, so it disposes as soon as RunAsync returns — including on Abandoned, while the wedged QS extraction statement is still actually executing against the server. That means: slice wedges past BackfillSliceDeadline → gate released this iteration → the next CollectQueryStoreAsync tick (RemoteCollectorService.QueryStore.cs) successfully TryAcquire()s the same per-server gate and runs concurrently with the still-running abandoned backfill statement — the exact collision #2165 is meant to prevent, in precisely the "genuinely stuck" case that matters most.
The comment directly above this line ("an abandoned-but-still-wedged slice keeps the gate closed... the tick must keep yielding") states the intended behavior, but the code doesn't achieve it. Worth fixing in QueryStoreServerGate/the call sites so the lease's release rides the same completion signal AbandonableStep._inFlight uses, rather than RunAsync's early return on abandonment.
|
Reviewed the diff (CHANGELOG, One correctness finding, posted inline on both apps (parity bug — same root cause, same shape in each): the gate does not actually stay held through an Everything else looks solid:
No other Lite/Darling parity drift spotted beyond the shared bug above (which itself is present identically in both, so no divergence between the two apps). |
Closes #2165.
What was measured
The per-tick
query_storecollection and the #2058 first-contact backfill were independent loops with no per-server coordination at all, and both do heavy Query Store text extraction. From the dogfood box (4-core, multi-tenant, mid-consolidation):The overlap isn't bad luck. A big catalog arriving is exactly what triggers both the backfill and budget-bound tick passes, so the two loops are most likely to collide precisely when the server is already drowning.
The gate
One shared
QueryStoreServerGateinPerformanceMonitor.Common, sitting besideAbandonableStep— that one bounds how long a step may hold a loop, this one bounds what may run beside it. Each host keeps its own keyed registry (Darling byintserver id, Lite bystring), because the primitive is the gate, not the registry.Wired at two points per app:
RunOneAsync, the one funnel that has the runtime and the collector name together; and around the backfill slice inRunQueryStoreBackfillLoopAsync.CollectQueryStoreAsyncrather than at the tick's dispatch switch, so every caller is covered including an on-demand collection; and around the slice inRunQueryStoreBackfillTickAsync.Three decisions worth calling out
Nothing ever waits. Both sides try-acquire with a zero timeout and skip on failure. These are shared fleet loops and an in-flight slice runs to a 180-300 second abandonment deadline, so a blocking acquire would let one slow server stall collection for every other server — the #2148 wedge arriving through a lock instead of a hang. A gate that can only skip cannot do that.
Skipping is safe for this collector specifically, and that's load-bearing rather than incidental. Query Store collection is watermark-driven (#1960): each pass resumes from the last shipped boundary, so a skipped pass defers rows, it doesn't drop them. Documented on the type, because the same gate around a wall-clock-windowed collector would silently lose data.
"Tick wins, backfill defers" is realized by cadence, not preemption. The loser is whichever loop arrives second — stopping a statement already running against the monitored server would mean killing it, and cancelling a QS read mid-flight buys nothing that waiting one cycle doesn't. The bias comes from the tick retrying on its ~1-minute interval against the backfill's 5, so it recovers ~5x faster from a collision, and a slice is byte-budgeted so it's short in the healthy case.
Two smaller ones:
AbandonableStep, so an abandoned-but-still-wedged slice keeps the gate closed — that statement is genuinely still running on the server, so the tick must keep yielding to it. Pinned by index comparison, not by comment.SemaphoreSlim. A gate that never waits needs none of what a semaphore provides (blocking acquire, timeouts, async waits) and would otherwise own one undisposed kernel-backed object per monitored server for the process lifetime — which is also what CA1001 flagged on the first cut. Leases are idempotent on dispose, because a stray secondDispose()would clear a flag the other loop had since taken and let both run at once: the exact condition being prevented, reached from the wrong direction.Testing
Both suites are
net10.0-windows, so beyond the xunit tests I compiled the gate into a plainnet10.0harness and executed the semantics here — 17/17 pass, including 8-way contention over 16,000 attempts:Covered: exclusion; release handing over; refused acquires returning immediately (asserted on elapsed time, because a blocking acquire would still pass an exclusion-only test while reintroducing the fleet stall); double-dispose not stealing another loop's hold; max-one-holder under contention; the
NotGatedsentinel being non-null and re-disposable; and per-server independence — a single shared gate would serialize QS collection fleet-wide, a throughput regression dressed as a fix.The wiring is pinned at the source in both apps, because behavioral coverage can't reach it — reproducing the overlap needs two live loops against one real server with a big catalog, and a correct gate that one loop doesn't take is exactly the bug still present while everything else passes. Those pins assert the registry is declared once per app (two loops each holding a private registry would compile, pass every gate unit test, and exclude nothing), that Darling gates on
QueryStoreCollector.Instance.Namerather than a literal so renaming the collector can't silently unhook it, and that the backfill takes the gate before the step. I ran all 11 pin assertions plus the doc-comment hygiene detector locally — all pass.Full solution builds clean, 0 warnings.
🤖 Generated with Claude Code