Skip to content

Darling: split query_store off the sequential per-server collection body - #2701

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/query-store-body-overrun
Aug 30, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/query-store-body-overrun

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2700.

Problem

`RunDueCollectorsAsync` runs every due collector for a server sequentially in one `foreach`, awaited inline, inside `ProcessServerSweepAsync`. The outer launch loop will not relaunch a server's sweep body while the previous one is still running (INV-2, "one body per server").

`query_store`'s per-run duration is bimodal: mean ~5-35s, but a heavy batch runs 100-230+ seconds. Because it was awaited inline in the same sequential foreach as every other due collector (query_stats, procedure_stats, wait_stats — all 1-minute cadence), and because the whole body can't relaunch until it returns, a single heavy query_store run stalled that server's ENTIRE collection for its full duration, not just its own row. On use1 today this pushed 11 servers' `last_collection` stale enough to false-trip the fleet's 15-minute Offline threshold, with zero actual collector failures — confirmed per-server via `get_collection_health`'s existing `BODY_OVERRUN` sweep-pressure diagnostic, which already names the fix in its own generated text: "moving or splitting that collector so it stops sharing a cycle."

Fix

`query_store` is now fired detached rather than awaited inline. It reuses the existing per-server `QueryStoreServerGate` (#2165) for mutual exclusion — a still-in-flight previous tick skips rather than overlapping — and its window is watermark-driven (#1960), so a detached run that outlives the sweep resumes correctly from its own boundary on the next tick. Every other due collector in the foreach now runs on schedule regardless of how long query_store takes, and the body itself returns promptly so the outer launch loop keeps relaunching normally.

A small RunDetachedQueryStoreAsync wrapper exists only to keep a shutdown-time OperationCanceledException from surfacing as an unobserved task exception — RunOneAsync's own catch-all already contains every other fault.

Test plan

  • dotnet build (project + full solution) clean, 0 errors
  • Could not run Darling.Tests locally (this Mac lacks the WindowsDesktop runtime the net10.0-windows TFM needs) — relying on CI
  • Per this file's own documented testing philosophy (top of DarlingSweepSchedulingTests.cs): the stateful scheduling body has heavy dependencies and is asserted by full-diff review rather than unit tests; the change is a minimal, surgical inline-await → detached-fire-and-forget swap reusing an already-tested gate, not new scheduling logic.

🤖 Generated with Claude Code

…ody (#2700)

A heavy query_store run (100-230+s on a leaflogix-driven batch, vs. a ~5-35s
mean) was awaited inline in RunDueCollectorsAsync's sequential foreach. Since
the outer launch loop will not relaunch a server's sweep body while it is
still running (INV-2), one slow query_store run stalled that server's ENTIRE
collection - every other due collector, not just query_store's own row - for
its full duration. That pushed several servers' last_collection stale enough
to false-trip the fleet's 15-minute Offline threshold with zero actual
collector failures (confirmed via get_collection_health's BODY_OVERRUN
diagnostic, which already names "moving or splitting that collector so it
stops sharing a cycle" as the fix).

query_store now runs detached, reusing the existing per-server
QueryStoreServerGate (#2165) so a still-in-flight previous tick skips rather
than overlapping. Its window is watermark-driven (#1960), so a run that
outlives the sweep resumes correctly on its own gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OperationCanceledException from surfacing as an unobserved task exception. */
if (IsQueryStoreCollector(name))
{
_ = RunDetachedQueryStoreAsync(server, runner, name, cancellationToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The detached query_store task is invisible to the #1553 shutdown drain.

Every other fire-and-track unit in this file is deliberately handed to something shutdown awaits:

  • server.InFlightSweep is drained via Task.WhenAny(Task.WhenAll(inFlightSweeps), Task.Delay(s_shutdownDrainBudget, ...)) (~line 1582).
  • The Query Store backfill loop is drained with await backfillLoop; (~line 1604).
  • The baseline backfill task is drained the same way (~line 1611).

_ = RunDetachedQueryStoreAsync(...) here is the only fire-and-forget in the file that isn't assigned to anything a shutdown path awaits. Because RunDueCollectorsAsync (and therefore ProcessServerSweepAsync/InFlightSweep) now returns as soon as it fires this off instead of waiting on it, the shutdown drain's Task.WhenAll(inFlightSweeps) resolves almost immediately — it no longer represents "is this server's collection still running." ExecuteAsync can return, and the host can proceed to tear down _postgres and other shared resources, while a 100–230s query_store run is still mid-flight and still calling DarlingObservability.LogCollectionAsync(_postgres!, ...).

RunDetachedQueryStoreAsync's own catch (OperationCanceledException) only fires once the underlying command actually reacts to cancellationToken cancellation, which takes real time — time the drain budget existed to provide, and which this task no longer gets on the shutdown path (only what remains of the process/host lifetime).

Given the codebase's own established convention for every other fire-and-track task, this looks like a real gap rather than an intentional exception: consider tracking these detached tasks (e.g., a ConcurrentBag/list per server or fleet-wide, drained the same bounded way as inFlightSweeps) so shutdown still gets a chance to let an in-flight query_store run wind down before storage is torn down.

@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewed the diff (single file: Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs; this is Darling-only fleet-scheduling code, so no Lite counterpart applies here — Lite's CollectionBackgroundService doesn't have this per-server sequential-sweep-body architecture at all, so there's nothing to keep in parity for this change).

Main finding (posted inline): the new _ = RunDetachedQueryStoreAsync(...) fire-and-forget isn't tracked by the shutdown drain. Every other fire-and-track task in this file (InFlightSweep, the Query Store backfill loop, the baseline backfill task) is assigned to something the #1553 shutdown path explicitly awaits with a bounded budget. This one isn't assigned to anything, and because RunDueCollectorsAsync now returns as soon as it fires the detached task instead of awaiting it, InFlightSweep completing no longer means "this server's collection is done" — the shutdown drain can resolve near-instantly while a 100–230s query_store run is still writing to _postgres in the background.

Things I checked and consider fine:

  • QueryStoreServerGate mutual exclusion ([PERF] Tick QS collection and QS backfill run concurrently against the same server — serialize per server #2165) correctly prevents the detached tick run and a concurrent snapshot_now/backfill run from overlapping against the same server — the interlocked TryAcquire is a real mutex, not just advisory.
  • Each collector run opens its own connection from ConnectionString (provider.CreateConnection(...)) rather than sharing a live connection object, so running query_store concurrently with the next tick's other due collectors doesn't corrupt a shared SqlConnection/NpgsqlConnection.
  • server.NextDue writes only happen on the synchronous sweep-body thread (never inside the detached task), so no data race there.

Lower-confidence note, not filed as a blocking finding: since RunDueCollectorsAsync can now return (and "one body per server" relaunch) while a previous tick's detached query_store is still in flight, server.Runtime = null (set from RunOneAsync's connection-fatal catch, ~line 4793) can now be written concurrently with a later tick's sweep body that's mid-loop against the same server. Each sweep body captures its own runtime local up front, so this doesn't corrupt in-progress work directly, but it does widen an existing eventual-consistency window (stale-runtime usage / delayed reconnect detection) from "duration of one sweep body" to potentially "duration of a lingering query_store run." Worth a second look, but I'm not confident it's a practical bug given how much of this scheduler already tolerates this kind of raciness by design.

No T-SQL in this diff, so the CONTRIBUTING.md T-SQL style items don't apply.

@erikdarlingdata
erikdarlingdata merged commit 85446a0 into dev Aug 30, 2026
6 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/query-store-body-overrun branch August 30, 2026 21:12
erikdarlingdata added a commit that referenced this pull request Aug 31, 2026
…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 added a commit that referenced this pull request Aug 31, 2026
Detach plan_correction from the sequential body, generalizing #2701
pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Sep 10, 2026
…ird-party name from two comments

erikdarlingdata#2490's guard matches only the full hostname SHAPE, so three references that
are not hostname-shaped survived erikdarlingdata#2900's pass.

CHANGELOG.md's erikdarlingdata#2699 server list carried two bare short names whose slugs are
not on the allowlist. Both now read allowlisted slugs that named no other
server anywhere in the tree, so neither swap merges two distinct identities:
epsilon-01 (epsilon occurred exactly once before this change, in the allowlist
declaration itself) and dummy-01 (dummy occurred only as the adjective -
"dummy SQL", "dummy 0-columns", "dummy test data").

DarlingWorker's plan_correction note and DetachedCollectorGate's erikdarlingdata#2701
paragraph used a third-party product name as a workload descriptor. Both now
read "workload-class", which keeps each signature named by its own mechanism
(distinct-plan-population, decimal-parameter-instability) rather than by a
vendor. Every measurement in both comments is unchanged.

apex is untouched everywhere else: it is this codebase's term for a blocking
chain's head blocker (~150 uses), and the fleet's "apex box"/"apex replica"
superlative. Only the apex-01 in that one server list was an identifier.

No schema change, no migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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