Repository navigation
Part of #2862 - #2915
Part of #2862#2915
Conversation
procedure_stats is the most expensive collector on the production us-east-1 store - 129.9M ms of target-side duration over 32,093 runs in 24 h, and 66% of the entire 1-minute collection body on a sampled sweep (4,735 ms of 7,221 ms) - and almost all of it is the read loop draining plan XML. A controlled decomposition split that read loop into RENDER 73.8% / TRANSFER 26.0%, against 0.2% for the same query with no plan apply at all. The render happens inside sys.dm_exec_text_query_plan, a SERVER-side TVF, so three quarters of the cost is CPU burned on the monitored production server - which is why the lever is cadence rather than a dedup key: hashing at the source removes the transfer and leaves the render on customer hardware. A plan_handle-keyed probe is separately disproven (8.5 distinct plan XMLs per handle per day). The schedule is untouched. #2843 pinned that no detached collector sits on the 1-minute tier, because a detached run skips while its predecessor is still in flight, so moving this collector off that tier would convert starvation into guaranteed misses. Gating the render instead keeps runtime statistics at full 1-minute resolution and amortises only the expensive half. New file-only darling.json knob procedureStatsPlanCycleInterval (default 4, clamped [1,60], read through a live provider). NO schema rung: it follows collectSchemaChangeEvents, the established file-only-knob precedent, and StoreConfigProvider.ApplyToConfig mutates the held config field by field, so a value absent from the store view survives a reload untouched. 1 is byte-identical to the old collector. The phase derives from server_id so the fleet staggers instead of spiking together - [10, 10, 11, 11] shipped against [42, 0, 0, 0] with a bare modulo, reflected out of the built assembly. That is also what makes the in-memory counter safe across a fleet-wide restart, so no state is persisted and collect.collector_state is not needed. No reader changes: the #1767 guard already skips NULL-plan rows and returns the latest row that has a plan. Modelled against #2849's cadence equation the change alone takes us-east-1 from ~113 s to ~82 s and cannot reach 60 s at max_concurrent_sweeps = 4, where the floor is ~72 s even at an infinite interval. The tail finding in #2862 is untouched and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| /// <summary> | ||
| /// The instance side of the #2862 cadence: advances this (server, collector) cycle counter and asks the | ||
| /// pure policy. Returns true unconditionally for every collector except | ||
| /// <see cref="PlanCadenceGatedCollector"/>, so no other collector's behaviour changes and no other | ||
| /// collector's counter is even allocated. | ||
| /// </summary> | ||
| /// <summary> | ||
| /// The complete plan-capture decision for one collector on one server on this cycle: the SKU flag | ||
| /// AND the #2862 cadence gate. This is what <see cref="CollectorContext.CapturePlanXml"/> is set from, | ||
| /// and it exists as ONE named seam rather than as a <c>&&</c> inside the context initializer so the | ||
| /// decision is reachable from a test — a bare conjunction there is droppable by a refactor with every | ||
| /// pin still green, which is the failure mode this shape removes. | ||
| /// </summary> |
There was a problem hiding this comment.
Two stacked <summary> blocks are attached to ShouldCapturePlanXmlFor here, and only the second one actually describes it. The first block ("advances this (server, collector) cycle counter and asks the pure policy") describes what ShouldCapturePlanForCollector does below — ShouldCapturePlanXmlFor itself never touches _planCadenceCycles, it just ANDs the SKU flag with the private helper. Looks like a doc comment meant for ShouldCapturePlanForCollector (which currently has none) got left dangling above the correct one instead of being moved down. Worth moving the first <summary> onto ShouldCapturePlanForCollector at line 397.
| /// a change to the source only and not to the collector seam.</para> | ||
| /// </summary> | ||
| [JsonPropertyName("procedureStatsPlanCycleInterval")] | ||
| public int ProcedureStatsPlanCycleInterval { get; set; } = 4; |
There was a problem hiding this comment.
This follows collectSchemaChangeEvents as a file-only knob per the PR description, but that precedent also got a darling.sample.json entry (with an explanatory comment, currently lines 145-152) and a Darling/README.md config-reference row (### collectSchemaChangeEvents, README.md:420-424). procedureStatsPlanCycleInterval has neither — an operator reading the sample config or the README today has no way to discover this knob exists. Worth adding both to match the established pattern for file-only knobs.
|
Reviewed the plan-cadence-gating change (
Left two inline notes:
No correctness, security, or Lite/Darling parity issues beyond those two. |
…ple.json DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks went red on DarlingCollectorRunner.cs:381 - inserting ShouldCapturePlanXmlFor pushed ShouldCapturePlanForCollector's own summary off it, leaving two stacked blocks on one member. Fixed the way that test's own message directs: NOT by deleting the first summary (it belongs to a different member) but by moving the new member above it, so the displaced block is reunited with the method it documents. Also adds procedureStatsPlanCycleInterval to darling.sample.json. collectSchemaChangeEvents - the file-only-knob precedent this follows - is documented there, while the store-backed knobs (planContentRetentionDays, queryStoreTextBudgetMb, composeStatementTimeoutSeconds) are not, so a file-only knob belongs in the sample. Validated with the loader's own JsonCommentHandling.Skip + AllowTrailingCommas options. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second half of the review bot's documentation note. collectSchemaChangeEvents - the file-only-knob precedent this follows - carries both a darling.sample.json comment and a Darling/README.md config-reference row; the previous commit added the first, this adds the second, so an operator reading either can discover the knob exists. Records what the knob trades (plan freshness, bounded by the collector's own ten-minute candidate window) and what it does not (runtime statistics, still collected every cycle on the unchanged 1-minute schedule), plus the 73.8/26.0/0.2 decomposition that motivates it and the fact that only procedure_stats is gated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My CHANGELOG entry cites [#2849] for the cadence model, but that issue had no link-reference line, so it rendered as literal text rather than a link. [#2862] and [#2843] were added with the entry; this is the third. Note for whoever touches that block next: [#2860], [#828] and [#887] each have DUPLICATE reference lines, and those predate this branch (confirmed against origin/dev). Left alone - #2889 is rewriting the block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed the diff (CHANGELOG, Correctness
Lite/Darling parity
Security
Performance
Style
No blocking findings. This is a tightly-scoped, well-tested change (mutation-tested per the PR description) and the parity/no-schema-change claims all check out against the code. |
|
Reviewed the diff (all C#, no T-SQL in this PR). Traced the load-bearing claims in the PR description against the actual code rather than taking them at face value:
Lite/Darling parity: no drift. Security / correctness boundaries: no new external input handling — the only new input is a JSON int clamped Performance: this is the intended improvement; the added Didn't find any correctness bugs, parity gaps, or security issues. The test suite ( |
Part of #2862 — the median finding only. The tail finding (the 120 s stall that is not capacity-shaped) is untouched and #2862 should stay open for it.
What changed
procedure_statsrenders execution-plan XML on one cycle in N instead of on every cycle. The collector stays on its 1-minute schedule, so runtime statistics keep full resolution; only the expensive half is amortised.The schedule is deliberately not touched. #2843 pinned that no detached collector sits on the 1-minute tier, because a detached run skips while its predecessor is still in flight — so moving this collector off that tier converts starvation into guaranteed misses. And changing its default frequency is a
CollectorScheduleDefaultschange, duplicated into Lite'sScheduleManager.s_presets, needing the parity treatment that PR CI would not catch. Gating the render avoids both.Why cadence, and not a dedup key
sys.dm_exec_text_query_planis a server-side TVF, so the render is CPU on the monitored production server. Hashing at the source would remove the 26% transfer and leave the 74% render burning on customer hardware. The full A/B/C decomposition, the disproof of theplan_handleprobe (8.5 distinct plan XMLs per handle per day), and the ABANDONED-cycle evidence are in #2862.No schema rung — and how that was confirmed rather than assumed
The knob is
procedureStatsPlanCycleIntervalindarling.json, default 4, clamped [1,60], read through a live provider. No migration, noStorageVersionbump, noconfig_servicecolumn. Two things make that sound:collectSchemaChangeEvents, which is already a file-only knob read through a live provider — the established precedent, named as such in its own doc comment.StoreConfigProvider.ApplyToConfigmutates the heldDarlingConfigfield by field. A property absent fromStoreConfigViewis never written, so a control-plane reload cannot clobber the file value. Verified by reading the method, not inferred.The trade-off, stated plainly: a change needs a service restart rather than taking effect live. Promoting it to a store column later is a change to the source only — the runner already reads it through
Func<int>, so the collector seam does not move.The gating state also needs no storage. The cycle counter is an in-memory
ConcurrentDictionarykeyed(ServerId, Collector).collect.collector_statewas considered and rejected: persisting the counter buys nothing, because the fleet stagger comes fromserver_idand not from accumulated drift, and it would cost a store write per server per cycle on the hot path.Design decisions worth reviewing
The phase derives from
server_id, and it is load-bearing. A bareordinal % intervalputs all 42 servers on the same capture cycle — three cheap cycles, then the whole fleet paying full render cost at once. That is a 4x spike, and peak is exactly what produces the 120 sPerItemWallClockBudgetabandonments, so the naive form would be worse than collecting every time. Shipped[10, 10, 11, 11]against the counterfactual[42, 0, 0, 0], both reflected out of the built assembly.That is also what makes the in-memory counter safe. A fleet-wide restart re-enters every server at ordinal 0 simultaneously, and the server-derived phase still spreads them across the interval. Pinned as the observable property (how much of the fleet captures on the first cycle back), not by calling a pure function twice.
The gated cycle omits the
OUTER APPLYentirely rather than rendering and discarding — the difference between the measured 0.2% floor and paying the whole 73.8%. It rides the existingCollectorContext.CapturePlanXmlseam, which erases both plan placeholders, and it is pinned on the SQL the collector actually builds.The wiring is one named seam, not a bare
&&.ShouldCapturePlanXmlForcomposes the SKU flag and the cadence gate, andCollectorContext.CapturePlanXmlis assigned from it. A conjunction inside an object initializer is droppable by a refactor with every pure-policy pin still green; that failure mode was proven red before this shape was chosen.Only the storing path is gated.
FetchRowsAsync— the on-demand live fetch an operator asked for by name, which writes nothing — still renders unconditionally.Why N = 4, derived rather than picked. Two bounds meet there:
(N-1)/N. N=4 captures 75% of the achievable saving; N=8 captures 87.5% for double the staleness, and moves modelled cadence only ~82 s → ~77 s against ~113 s → ~82 s for going 1 → 4.s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()). Ifinterval x cadenceexceeds that 10-minute window, a module can enter and leave the candidate set between two captures and never have its plan rendered. At N=4 and ~82 s that is 5.5 min, comfortably inside; at N=8 and ~77 s it is 10.3 min, past it. So the knob is capped near 7 by design, independent of taste, and 4 leaves margin for cadence regression.The first capture after a gap is not more expensive. The
TOP (150)candidate set is already saturated on the servers that matter: two separate heavy servers reportedrows_collected = 150on every procedure_stats run sampled (8 consecutive runs on one, 3 on the other), and the fleet mean is 140.9 rows against the 150 cap. So a skipped cycle cannot enlarge the set. Stated precisely because the fleet mean is not 150 — some lighter servers return fewer, and for those the saving is smaller but the gap is also not a risk.The cost paid, and the consumers checked
Plan-data granularity: worst-case plan age goes from ~1.9 min to ~5.5 min. Runtime statistics are unaffected — collected every cycle as before.
ViewerDataService.Plans.csandMcp/DarlingStoredPlanReader.csboth carry the query_text/query_plan_xml stored inline per row: 94% of a field store — normalize into hash-keyed dimension tables (~135x measured) #1767 guardAND (COALESCE(ps.query_plan_xml, qpd.query_plan_xml) IS NOT NULL OR qpd.query_plan_gz IS NOT NULL) ORDER BY ps.collection_time DESC LIMIT 1— they skip NULL-plan rows and return the latest row that has a plan, with no time bound, so a skipped cycle is invisible. This is whatanalyze_procedure_planreads through.PgCollectorRowWriter.Valueshort-circuits beforePayloadDimensions.Digest: "A NULL payload stays NULL — there is nothing to dedupe and no dim row to point at."ViewerDataService.ProcedureStats.cscomputesbool_or(query_plan_xml IS NOT NULL OR query_plan_digest IS NOT NULL) AS has_query_planover the grid's time window to gate the per-row Download button. Its own comment says it matches whatGetProcedureStatsPlanXmlAsyncfetches on, but that fetch has no time bound while this flag does — so the flag is the conservative one, and on a grid window shorter thaninterval x cadence(~5.5 min at N=4) it can read false for an object whose plan the fetch would still find. Pre-existing asymmetry, made marginally more reachable; not addressed here.PgFactCollector.QueryPerf.cs'sProcedureStatsSqluses onlydelta_*runtime columns — no plan columns — so the analysis layer is unaffected. Read only; that file belongs to another lane.Verification
Darling.Testscannot execute on macOS, so the pin's real source was compiled into a throwawaynet10.0xunit host (assembly-namedDarling.Teststo reachInternalsVisibleTo) referencing the built DLLs, not the projects. 17/17 green.Every pin was proven red first, each variant failing a different assertion:
TheInstanceGate_LetsProcedureStatsCaptureExactlyOneCycleInNExactlyOneCycleInN_Capturesserver_idphase droppedTheFleetIsStaggered.../...StaggersTwoServersOntoDifferentCyclesTheKnobClampsToItsDocumentedRange...AGatedCycle_OmitsThePlanApplyEntirely...DoesNotContain— sub-string foundTheShippedDefault_IsFourCyclesTwo method notes, because one of them nearly produced a false result: the first mutation attempt silently failed to apply (a CRLF anchor mismatch) and the suite reported green — a failed mutation reads exactly like a passing test, so each mutation now asserts its anchor count as a positive control before rewriting. And
touch+ rebuild is not a staleness control: .NET builds deterministically, so the MVID was unchanged. The real control mutates content — MVIDaa774a98…→ee078864…withMaxProcedureStatsPlanCycleIntervalreading 61, then both returning on restore.Reflected off the built assembly:
PlanCadenceGatedCollector = procedure_stats, clamps[1,60]withclamp(0) = clamp(int.MinValue) = 1,DarlingConfigdefault4, fleet-of-42 distribution[10, 10, 11, 11].Measured, and what is only modelled
Fresh from
get_collector_cost, last 24 h on us-east-1:procedure_stats129.9M ms over 32,093 runs, avg 4,048 ms, 42 servers — the top collector, 2.4x the next. One correction to the record while I am here: it is not true that both fleets sit at the 150 cap. us-east-1's mean is 140.9 rows/run; us-east-2's is 71.2. #2847's original wording had this right ("pegged at 150 every run … use2 p50 85"); the shorter "both report 150 because both are at the cap" framing does not survive measurement, and it matters because the cap being saturated is what makes a skipped cycle free on us-east-1 specifically. Per-day averages against us-east-2 for the same collector: 5,389 vs 567 ms (9.5x) and 3,706 vs 424 ms (8.7x). On a sampled sweep it was 66% of the whole 1-minute body (4,735 ms of 7,221 ms), and summing the fleet's per-sweep cost gives a body of 9,578 ms against #2849's independently measured 9.77 s.The resulting cadence cannot be measured before deploy, and the numbers below are the model, not a measurement. Against #2849's
cadence ≈ N x R / C + tick/2, calibrated so the current point reproduces the observed 113.1 s:This does not clear 60 s at
max_concurrent_sweeps = 4, and #2849's "~58 s" for a halvedRis not reachable. That figure assumedprocedure_statswas ~50% of the gate-held body; measured today it is 44.7% (4,281 of 9,578 ms per sweep) with a 364 ms floor it cannot go below, so the achievable reduction is ~41% ofR, not 50%. The floor at C=4 is ~72 s.What the change does deliver is ~30 s of cadence at no CPU cost, and that is derivable rather than hoped for: collector work per unit time is
N x R / cadence, which goes 3.74 → 3.63 concurrent-body-equivalents — flat to marginally down, because each collection is cheaper by as much as the rate rises. RaisingChas no such property; it buys rate at proportional CPU.Worth re-measuring against
collection_logafter the roll rather than trusting any of this.Review response
The review bot raised exactly two things at
c33be22e, both acted on rather than dismissed:<summary>blocks onDarlingCollectorRunner.cs:393— real, and it also turnedbuildandDarling PostgreSQL testsred onDocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks. Fixed in7d94c10fthe way that test's own failure message directs: not by deleting the first summary, which belongs toShouldCapturePlanForCollector, but by moving the new member above it so the displaced block is reunited with the method it describes. Re-verified by reimplementing the rule (openings per doc run, attributes not ending a run) and scanning all 2,080 non-bin/obj.csfiles: 0 offenders, with a synthetic stacked pair as a positive control and asummary+parablock as a negative control.darling.sample.jsonlanded in7d94c10fand theDarling/README.mdconfig-reference row in87de9ce8, matching whatcollectSchemaChangeEventscarries. The sample was validated with the loader's ownJsonCommentHandling.Skip+AllowTrailingCommas, with the unpatchedorigin/devcopy as a control.Darling/compose/darling.sample.jsondeliberately gets nothing — it is a 36-line minimal file that carries none of these knobs.a50276f6adds the[#2849]CHANGELOG link reference, which my entry cited without defining. Noted in passing and not fixed:[#2860],[#828]and[#887]each have duplicate reference lines, and they predate this branch — left for #2889, which is rewriting that block.