Skip to content

Detach plan_correction from the sequential body, generalizing #2701 - #2720

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/plan-correction-detach
Aug 31, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/plan-correction-detach

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

Fixes #2717.

Problem

Erik flagged plan_correction's Collector Cost Regression on one fleet server (217,072 ms/day, 3.4x its 14-day baseline) and asked for further perf tuning beyond #2687/#2688.

Investigation found plan_correction's own SQL has no further tuning headroom. It's already correctly seek-based post-#2687 (avg 1057ms). But get_collection_health shows a max of 21,351ms, with 97% of that (20,755ms) attributable to one database. Checked collect.query_store_plan_map directly for that database: 36,232 distinct plan_ids, 28,260 distinct digests — the identical application-class decimal-parameter-instability signature already root-caused for query_store on other servers earlier tonight. This is a data-volume problem downstream of an already-tracked app-side bug, not a query-shape defect in plan_correction.

The actual actionable finding: plan_correction's cost shape (avg ~1s, occasional 20+s spike depending on the affected server) is exactly the bimodal shape #2701 fixed for query_store (avg 5-35s, occasional 100-230s spike) — which caused query_store to block every other due collector sharing its per-server sequential body and risk BODY_OVERRUN. #2701's fix applies only to query_store. plan_correction was still awaited inline in RunDueCollectorsAsync, still able to block other collectors on an affected server for the duration of its own spike.

Fix

Generalizes #2701's detach pattern instead of duplicating it for a second collector:

  • DetachedCollectorGate (new, PerformanceMonitor.Common): a collector-agnostic sibling of QueryStoreServerGate, same CompareExchange-flag single-flight shape, never blocks. QueryStoreServerGate itself is untouched — it has an orthogonal second job (mutual exclusion against the separate first-contact backfill loop) this type doesn't need to solve.
  • _detachedCollectorGates in DarlingWorker: keyed by (int ServerId, string CollectorName), so a future third collector detached this way needs only its own IsXCollector check — the dictionary already generalizes.
  • IsPlanCorrectionCollector mirrors IsQueryStoreCollector's renaming-safety pattern (compared against the collector's own declared Name).
  • RunDetachedQueryStoreAsync renamed RunDetachedAsync and shared by both detached collectors rather than duplicated — its body (contain a shutdown-time OperationCanceledException) was already fully generic.
  • RunOneAsync gates plan_correction through the new generic gate the same way it already gated query_store through its own.

plan_correction is safe to detach for the same reason query_store is: its window isn't wall-clock derived. It re-reads sys.dm_db_tuning_recommendations whole on every successful pass (no persisted watermark to invalidate), so a skipped tick just re-reads the current — possibly since-refreshed — live set next time. No rows are lost, only deferred.

Test plan

  • dotnet build (project + full solution) clean, 0 errors, no new warnings
  • DetachedCollectorGateTests.cs added, mirroring QueryStoreServerGateTests.cs's five properties (exclusion, release-lets-next-in, never-blocks, double-dispose-safety, single-holder-under-contention)
  • Could not run Lite.Tests/Darling.Tests locally (net10.0-windows, this Mac lacks the WindowsDesktop runtime) — verified all 14 assertions pass against the real, shipped DetachedCollectorGate type via a throwaway net10.0 console harness referencing the actual project (not a retyped copy), per this repo's documented macOS verification practice. Relying on CI for the full xUnit run.
  • First CI run caught two real issues, both fixed: a real fleet-tenant identifier that had leaked into a code comment (FleetIdentifierScrubTests, since this repo is public — replaced with generic language) and a stacked doc-comment block left over from renaming RunDetachedQueryStoreAsync to RunDetachedAsync (DocCommentHygieneTests). Commit amended and force-pushed to a branch nothing else was based on.

🤖 Generated with Claude Code

Comment on lines +5037 to +5048
using var detachedGate = IsPlanCorrectionCollector(collectorName)
? _detachedCollectorGates.GetOrAdd((runtime.ServerId, collectorName), static _ => new DetachedCollectorGate()).TryAcquire()
: null;

if (IsPlanCorrectionCollector(collectorName) && detachedGate is null)
{
_logger.LogInformation(
" [{Server}] {Collector} skipped this tick — a previous detached run has not finished (#2717). " +
"Re-reads the live set next tick; no rows are lost.",
server.Config.DisplayName, collectorName);
return 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor maintainability nit: IsPlanCorrectionCollector(collectorName) is evaluated twice here — once to decide whether to acquire the gate, once more to decide whether a null result means "busy" vs. "not applicable to this collector." That's the exact ambiguity QueryStoreServerGate.NotGated exists to eliminate a few lines up (its doc comment: "Without it, 'not gated' and 'gate busy' would both be null and every such site would need to re-test the predicate to tell a skip from a pass-through; getting that wrong silently skips a collector nobody meant to gate").

Today's code re-tests the predicate correctly, so there's no live bug. But DetachedCollectorGate explicitly models itself on QueryStoreServerGate ("Same CompareExchange-flag shape... and for the same reason") without adopting its NotGated sentinel, and the doc comment above invites a "future third collector" to extend this condition. If that extension only updates the ternary at line 5037 (to acquire the gate) but not the if at line 5041 (or vice versa), the two checks silently drift apart — reintroducing precisely the failure mode NotGated was built to prevent.

Worth either:

  • hoisting IsPlanCorrectionCollector(collectorName) into a local bool used in both places, or
  • adding a DetachedCollectorGate.NotGated sentinel mirroring QueryStoreServerGate.NotGated, so the check collapses to a single if (detachedGate is null).

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed. C#-only change (no T-SQL touched), scoped to Darling's DarlingWorker.cs plus a new shared PerformanceMonitor.Common/DetachedCollectorGate.cs and its Lite.Tests coverage. The core mechanism (generalizing #2701's detach pattern to a second collector via a keyed gate) is sound and the new gate's unit tests mirror QueryStoreServerGateTests.cs's properties well.

One maintainability nit posted inline on DarlingWorker.cs (duplicate predicate evaluation instead of a NotGated-style sentinel).

Lite/Darling parity note (not a blocker, flagging per review scope): DetachedCollectorGate lives in the shared PerformanceMonitor.Common project but is only wired up in Darling — Lite's RunDueCollectorsAsync (Lite/Services/RemoteCollectorService.cs:401-414) still awaits every due collector, including plan_correction and query_store, inline in the same per-server sequential foreach. Lite runs the identical shared PlanCorrectionCollector/QueryStoreCollector definitions ("the cross-SKU parity contract" per RemoteCollectorService.PlanCorrection.cs's own doc comment) and Lite's own get_collection_health MCP docs cite query_store's bimodal cost as the canonical BODY_OVERRUN example — so the same stall-the-whole-server-sweep risk this PR fixes for Darling appears to exist in Lite too, for both collectors.

That said, #2701 (the original query_store detach) was itself titled "Darling: split query_store..." and never touched Lite, so this PR isn't introducing new drift so much as continuing an existing, apparently deliberate scoping decision. Given Lite's smaller typical fleet size and 1-minute collection interval, the cost/benefit may genuinely differ from Darling's fleet-wide service. Worth a maintainer call on whether that's intentional (e.g., filed as a Lite follow-up) or worth doing now while the generic gate type already exists and is shared.

No correctness bugs, security issues, or T-SQL conventions concerns found (no SQL was touched).

…2717)

plan_correction's own SQL has no further tuning headroom: it's already
correctly seek-based post-#2687, averaging ~1 second. But on a server whose
Query Store carries an abnormally large distinct-plan population (the same
leaflogix-class decimal-parameter-instability signature already root-caused
for query_store elsewhere in the fleet), it can spike to 20+ seconds -
confirmed on one affected server: avg 1057ms, max 21351ms, 97% of that worst
cycle attributable to one database (independently confirmed via
query_store_plan_map: 36,232 distinct plan_ids, 28,260 distinct digests).

That is the identical bimodal shape #2701 detached query_store for, and
plan_correction was never included in that fix - it was still awaited inline
in RunDueCollectorsAsync's sequential foreach, still able to block every
other due collector on an affected server.

Generalizes the fix instead of duplicating it: DetachedCollectorGate is a
collector-agnostic sibling of QueryStoreServerGate, keyed by (server,
collector name) so a future third collector needs only its own IsXCollector
check. QueryStoreServerGate itself is untouched - it has an orthogonal
second job (excluding the separate backfill loop) this type doesn't need to
solve. RunDetachedQueryStoreAsync is renamed RunDetachedAsync and shared by
both detached collectors rather than duplicated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata force-pushed the fix/plan-correction-detach branch from 5f7af14 to ccbc400 Compare August 31, 2026 13:12
Comment on lines +5037 to +5041
using var detachedGate = IsPlanCorrectionCollector(collectorName)
? _detachedCollectorGates.GetOrAdd((runtime.ServerId, collectorName), static _ => new DetachedCollectorGate()).TryAcquire()
: null;

if (IsPlanCorrectionCollector(collectorName) && detachedGate is null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

IsPlanCorrectionCollector(collectorName) is evaluated twice — once to decide whether to acquire, once more here to tell "not gated" apart from "gate busy," since both cases produce detachedGate == null.

QueryStoreServerGate solved this exact ambiguity with a NotGated sentinel (see its doc comment: "Without it, 'not gated' and 'gate busy' would both be null and every such site would need to re-test the predicate to tell a skip from a pass-through; getting that wrong silently skips a collector nobody meant to gate."). DetachedCollectorGate doesn't have an equivalent, so this call site re-implements the exact pattern that sentinel exists to avoid.

It's harmless today because both sites test the same single predicate, but the comment above (and in DetachedCollectorGate's own doc) explicitly anticipates a third detached collector being added here via an ||. Whoever adds it has to remember to update both the ternary and this if with the identical OR-expression — miss one and it either never skips (double-run risk) or skips a collector that was never meant to be gated. Worth either adding a DetachedCollectorGate.NotGated-style sentinel, or at least computing the predicate once into a local and reusing it in both places.

@claude

claude Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Reviewed the C# changes (no T-SQL in this diff). One correctness/maintainability nit posted inline; the item below is a scope question rather than a bug in the code as written.

Lite/Darling parity: plan_correction (and query_store before it) stays undetached in Lite

DetachedCollectorGate and its wiring only touch Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs. Lite's RemoteCollectorService.RunDueCollectorsAsync (Lite/Services/RemoteCollectorService.cs:401-414) has the identical shape this PR is fixing in Darling: servers run in parallel, but collectors within a server run in a strict sequential foreach with a plain await. plan_correction is dispatched through that same loop (RemoteCollectorService.cs:600) with the same shared PlanCorrectionCollector definition — same PerItemWallClockBudget of 120s, same DMV-driven read with no watermark, same BODY_OVERRUN/sweep_pressure diagnostic surfaced via get_collection_health in both apps.

Lite's own QueryStoreServerGate usage (RemoteCollectorService.QueryStore.cs) only excludes the tick from the separate backfill loop (#2165) — it was never wired to detach query_store from the sequential body the way #2701 did for Darling, so this parity gap predates this PR. But Lite isn't single-server-only (README documents Credential Profiles explicitly for "fleet onboarding" in Lite), so the same bimodal-cost/application-class signature that motivated both #2701 and this PR could just as easily block Lite's sequential per-server body and push it into BODY_OVERRUN on a large Lite-monitored fleet.

Is leaving Lite undetached intentional (e.g. scoped strictly to the reported prod incident, or a deliberate "Darling only" architectural call), or should there be a follow-up issue to bring Lite's RunDueCollectorsAsync in line with the same detach pattern for both collectors?

…ia NotGated sentinel

DetachedCollectorGate modeled itself on QueryStoreServerGate's CompareExchange shape
without adopting its NotGated sentinel, so the gate-acquisition site tested
IsPlanCorrectionCollector twice (once in the ternary, once in the following if) to
tell "not gated" apart from "gate busy" — both produce null. Add
DetachedCollectorGate.NotGated mirroring QueryStoreServerGate.NotGated so the check
collapses to a single `if (detachedGate is null)`, matching the adjacent
queryStoreGate idiom in the same method and removing the two-call-sites-must-stay-
in-sync risk the surrounding doc comment already flags for a future third collector.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/// awaiting it in the sequential foreach, single-flighted per server so a still-running previous tick
/// skips rather than overlaps.</para>
///
/// <para><b>#2717 is the second.</b> <c>plan_correction</c> (1-minute cadence, a <c>PerItemWallClockBudget</c>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: this says plan_correction is "1-minute cadence", but its schedule default is 5 minutes (CollectorScheduleDefaults.cs: ["plan_correction"] = new(5, 30)), same tier as query_store — which this same doc comment correctly calls "5-minute cadence" two paragraphs up. Given how much weight this codebase's comments carry as documentation (issue numbers, exact timings), worth fixing so it doesn't mislead a future reader who trusts it over the schedule table.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff (DarlingWorker.cs, new DetachedCollectorGate in PerformanceMonitor.Common, and the new Lite.Tests/DetachedCollectorGateTests.cs).

Correctness — the generalization looks sound. RunOneAsync now applies queryStoreGate and detachedGate independently (mutually exclusive by construction since a collector name can't satisfy both predicates), both are using-scoped so they always release on any exit path (success, early-return, or exception), and DetachedCollectorGate's CompareExchange/Interlocked-release pair correctly mirrors QueryStoreServerGate's never-blocks, idempotent-release shape. The three existing RunOneAsync call sites (on-load freq-0 path, the new RunDetachedAsync, and snapshot_now) all inherit the gate consistently — snapshot_now can still skip a gated collector if a detached tick is in flight, but that's pre-existing behavior for query_store that plan_correction now shares, not a new regression.

Left one inline nit: a doc comment in the new DetachedCollectorGate.cs misstates plan_correction's cadence as 1-minute; it's actually 5-minute per CollectorScheduleDefaults.cs, matching query_store's own tier.

Lite/Darling parity — no drift introduced by this PR specifically. Lite's RemoteCollectorService.RunDueCollectorsAsync still awaits every due collector (including query_store and plan_correction) inline in its per-server sequential loop, with no fire-and-forget/detach equivalent to RunDetachedAsync/DetachedCollectorGate — but that asymmetry predates this PR (it was already true after #2701 detached query_store in Darling only), and Lite's QueryStoreServerGate usage there serves a different purpose (excluding the backfill loop, not detaching from the sequential body). Flagging for awareness in case the BODY_OVERRUN risk this PR addresses in Darling turns out to matter for Lite's single-user desktop workload too, but it doesn't look like something this PR needs to fix.

Security / other — no external input, network, or process boundaries touched; this is pure in-process scheduling/concurrency control. The new test file's ReadRepoFile walks up from [CallerFilePath] to find DarlingWorker.cs and asserts the call site textually (mirroring QueryStoreServerGateTests) — verified both assertions (DetachedCollectorGate.NotGated present once, IsPlanCorrectionCollector(collectorName) present exactly once) are accurate against the current source.

… not 1)

Same tier as query_store, per CollectorScheduleDefaults.cs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff (DetachedCollectorGate.cs, DarlingWorker.cs, DetachedCollectorGateTests.cs). No T-SQL in this PR, so the T-SQL style rules don't apply; the C# is clean and closely mirrors QueryStoreServerGate's proven shape (interlocked flag, idempotent lease, NotGated sentinel, never-blocks TryAcquire) — I compared them side-by-side and didn't find a correctness issue in the gate itself or in its wiring into RunOneAsync/RunDueCollectorsAsync. The NotGated-collapses-the-double-check fix from the second commit is real and the pinned test (Darling_GateAcquisitionTestsThePredicateExactlyOnce) will actually catch a regression there. Numeric/comment claims (120s PerItemWallClockBudget, 5-minute cadence (5, 30) in CollectorScheduleDefaults, PlanCorrectionCollector.Name == "plan_correction") all check out against current source.

One parity question worth a look, not necessarily a blocker:

Lite never got the "detach from the sequential body" treatment at all — for either collector. Lite/Services/RemoteCollectorService.cs:401-413 runs each server's due collectors in a plain sequential foreach (servers run in parallel, collectors within a server do not), and plan_correction dispatches inline there with no gate, same as query_store does. So the underlying bimodal-cost-blocks-siblings problem this PR generalizes for Darling has been present in Lite since #2701 for query_store, and is unaddressed for plan_correction too. Lite.Tests/QueryStoreServerGateTests.cs (the precedent this new test file mirrors) pins wiring in both Lite/Services/RemoteCollectorService.QueryStore.cs and DarlingWorker.cs, but DetachedCollectorGateTests.cs only pins the Darling side — there's no Lite call site to pin, because DetachedCollectorGate (despite living in the shared PerformanceMonitor.Common project) is currently Darling-only.

I don't think this PR needs to fix Lite's copy — it's consistent with how #2701 shipped (Darling-only), and Lite's single/few-server desktop usage is lower-stakes than Darling's unattended fleet loop. But since this PR doubles the number of collectors carrying the fix in Darling while Lite still has zero, it might be worth a tracking issue so the gap doesn't keep widening silently.

Minor: no CHANGELOG.md entry. Every other [Unreleased] entry in that file (#2680-#2699) corresponds 1:1 with a merged fix of this shape; this one doesn't have one yet.

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