Repository navigation
Fixes #2902 - #2906
Merged
Merged
Fixes #2902#2906
Conversation
#2902) The two carryover dictionaries #2312's activity-driven fetch defers budget-capped ids into were keyed by (ServerId, Database) with no collector in the key, while the fetch that spends them is gated on a SKU flag -- CapturePlanXml for plans, FetchQueryTextSeparately for text -- rather than on a collector identity. Five collectors take the enumerated dispatch path off Azure (query_store, plan_correction, query_store_health, index_object_stats, database_scoped_config), and only query_store can ever CREATE debt there, because ExtractPlanReferences and its text twin return empty for any other collector's batch. So the other four arrived having referenced nothing, found query_store's deferred ids under the key they happened to share, drained them, and had the fetch's wall clock recorded against their own collection_log row: ~346s of Query Store fetch time over 38h on one monitoring host, billed to plan_correction, query_store_health and index_object_stats. The draining was never intentional. FetchAndStorePlansAsync is documented as "the activity-driven plan-XML fetch for one database"; its call site says "this fetch is Query-Store-only"; and ExtractPlanReferences' own doc asserted that a non-query_store batch reaching it "never actually happens" because of the CapturePlanXml gate -- a belief that was false on every cycle, and that comment is corrected here. #2333's honesty notes name every deliberate trade the carryover makes (process-memory, restart forgets, the plan-dimension growth bend) and cross-collector draining is not among them. Both fields landed in one commit (8c79f0c) with the key shape copied from _consecutiveQueryStoreItemFailures, which is per-database for the same reason and is safe that way only because every one of its call sites sits behind an explicit definition.Name == query_store check. The shape was inherited; the guard was not. carryKey is one variable feeding four per-(server, database) fetch-state dictionaries, so all four widen together rather than two of them growing a near-identical sibling key: _observedPlanSize's learned plan size and backoff count and _textFetchFailures were also being reset by those foreign visits on their nothing-owed return path. Behaviour is unchanged for them beyond that, since after this only query_store reaches a fetch that reads or writes any of the four. FetchStateKey exists as a method rather than an inline literal so the pin drives the shipped construction -- an inline literal at each site is how the collector went missing. What this does not change: no work is saved and none is moved. The same ids are fetched and the same bytes ship; query_store's own measured cost rises by roughly what its siblings stop being charged. It does change when the debt is paid -- query_store's next visit rather than the first of five, at most one 5-minute cycle later, since query_store and plan_correction are both on the 5-minute tier while query_store_health is hourly, index_object_stats daily and database_scoped_config on-load. There is no cap and no eviction on the debt, so halving the 5-minute-tier drain rate makes an existing capacity question visible rather than creating one. PlanFetchCarryoverKeyTests pins the behaviour, not the shape: debt written under one collector's key is invisible under any other's, still visible under its own, and still separated by server and database. The family is derived by invoking the definitions' own RunsPerDatabase/BuildEnumerationQuery over CollectorCatalog.All, with a non-vacuity floor; the artifacts under test are the dictionaries' own declared key type and the runner's own key builder, both reached by reflection so a key-shape change goes red instead of failing to compile; and #2898's shared IL decoder proves both fetch state machines call the builder, with the probe calls as positive controls checked first. Each of the five assertions was proven red by its own mutation of the shipped source. No schema change: the per-phase persistence rung is #2860's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed the diff (CHANGELOG.md, Correctness
Lite/Darling parity
Tests
Style
Nothing to flag — this looks like a solid, well-scoped attribution fix with good test coverage for the failure mode it's closing. |
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2902.
The design question first: (a), the keying is the bug — not (b), the accounting
The issue asked whether cross-collector draining is deliberate (in which case the defect is the
attribution, a bigger change) or accidental. It is accidental, and the evidence is in the tree:
FetchAndStorePlansAsync's own doc calls it "the activity-driven plan-XML fetch for onedatabase" (query_store collector costs 40-110s per run on multi-tenant primaries — the read has no interval watermark, so every cycle pays for the whole window #2312 Finding 2). No collector-sharing contract is stated or implied.
the type the signature needs AND gates the engine in one expression".
ExtractPlanReferences' doc records the mistaken belief directly: "any batch that is notquery_store rows extracts nothing, and the caller's
CapturePlanXmlgate means that never actuallyhappens." It happens on every cycle —
CapturePlanXmlis a SKU flag set once per cycle from_capturePlans(), so it gates the SKU, not the collector. That comment is corrected in this PR.restart forgets the debt, the plan-dimension growth bend — and cross-collector draining is not among
them. In a PR that documents its trades that carefully, an intentional shared queue would have been
named.
git blame: both fields landed in one commit,8c79f0cb(query_store plan/text fetch goes activity-driven: the store is the watermark (#2312) #2333), with the key shape copied from_consecutiveQueryStoreItemFailures— per-DATABASEfor the same reason, and safe that way onlybecause every one of its call sites sits behind an explicit
definition.Name == query_storecheck.The shape was inherited; the guard was not.
with no pin, in this codebase, is not plausible.
QueryStoreCollector.Instance.BuildPlanFetchByIdsQuerywhichever collector isexecuting. A shared queue would not be built out of one collector's singleton.
Nothing was found on the (b) side beyond the abstract "debt paid sooner is better", which no comment
makes.
Two corrections to the issue
CapturePlanXmlat all.FetchAndStoreQueryTextAsyncis gated oncontext.FetchQueryTextSeparately, which the cycle context sets to a hardcodedtrue([BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150). Sotext carryover was drained cross-collector even with
capturePlansoff. The plan side is as the issuedescribes.
database_scoped_configalso reaches the fetch; it does not appearin the measurement because it is on-load only (
FrequencyMinutes 0), so it almost never arrives whiledebt is outstanding. The three the log caught are exactly the three that run on a cadence.
The fix
carryKeyis one variable feeding four per-(server, database)fetch-state dictionaries, so all fourwiden together rather than two of them growing a near-identical sibling key:
_planFetchCarryover,_textFetchCarryover— the deferred-id debt (the issue's subject)._observedPlanSize(learned plan size +ConsecutiveFetchFailures) and_textFetchFailures— whoseper-database counters those same foreign visits were also resetting, on the nothing-owed return
path that calls
ClearPlanFetchBackoff/TryRemovebefore returning. Behaviour is otherwiseunchanged for them, because after this only
query_storereaches a fetch that reads or writes any ofthe four.
FetchStateKeyis a method rather than an inline tuple literal so the pin drives the shippedconstruction — an inline literal at each site is how the collector went missing in the first place.
No schema change, no migration: the per-phase persistence rung is #2860's and comes after this.
What this does and does not change
It saves no work and moves none. The same ids are still fetched and the same bytes still ship;
query_store's own measured cost rises by roughly what its siblings stop being charged. It is anattribution fix, worth making because #2847, #2841 and #2860 all read
collection_logper collector, andthis moved time between collectors and between cadence tiers in the series they reason from.
Deferral latency. Debt now waits for
query_store's next visit rather than the first of five. At theCollectorScheduleDefaultscadences that is at most one 5-minute cycle:query_storeandplan_correctionare both on the 5-minute tier, whilequery_store_healthis hourly,index_object_statsdaily anddatabase_scoped_configon-load. So the drain opportunities thatmaterially existed were the two 5-minute ones, and this roughly halves them.
The delivered cadences say the same thing and are the ones that matter, since the sweep runs well behind nominal at fleet size. Two days of the tool's own per-collector aggregate on 42 servers:
query_store15,485 runs andplan_correction15,586 — 7.81 and 7.76 minutes per server per run, effectively identical — againstquery_store_health's 1,731 (69.9 min) andindex_object_stats' 45 (~44 h). Summing per-minute drain opportunities, this drops them by ~52% and adds a median wait of about 4 minutes, bounded by onequery_storecycle. Nothing downstream reads the debt: it is process-memory with no reader outside these two methods, and the plan XML it eventually lands is an enrichment the readers already fall back from.Eviction: there is none, and that is worth stating plainly.
_planFetchCarryover[carryKey] = owedstores the whole remaining set. Ids leave only by landing, by proving target-side-gone on a provably
uncut pass, or with the process. No count cap, no byte cap, no age-out. The declared bound is the field's
own doc — "ids are 8 bytes and a first-contact backlog is one catalog's worth" — which is a ceiling, not
a cap. So halving the 5-minute-tier drain rate makes an existing capacity question visible rather
than creating one: if
query_store's own candidate cap cannot cover what its probe finds missing, thebacklog grows, and that was already true with
plan_correction's free passes masking it. #2860'spersisted phase columns (
ids attemptedagainstprobe idsperquery_storerun) are the instrument towatch it with; nothing else can see it today.
The pin
PlanFetchCarryoverKeyTests, five assertions, behavioural rather than shape — "the key has three fields"would pass for a key carrying a collection time, which fixes nothing and drops every deferred id.
RunsPerDatabase/BuildEnumerationQueryover
CollectorCatalog.All, in the shapeServerWatermarkDispatchGateTestsestablished. The only namewritten by hand is the owner, and that is structural (the fetch runs
QueryStoreCollector.Instance'sbuilders regardless of who executes) — and it is itself checked, since the owner must be a member of
the derived family.
FieldInfo.FieldType) and the runner's own key builder, both reached by reflection so that akey-shape change goes red instead of failing to compile. A pin that stops compiling when the thing it
guards changes is worth nothing.
BothFetchBodies_BuildTheirKeyThroughTheOneBuilder...closes the tautology hole: it reads the builtassembly's IL on The IL-scan idiom in four reachability pins is unsound: an opcode-shaped byte scan that skips, and a token map that cannot see generic callees #2898's shared decoder, per state machine, with
TouchAndProbePlansAsync/TouchAndProbeTextsAsyncas positive controls checked first, and asserts each of<FetchAndStorePlansAsync>and<FetchAndStoreQueryTextAsync>callsFetchStateKey.Red-first, five variants, five different assertions, each a mutation of the shipped source, rebuilt
and re-run:
(ServerId, Database)NoOtherCollectorOnTheFetchPath_CanDrainTheOwnersDeferredIds— "database_scoped_config finds query_store's deferred ids in _planFetchCarryover under its own key"collectorName + Guid)TheOwningCollector_StillFindsItsOwnDeferredIds...— "query_store cannot find the ids it just deferred"TheKeyStillSeparatesServersAndDatabases...— "Two databases on one server share a _planFetchCarryover entry"BothFetchBodies_BuildTheirKeyThroughTheOneBuilder...— "<FetchAndStoreQueryTextAsync>never calls FetchStateKey"query_store_healthstops enumeratingTheFetchPathFamily_HoldsTheOwnerAndSeveralOthers...— "Only 4 collectors reach the plan/text fetch"Verification, and its scope
PerformanceMonitor.Darling.Service.dll: all four fields now key onValueTuple\3<Int32,String,String>with tuple element names[ServerId, Database, Collector], and both fetch methods carry acollectorName` parameter. Proven non-stale by a control: applying therevert and rebuilding flips the reflected arity to 2 and changes the MVID; restoring flips it back.
-p:EnableWindowsTargeting=true), includingDarling.Tests.IlCallSiteScanner.csinto a throwawaynet10.0xunit host withAssemblyName=Darling.TestssoInternalsVisibleToapplies: 5 passed, 0 failed.prove the call site passes the cycle's collector rather than a constant — only a sweep can, and the
test class says so in its own scope note. The ~346 s figure is the issue's log parse, not re-derived.
Corroboration from the store, independent of the log parse
Two days of the tool's own per-collector aggregate on the 42-server fleet says the same thing from the
other side:
query_store_healthreads one cheap row per database per hour and averages 304 ms over1,731 runs — with a 72,454 ms worst execution in the same window. A 238x tail on a collector whose
own read cannot produce one.
database_scoped_config, which is on-load only and so rarely arrives whiledebt is outstanding, tops out at 562 ms.
🤖 Generated with Claude Code