Wire the plan fetch's adaptive candidate sizing (#2312 Finding 1) - #2322
Conversation
| shippedBytes += plan.PlanXml is null ? 0L : (long)plan.PlanXml.Length * 2; | ||
| } | ||
| _observedPlanSize[(server.ServerId, databaseName)] = | ||
| QueryStorePlanXmlState.Learn(estimate, shippedBytes, fetched.Count, candidates, budget); |
There was a problem hiding this comment.
fetched.Count includes rows whose PlanXml is null. BuildPlanFetchQuery's own doc (QueryStoreCollector.cs, around line 1166) says NULL query_plan "counts as ZERO bytes and STILL SHIPS, as a row with NULL text" — deliberately, so the watermark can advance past unpersistable plans.
That's fine for the catch-up/window comparison (plansShipped >= candidateWindow legitimately wants the raw row count). But it's also used as the divisor in ObservedAvgPlanBytes(bytesShipped, plansShipped) here. Any pass that ships a mix of real and NULL-XML plans divides real bytes by a count that includes the zero-byte NULL rows, which biases the learned average down.
Per this module's own stated safety rule (FirstContactAvgPlanBytes doc, a few hundred lines up in QueryStorePlanXmlState.cs): "over-estimating plan size yields a SMALL candidate window, and small is the safe direction... one that is too large decompresses plans it will never ship." An average biased low pushes K in the unsafe direction next pass.
Worth at least confirming this is negligible in practice (how common are NULL query_plan rows on the measured fleet?), since forced-plan-failure paths and oversized plans are exactly the cases mentioned as producing NULLs. If it's not negligible, the fix would be to divide by the count of rows that actually carried bytes, while still using the raw fetched.Count for the window/catch-up comparison.
There was a problem hiding this comment.
Right, and in the worst direction — a NULL-inflated divisor understates the average, which INFLATES the next window, exactly what the estimator is built to err away from. 68abe25 splits the counts: plansShipped (raw, the window/catch-up comparison — NULL rows genuinely shipped and the watermark passes them) vs plansMeasured (non-NULL, the average's divisor). The mixed-pass case is pinned (10 rows, 5 with XML, 500K bytes → 100K average, not 50K), and the all-NULL pass now flows plansMeasured: 0 so the previous average stands via ObservedAvgPlanBytes' existing null.
|
Reviewed. Traced the One doc issue outside the diff hunk, so noting it here instead of inline: the XML doc on Lite/Darling parity: no drift — confirmed Lite has no plan-XML fetch path ( No SQL changes in this PR (the query text/parameterization in |
|
Reviewed the diff ( Correctness —
Parity — confirmed Lite has no Security/perf — no new input-trust boundaries, no injection surface touched; the added One thing I couldn't do in this sandbox: run |
What
Finding 1 from today's #2312 diagnosis:
QueryStorePlanXmlState.CandidatePlanCountcarries a complete adaptive design — learn each database's real average plan size from its own shipped passes, with a catch-up floor for the window where the sample is provably biased small — and the single call site passedobservedAvgPlanBytes: null. Every pass on every database sized its decompression window from the 160KB first-contact seed: K ≈ 116, always, despite the 11x fleet spread the design documents.How
QueryStorePlanXmlState.Learn(pure, pinned): folds one pass's outcome into a carriedPlanSizeEstimate. Empty pass → catch-up clears, average stands (the walk is provably caught up). Pass cut by either bound (window consumed OR byte budget reached —>=because the budget predicate admits the plan that crosses the line) → catch-up sets. Ordinary pass → learns its average, clears catch-up. All-NULL-plans pass keeps the previous average (ObservedAvgPlanBytes yields null).PlanXml.Length * 2— DATALENGTH of nvarchar exactly), learned BEFORE the empty-pass early return so the caught-up signal isn't lost.Effect
Measured with the shipped math: a genuine 15KB-average database re-sizes from K=116 to K=1,259 and walks its plan backlog ~10x faster per pass; during catch-up the floor pins K back at seed size (the biased-sample guard working as documented). Verified locally 7/7 via a net10.0 harness including the downstream CandidatePlanCount effect;
QueryStorePlanSizeLearnTestspins the fold's decision table in CI.Darling-only: Lite runs no separate plan fetch. Finding 2 (activity-driven fetch — the structural fix) is design-gated on the issue.
🤖 Generated with Claude Code