Repository navigation
#2138 Phase 0: CPU-primary plan-regression scoring + absolute-spend noise floor - #2139
Conversation
…oise floor The old PLAN_REGRESSION score was GREATEST(cpu ratio, duration ratio), so a plan whose CPU never moved could fire on duration alone — but duration is confounded by blocking, IO waits and machine contention that no plan choice caused, exactly the false positive a plan-forcing bot must never act on. Now (both SKUs, fact + drill-down): - A CPU regression scores at its own ratio (>= 2x fires, as before). - A duration-dominant regression fires only when EXTREME (>= 4x) AND corroborated by at least mild CPU worsening (>= 1.25x), scored at HALF the duration ratio so it competes honestly with CPU-detected rows. - A 10-CPU-second absolute-spend floor on the latest plan's window total drops offenders whose ratios are all sampling jitter. A noise gate, not an importance ranking — magnitude still belongs to the factor and scorer. The regressed-queries drill-down previously kept its OWN copy of the old GREATEST and could surface rows the fact never counted; it now runs the same scoring, so fact and drill-down agree by construction. Pinned by split-signal tests (CPU and duration moved independently) in the DuckDB suite and the live-Postgres twin: duration-only does NOT fire, extreme-duration-with-corroboration fires at dur/2, below-floor does NOT fire, and the classic 12x seeds still report 12.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| WHEN l.dur_per_exec / NULLIF(b.dur_per_exec, 0) >= 4 | ||
| AND l.cpu_per_exec / NULLIF(b.cpu_per_exec, 0) >= 1.25 | ||
| THEN l.dur_per_exec / NULLIF(b.dur_per_exec, 0) / 2 | ||
| END AS regression_factor, |
There was a problem hiding this comment.
regressed_dimension metadata can now be mislabeled for CPU-primary rows.
The new CASE gives the CPU branch precedence: whenever cpu_ratio >= 2, the row scores as cpu_ratio regardless of how large dur_ratio is (duration is only ever consulted when the CPU branch didn't already fire). But the downstream attribution in CollectPlanRegressionFactsAsync (same file, ~line 392-394) still does:
var cpuRatio = bestCpu > 0 ? latestCpu / bestCpu : 0.0;
var durRatio = bestDur > 0 ? latestDur / bestDur : 0.0;
worstDimension = cpuRatio >= durRatio ? 1 : 2; // 1 = cpu, 2 = durationThat logic was correct under the old GREATEST(cpu_ratio, dur_ratio) scoring (the larger raw ratio was the one that set the factor), but it's no longer correct now that the SQL picks CPU by precedence, not by magnitude. Concrete failure: cpu_ratio = 2.5, dur_ratio = 10 (e.g. a genuine CPU regression that also picked up extra blocking). The CASE fires the CPU branch — regression_factor = 2.5 — but worstDimension computes 2.5 >= 10 → false → reports dimension 2 (duration), mislabeling a CPU-caused regression as duration-caused. The PR description says "a duration-fired row reports dimension 2," but the converse isn't guaranteed: a CPU-fired row can now report dimension 2 too.
Fix: derive worstDimension from which CASE branch actually fired (cpuRatio >= 2 ? 1 : 2), not from comparing the two raw ratios. Same bug exists in Lite/Analysis/DuckDbFactCollector.QueryPerf.cs (~line 390-392) — parity is preserved, but both sides need the fix. None of the new split-signal tests pin regressed_dimension, which is how this slipped through.
| WHEN l.dur_per_exec / NULLIF(b.dur_per_exec, 0) >= 4 | ||
| AND l.cpu_per_exec / NULLIF(b.cpu_per_exec, 0) >= 1.25 | ||
| THEN l.dur_per_exec / NULLIF(b.dur_per_exec, 0) / 2 | ||
| END AS regression_factor, |
There was a problem hiding this comment.
Same issue as the Darling counterpart (PgFactCollector.QueryPerf.cs): the CASE now gives CPU precedence over duration, but worstDimension in CollectPlanRegressionFactsAsync below (~line 390-392) still infers the dimension by comparing raw cpuRatio >= durRatio, which matched the old GREATEST scoring but not this one. A row with cpu_ratio = 2.5, dur_ratio = 10 fires the CPU branch (regression_factor = 2.5) yet gets regressed_dimension = 2 (duration) because 2.5 < 10. Should derive the dimension from which branch fired (cpuRatio >= 2 ? 1 : 2) instead.
|
Reviewed the diff (all 4 SQL sites: Darling fact + drill-down, Lite fact + drill-down, plus the new tests). Verified correct:
One correctness bug found (left as inline comments on both sites): No SQL injection, secrets, or file/network/process concerns — all queries are parameterized. No missing-index suggestions per your note. Nothing else stood out as a Lite/Darling drift or performance regression. |
…catch) The dimension was still inferred by comparing raw ratio magnitudes (cpuRatio >= durRatio), which matched the old GREATEST scoring but not the new CASE: CPU has precedence, so a row with cpu 2.5x and duration 10x fires the CPU branch at 2.5 yet was labeled duration-caused. Derive the dimension from which branch fired (cpuRatio >= 2), both SKUs; drop the now-dead latestDur/bestDur locals. Pinned: new mixed-signal test (cpu 2.5x + dur 10x -> factor 2.5, dimension cpu) and a dimension assert on the extreme-duration arm (dimension duration), in the DuckDB suite and the live-Postgres twin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Good catch on |
Review summaryReviewed the CPU-primary plan-regression scoring change against Correctness
Lite/Darling parity — checked byte-for-byte: the CASE expression, the floor literal, the Security — no dynamic SQL, all values parameterized ( Performance — the added Left one inline nit (low severity, optional cleanup): No blocking issues found. |
| var cpuRatio = bestCpu > 0 ? latestCpu / bestCpu : 0.0; | ||
| var durRatio = bestDur > 0 ? latestDur / bestDur : 0.0; | ||
| worstDimension = cpuRatio >= durRatio ? 1 : 2; // 1 = cpu, 2 = duration | ||
| worstDimension = cpuRatio >= 2 ? 1 : 2; // 1 = cpu, 2 = duration |
There was a problem hiding this comment.
Minor/optional: now that regressed_dimension is derived from cpuRatio >= 2 alone (rather than comparing cpuRatio vs durRatio), the latest_dur/best_dur columns a few lines up (l.dur_per_exec AS latest_dur, b.dur_per_exec AS best_dur) are no longer read anywhere in this method — the latestDur/bestDur locals that used to consume them were removed. They're still selected all the way through the CTE and final SELECT list, just dead weight now (harmless, bounded to 20 rows, but could be dropped). Same applies to the Lite mirror in DuckDbFactCollector.QueryPerf.cs.
Phase 0 of #2138 (gaps 1 + 2 from the gap analysis on the issue): make the PLAN_REGRESSION verdict trustworthy enough to someday act on, while everything stays advise-only.
What changed
CPU is now the primary signal. The old score was
GREATEST(cpu ratio, duration ratio), so a plan whose CPU never moved could fire on duration alone — but duration is confounded by blocking, IO waits, and machine contention that no plan choice caused, exactly the false positive a plan-forcing bot must never act on. Now:A 10-CPU-second absolute-spend floor. The latest plan must have burned >= 10 CPU-seconds across the 14-day comparison window. This is a noise gate, not an importance ranking — a 12x ratio on a query costing 12ms/run over 100 runs is sampling jitter, not a finding. Magnitude ranking stays with the factor and the scorer.
Fact and drill-down agree by construction. The regressed-queries drill-down kept its OWN copy of the old GREATEST in both SKUs — left alone, it could surface rows the fact never counted. Both drill-downs now run the identical CASE + floor.
Where
All four SQL sites:
PgFactCollector.QueryPerf.cs+PgDrillDownCollector.Queries.cs(Darling),DuckDbFactCollector.QueryPerf.cs+DrillDownCollector.Queries.cs(Lite). Theregressed_dimensionmetadata stays honest for the new path: a duration-fired row reports dimension 2.Pins
Split-signal seeds (CPU and duration moved independently) in the DuckDB suite and mirrored as new arms of the live-Postgres twin, so the REAL Postgres SQL is exercised too:
Existing consumers are unaffected mechanically: scorer threshold formula, remediation generation, and force-plan replica-scope logic all consume the factor/rows downstream of this filter.
What this is NOT
No behavior toward forcing plans. Phases 1–2 (evict-first, live-hot targeting, self-review windows, auto-unforce ledger, write-credential tier) remain design-gated on the issue.
Part of #2138 — leave the issue open.