Skip to content

A PostgreSQL operator now learns which statement got slower when its plan changed, and why: PG_PLAN_REGRESSION joins the captured plan_hash flip to the statement's per-call step, PG_PARAMETER_SENSITIVITY names the skewed predicate column behind a multi-plan statement, and the server-wide per-call mean is baselined (partial #3691, lane 27) - #3798

Merged
erikdarlingdata merged 1 commit into
devfrom
feat/3691-pg-plan-regression
Sep 20, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Headline mechanism. A PostgreSQL target's analyze_server pass now names the statement whose plan changed and says what the change cost. PgTargetFactCollector.CollectPlanFactsAsync (the v3 plan family, pg_plans) joins pg_plan_capture's plan_hash flips to pg_statement_stats' STORED per-call deltas split at the flip and emits PG_PLAN_REGRESSION — "statement queryid N got 10× slower per call when its plan changed (20 ms → 200 ms)" — graded on the after ÷ before ratio behind an absolute per-call delta and a call floor on each side; PG_PARAMETER_SENSITIVITY names the mechanism when one query_id was captured under three or more plans and pg_qualstats (pg_predicate_stats) names a predicate column whose pg_column_stats.top_value_frequency says one value covers half the table; and ANOMALY_PG_PLAN_REGRESSION baselines the SERVER-WIDE per-call mean (Σ stored exec-time deltas ÷ Σ stored call deltas per collection) hour-of-week and folds onto the regression. PARTIAL for #3691 — lane 27. The checklist line this PR completes, verbatim: "Plan regression (statement step-change × plan_hash flip …); parameter sensitivity (…)" — the whole line is this lane's. Do not close.

What the family concludes, and from which table

  • PG_PLAN_REGRESSION (pg_plan_capture × pg_statement_stats). Per (query_id, plan_hash) the captures collapse to a first-seen time; ordered by first-seen, the plan the window OPENED with is "before" and the plan it CLOSED with is "after". The statement's stored deltas (delta_calls, delta_total_exec_time_ms — lane 7's discipline: the collector's deltas, never a re-differencing) are summed before the second hash first appeared and from the last hash's first appearance on; with three or more hashes the middle plans' minutes belong to neither side, counted (plan_hash_count) but never averaged into a plan they were not run under. Fact.Value is the ratio; metadata carries both means, both call counts, the two hashes as their leading 48 bits (12 hex characters, exact in a double — a prefix the reader matches against get_pg_plans), whether the top node changed and the node-count delta, the ages of the first and last change, and how many statements flipped. query_id rides on ObjectName (lane 7's 2^53 reason). Orphans (query_id = 0, no %Q) are excluded. ONE fact per pass — the worst flip that clears every bar, else the largest-ratio flip with enough calls to compare (graded 0, shown), else nothing. Absence of a capture is "not slow enough to log", never "no plan" — except when pg_plan_capture_readiness' library_loaded facet says auto_explain is NOT loaded, when the fact is emitted unavailable with reason_auto_explain_off (the write family's shape) and the advice says how to load it and what it costs.
  • PG_PARAMETER_SENSITIVITY (pg_plan_capture × pg_predicate_stats × pg_column_stats, joined by (database, schema, table, column) NAME per database — both tables are per-database by design). The most-skewed statement with a skewed column; when statements flipped three ways but no predicate row exists, ONE unavailable fact with the reason read from pg_extension_availability (reason_pg_qualstats_absent) or, installed but unsampled, reason_no_predicate_sampled (its default sample_rate is 1/max_connections — V98's warning). DatabaseName is the skewed column's database.
  • ANOMALY_PG_PLAN_REGRESSION (pg_statement_stats, server-wide). The coordinator's v3 ruling on the decision the stub left to this lane: the baseline seam keys one series per (server, metric), so the arm is the SERVER-WIDE per-call mean — honest but blunt, and the detector's doc, the advice ("not which one"), and server_wide = 1 in the metadata all say so. Pair gate (Brains-review campaign: deferred structural residue (from #3538 / #3539 / #3540 / #3541) #3653, peak AND window mean), heavy-tail robust cutoff by reference (a per-call mean is a query-duration quantity; the PG population is unmeasured), pg_statement_stats joins BaselineServingRawCollectors (D10 — the family's heaviest read and the retention an operator is likeliest to shorten).

The lineage shape of every bar

All unmeasured, each with the marker within six lines and the calibrating read named; threshold_lineage = 0 stamped on every graded fact (= 1 on the unavailable shapes, where no bar decided — the write family's rule). The 2026-09-19 calibration read statement shares and busy fractions, never a plan-hash or per-collection mean distribution, so no bar here may claim measured. PlanRegressionRatioConcerning 2.0 / Critical 5.0 (self-gated to 0 under 2×); PlanRegressionMinDeltaMs 50; PlanRegressionMinCallsPerSide 20; PlanCoFireBoost 0.3 (two corroborators → 1.6, over the notify floor; one → 1.3, under); ParameterSensitivityMinHashes 3; ParameterSensitivitySkewFrequency 0.5; ParameterSensitivityBase 0.4 / CoFireBoost 0.5 (D5: 0.4 × 1.5 = 0.6); AnomalyThresholds.PgStatementMeanMsFloor 10 ms / Fallback 250 ms (the // lane 27 block; pinned not equal to the SQL Server QueryDurationFloorUs by value). The scorer's bars are passed to the sensitivity read as parameters and referenced by the collector's pick, so the read, the pick and the grade cannot disagree.

Co-fire gating

PG_PLAN_REGRESSION: +0.3 when PG_BAD_ACTOR_<queryid> fired for the SAME statement (string match on ObjectName against lane 7's key shape — a different statement's bad actor, however hot, lifts nothing), +0.3 when ANOMALY_PG_PLAN_REGRESSION fired (an amplifier reading the anomaly's verdict, lane 15's pattern — never an edge from a base-0 fact). PG_PARAMETER_SENSITIVITY: +0.5 when the regression fired on the same statement. Edges: PG_PLAN_REGRESSION → PG_BAD_ACTOR (the alias) opens only when the pass's top bad actor IS the regressed statement — ResolveBadActor has no per-statement input and pointing the story at a different statement's text would be worse than no edge; the amplifier still lifts (stated in the graph file); PG_PARAMETER_SENSITIVITY → PG_PLAN_REGRESSION (same id); ANOMALY_PG_PLAN_REGRESSION → PG_PLAN_REGRESSION. PgTargetScorer.SameStatementFired decides "the same statement" once for amplifiers and edges.

Advice

Value-stated: the step, the ratio and delta, calls on each side, the hash prefixes, the node change, the flip's age, the co-fires, the skew frequency, n_distinct (ratio-shaped negatives rendered as such), the worst estimate error. Levers with counter-objectives, none emitted as a statement: plan_cache_mode = force_custom_plan (planning time on every execution), a larger per-column statistics target (described as the knob; ANALYZE time and statistics space), ANALYZE (one sample read), a rewrite (application change). The queryid re-key caveat on every block. No CREATE INDEX anywhere (D8) — pinned on every composed and static block and on every string literal in the family's files. PostgreSQL has no plan cache to force the earlier plan back into — said, not implied.

What it does NOT do

No PG_SEQ_SCAN_ADVISORY (lane 30 — its scorer arm, advice arm, graph edge and the marked /* lane 30: Seq-Scan read */ region at the end of CollectPlanFactsAsync are left for it; nothing here forces a rewrite). No per-statement baseline (the seam's key dimension is a shared-file item, reported). No same-id alias resolution (reported). No tool-row edit in PgTargetToolRecommendations (the plumbing rows already name get_pg_plans / get_pg_top_queries / get_pg_column_stats / get_pg_predicate_stats; the prose names all five tools). No rung, no shared-switch edit, no PgTargetFactKeys edit. The SQL Server pass is untouched: every new arm sits behind the pg_plans source or a PG_ / ANOMALY_PG_ key, and the routing pins pass unchanged.

Censuses moved deliberately (each edited, none deleted)

PgTargetSharedSwitchRoutingTests (three Null → NotNull, the anomaly's equality + no "Anomalous spike"); PgTargetAnomalyTests (served metrics 9 → 10 with the (name, table) row; inert loop loses PgStatementMeanMs; fence test nine → ten filled with the plan partial in the filled list and only the kernel stub exempt; StatementMeanWindowSql in the detector roster; the two constants in the AnomalyThresholds roster); PgTargetV3PlumbingTests (the two filled keys pinned self-gating + composing, the seq-scan / kernel / memory stubs still inert); BaselineSupplyTests (9 → 10 sources, pg_statement_stats in the derived set); PgTargetBetweenWavesV2Tests (floor set 11 → 12); PgTargetMeasuredLineageTests.s_stillUnmeasured (every plan bar and both anomaly bars). PgTargetFactCollectorTests, PgTargetThresholdLineageTests, LocalClockBucketKeyTests, StoreSqlClockDisciplineTests, DocCommentHygiene, RepoFileAdoptionTests, CommentFilterAdoptionTests, LivePostgresCollectionHygieneTests, LiveCleanupConversionRatchetTests: green, no edit. No file mentions an aurora_stat_* function.

Test plan

New Darling/Darling.Tests/PgTargetPlanTests.cs (22 tests, 1 gated): lineage; the ramp and every self-gate; the collector's PURE picks (PickRegression / PickSensitivity, exposed internal, pinned on arranged rows); amplifiers through the real ScoreAll (same-id vs other-id); edges incl. the alias opening only for the top bad actor; traversal; advice by clause for every branch and both unavailable shapes; the three reads by text; baseline arm + window read sharing one grain; gated e2e — 31 days of one-minute pg_statement_stats for five statements (223,205 rows, set-based), three captured hashes on the hot statement, its mean 20 → 200 ms from the first flip, a skewed predicate column, readiness + extension rows; the REAL analyze_server returns PG_PLAN_REGRESSION at 1.6 naming the id, leaf PG_BAD_ACTOR_<id>, headline exact, hash prefixes in the prose; PG_PARAMETER_SENSITIVITY at 0.6 chained; ANOMALY_PG_PLAN_REGRESSION in the regression's incident with the family's prose; get_analysis_facts source=pg_plans → two facts, both threshold_lineage 0; then a second server with library_loaded = false → the one unavailable fact with its reason.

Executed here, not only compiled: a net10.0 harness (AssemblyName=Darling.Tests, inside the worktree under obj/, deleted before commit) compiling every PgTarget*.cs plus FactSourceRegistryTests, CollectionCaveatsTests, BaselineSupplyTests, LocalClockBucketKeyTests, BaselineTierJoinShapeTests, StoreSqlClockDisciplineTests, DocCommentHygieneTests, RepoFileAdoptionTests, CommentFilterAdoptionTests, LivePostgresCollectionHygieneTests, LiveCleanupConversionRatchetTests, FleetIdentifierScrubTests, FactCollector*Tests, AnalysisPass*Tests, CommandDeadlineScannerAdoptionTests, AsOfWindowAnchorTests — 873 / 873 green, 0 skipped — against a throwaway timescale/timescaledb:2.28.1-pg18 (Docker context desktop-linux, role and database darling, migrated by PgMigrations.MigrateAsync, torn down after), every gated e2e in the set included. The harness caught three of my own first-draft defects (a theory row under the delta bar; a prose clause the test forgot; a pin reading a stripped literal) — each was the test, not the product. Rebased onto origin/dev after lanes 29 / R5 landed (no conflicts; file-disjoint), compile re-verified. First Windows execution is CI's.

Builds, zero warnings: PerformanceMonitor.Analysis, PerformanceMonitor.Darling.Analysis, PerformanceMonitor.Darling.Service, Darling.Tests, Lite.Tests, deprecated/Dashboard.Tests (-p:EnableWindowsTargeting=true on the test projects).

Which component(s) does this affect?

  • Lite
  • Darling
  • Lite Tests
  • Darling Tests
  • SQL collection scripts
  • Documentation
  • Full Dashboard (deprecated)
  • CLI Installer (deprecated)

How was this tested?

Above. No production store or fleet server was read; the rig was a local container, torn down.

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • I have tested my changes against at least one SQL Server version (not applicable — a PostgreSQL-target-only family; the SQL Server pass reads none of the changed arms and the routing pins pass unchanged)
  • I have not introduced any hardcoded credentials or server names

Partial for #3691 (lane 27). CHANGELOG deliberately untouched — the coordinator splices this wave's entries.

…plan changed: PG_PLAN_REGRESSION joins pg_plan_capture's hash flip to pg_statement_stats' per-call step, PG_PARAMETER_SENSITIVITY names the skewed predicate column behind a multi-plan statement, and ANOMALY_PG_PLAN_REGRESSION baselines the server-wide per-call mean (partial #3691, lane 27)
@erikdarlingdata
erikdarlingdata merged commit 4991595 into dev Sep 20, 2026
4 of 7 checks passed
@erikdarlingdata
erikdarlingdata deleted the feat/3691-pg-plan-regression branch September 20, 2026 15:51
erikdarlingdata added a commit that referenced this pull request Sep 20, 2026
…gTargetFactCollector.Plans.cs Ratio ARRIVED with #3798, PgTargetDrillDownCollector.Buffer.cs Round LEFT with #3799 (#3691 residue) (#3806)
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