Skip to content

compare_analysis sigma-bands the PostgreSQL baselined metrics, so a 6× TPS surge on a PostgreSQL target stops reading "stable" (#3691 W1/W2) - #3713

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3691-compare-pg-banding
Sep 19, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/3691-compare-pg-banding

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

compare_analysis on a PostgreSQL target now bands PG_TPS, PG_CONNECTION_SATURATION, PG_DEADLOCK_RATE and PG_CPU_PERCENT against the server's own hour-of-week baseline, so a 6× throughput surge stops reading "stable". PARTIAL for #3691 — ticks the checklist line: compare_analysis bands a 6× TPS move "stable"

The lie it told

The v1 exit check planted 31 days at ~10 tps and four hours at 60 tps on a server stamped engine_kind='postgres'. The anomaly detector called it 25σ. compare_analysis, same store, same window, said this about the PG_TPS family:

{ "key": "PG_TPS", "baseline_value": 10, "comparison_value": 60, "value_delta": 50,
  "relative_move": 0.8333, "ladder_position": 0, "status": "stable", "band_source": "absolute",
  "delta_sigma": null, "baseline_sigma": null, "baseline_median": null, "baseline_metric": null,
  "beyond_anomaly_cutoff": null }

Two things conspired. ComparisonBanding.BaselinedMetricFor mapped only the four SQL Server keys (CPU_SQL_PERCENT, CPU_SPIKE, IO_READ_LATENCY_MS, SESSION_STATS), so no PostgreSQL row ever took the sigma path — the PostgreSQL buckets PgTargetBaselineProvider stores (pg_tps, pg_session_count, pg_deadlock_rate, pg_cpu) were never even requested. And the absolute rule needs BOTH a quarter relative move AND a quarter ladder position; PG_TPS has base severity 0 by design (throughput is context, not a grade), so no move of any size could register. A key whose only honest reading is "how far did it move on this server's own scale" was structurally shut out of the one rule that reads that.

What it says now

Same facts, planted bucket median 10 / sigma 1 (the unit pin; the live class proves the real bucket):

{ "key": "PG_TPS", "baseline_value": 10, "comparison_value": 60, "value_delta": 50,
  "relative_move": 0.8333, "ladder_position": 0, "status": "worse", "band_source": "baseline",
  "delta_sigma": 25, "baseline_sigma": 1, "baseline_median": 10, "baseline_confidence": 1,
  "baseline_tier": "Full", "baseline_metric": "pg_tps", "beyond_anomaly_cutoff": true }

delta_sigma is the detectors' 25σ display cap on a +50σ raw move; beyond_anomaly_cutoff: true says the move also clears the detector's own 3.5σ bar, which is the same verdict the detector gave.

Why this shape

The mapping (W1). Four arms added to BaselinedMetricFor, constants only (PgTargetFactKeys.* → MetricNames.Pg*), mapped where the fact's reading is in the bucket's unit and stated per key in the doc:

  • PG_TPS = window transactions ÷ observed seconds; pg_tps = per-collection transactions ÷ the collection's own gap. Averaged-key case: per-sample sigma is an upper bound on the average's dispersion, so the band is conservative (the existing StableWithinRobustSigmas doc already argues this for CPU %).
  • PG_DEADLOCK_RATE = deadlocks ÷ observed hours; pg_deadlock_rate = deadlocks × 3600 ÷ gap — the same unit, unlike SQL Server's events-per-day deadlock baseline which stays unmapped. On a healthy server the bucket's median and MAD sit at 0, EffectiveRobustSigma is 0, and the existing Compare guard routes the key to the absolute rule — the never-blind fallback, with baseline_metric still naming the bucket.
  • PG_CPU_PERCENT = peak acu_utilization_percent when the window carried a capacity sample; pg_cpu = that column's buckets.
  • PG_CONNECTION_SATURATION is the one whose Value is not the bucket's unit: peak ÷ usable connections (a 0–1 fraction) against MAX(total_sessions) per capture (a count).

The unit-reconciliation seam. SESSION_STATS needed none (its value IS the connection count). For PostgreSQL a new BaselinedValueFor(Fact) returns the reading in the bucket's unit or null: PG_CONNECTION_SATURATION → its peak_total_sessions metadata (the very reading the bucket is built from and the one PgTargetAnomalyDetector judges against it; null when absent, as on a hand-built fact); PG_CPU_PERCENT → its value only when capacity_measured = 1 (a fact holding the raw cpu_percent is percent of currently-allocated capacity, #3281 — another unit — and is withheld); every other key → Fact.Value. Compare takes the sigma path only when the bucket is trustworthy AND both sides offer a reading; otherwise the absolute rule. BandBySigma decides the band on the reading delta; value_delta stays in the fact's own unit. Rejected: banding the fraction in count-sigma (the brief forbade it and it is wrong by a factor of usable_connections); converting the bucket into fraction units (same arithmetic, but then baseline_median/baseline_sigma would be in a unit the baseline_metric name does not describe).

Direction. The sigma arm's rule stays "higher is worse". For PG_TPS that is the detector's reading — the high side is the flagged one, more load than this hour routinely carries — so the surge counts as a regression in the family rollup instead of vanishing. A throughput FALL therefore reads better; that is the same one-sided limit the detector has (it does not fire on a collapse), stated in code, and the two-sided reading is left as a v3 item rather than faked by inverting a key whose fall is either an outage or a quiet hour.

Wiring (brief step 2) — confirmed, not re-plumbed. DarlingAnalysisService.ComparePeriodsAsync resolves the engine per call from servers.engine_kind and LookUpDispersionAsync asks engine.Baselines — PgTargetBaselineProvider for a PostgreSQL target — for each metric DispersionMetricsFor names, anchored at comparisonStart, which is exactly where PgTargetAnomalyDetector anchors its GetBaselineAsync. No file outside the boundary needed to change.

The churn note (W2). plan_cache_churn.note told a PostgreSQL operator that BAD_ACTOR_<hash> keys — which their payload never contains — are plan-cache identities. PlanCacheChurn gains a PostgresTarget flag and two named sentences; Compare sets it when any compared fact carries a PgTargetSources.Prefix (pg_) source — the engine resolution carried on the fact by the collector that ran, the same prefix the shared scorer routes on, so neither SKU's tool body has to plumb an engine parameter. The PostgreSQL sentence says what PG_BAD_ACTOR_<queryid> keys are (statement identities from pg_stat_statements), that the churn lists are empty by construction (the prefix decision from between waves, pinned in PgTargetBetweenWavesTests), and that a statement present on one side only IS counted in new_issues/resolved_issues when it clears the presence bar. Rejected: suppressing the block when no bad actors exist — that would change SQL Server output on a quiet server.

Blast radius

  • SQL Server: byte-identical. Every SQL Server key's BaselinedValueFor is its value, the four SQL Server metric arms are unchanged, DispersionMetricsFor over a SQL Server set never names a pg_ metric, and the churn note is the same literal. Proven two ways: the 18 existing ComparisonBandingTests pass unchanged, and a throwaway harness serialized the full compare payload (summary, families, rows, churn, band_rules) for a nine-key SQL Server scenario with and without dispersion against the dev DLL and this branch's DLL — cmp identical for both files.
  • Lite: shares ComparisonBanding; Lite's BaselineProvider is never asked for a pg_ metric because Lite has no pg_-sourced facts.
  • PostgreSQL: four keys can now take the sigma path; LookUpDispersionAsync issues up to four more bucket reads per compare (each a windowed, parameterised read on the same tables the anomaly pass already reads; not on the alert path). Payload shape unchanged — no new fields.

What it does NOT do

  • Does not band PG_WAIT_PROFILE (per-type shares vs an all-types pg_wait_ms_per_sec baseline — the same reason SQL Server wait fractions are unmapped), nor the buffer / write / vacuum / temp / config readings (no bucket exists).
  • Does not make band_rules.baseline engine-aware ("delta_sigma is the value delta…"): for PG_CONNECTION_SATURATION the sigma is on the session-count reading while value_delta is the fraction. Stated in the code; changing the shared sentence would alter SQL Server output. Noted for the coordinator.
  • Does not give PG_TPS a two-sided reading (v3 on PostgreSQL-target analysis engine: v2/v3 slices and the v1 lanes' residue (from #3542) #3691).
  • Does not touch PgTargetBaselineProvider, MetricNames, or any PgTarget* source file.

Which component(s) does this affect?

  • Lite (shared PerformanceMonitor.Analysis)
  • Darling
  • Lite Tests
  • Darling Tests
  • SQL collection scripts
  • Documentation
  • Full Dashboard (deprecated)
  • CLI Installer (deprecated)

How was this tested?

New, executed locally on macOS in a net10.0 harness compiling the real test file against the real PerformanceMonitor.Analysis (23/23 green: 18 existing + 5 new), first executed by CI on Windows as part of Lite.Tests:

  • ComparisonBandingTests.APostgresTpsSurge_IsBandedByTheServersOwnSigma_NotStableByALadderThatCannotMove — the before picture (no dispersion → absolute → stable, relative_move 0.8333, ladder 0, baseline_metric: pg_tps) and the after (bucket median 10 / MAD 0.6745 = sigma 1.0 → worse, baseline, delta_sigma at the 25 cap, beyond_anomaly_cutoff); inside one sigma is stable; a fall reads better.
  • …APostgresSaturationFraction_IsBandedOnItsPeakSessionCount_NeverOnTheFractionInCountSigma — BaselinedValueFor picks the count; 20 → 60 sessions of 100 against median 20 / MAD 0 (sigma floors at 0.2) is worse with the fraction in value_delta; a one-sigma count move is stable; a fact without the metadata takes the absolute rule and still names pg_session_count.
  • …PostgresCpu_IsSigmaBandedOnlyInTheCapacityUnit_AndDeadlocks_OnlyAgainstANonZeroBucket — capacity-measured CPU bands; raw-percent CPU falls back; a zero deadlock bucket has sigma 0 and falls back; a non-zero one bands (5σ); DispersionMetricsFor names exactly the four pg_ metrics for a PostgreSQL set.
  • …TheChurnNote_SpeaksTheEngineOfTheFacts — PostgreSQL set: PostgresTarget, empty churn, PG_BAD_ACTOR_* on the presence path, the PostgreSQL note verbatim; SQL Server set: the SQL Server note verbatim (literal asserted).
  • …ASqlServerFactSet_NeverReachesThePostgresArms — the routing pin: every SQL Server reading is its value, the four SQL Server arms unchanged, no pg_ metric requested, churn not PostgreSQL.

New, gated (DARLING_TEST_PG), compile-verified here, first executed in CI's Darling PostgreSQL tests: CompareAnalysisPgBandingLiveTests.ASixFoldTpsSurge_IsBandedWorseBySigma_AgainstThePgTpsBucket_ThroughTheRealTool — 31 days of one-minute pg_database_stats at ~10 tps, the last 250 minutes at 60, server stamped engine_kind='postgres', the REAL DarlingMcpTools.CompareAnalysis (4 h vs 28 h back) → PG_TPS row worse / baseline / baseline_metric: pg_tps / beyond_anomaly_cutoff / delta_sigma 25 / ladder_position 0 / no coverage caveat; family row worse; the PostgreSQL churn note. [Collection("live-postgres")], #1902 LiveStoreCleanup shape.

Census pre-checks executed locally (harness compiling the census classes against this tree): DocCommentHygieneTests (all but the project-list pin, which saw the harness itself), LiveCleanupConversionRatchetTests, LivePostgresCollectionHygieneTests (the new class is not flagged), RepoFileAdoptionTests, PgTargetBetweenWavesTests — green.

Builds (zero new warnings): dotnet build PerformanceMonitor.Analysis -c Debug 0/0; Lite.Tests -p:EnableWindowsTargeting=true 1 warning = dev's pre-existing xUnit2000 in LogTailOverlapThresholdPinTests.cs:95 (verified identical on a stash of this branch's changes); Darling/Darling.Tests 0/0; deprecated/Dashboard.Tests 0/0.

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 — SQL Server arithmetic is byte-identical (harness-diffed); the PostgreSQL path is pinned in unit tests and a gated live class
  • I have not introduced any hardcoded credentials or server names

…x TPS surge stops reading stable (#3691 W1, W2)

ComparisonBanding.BaselinedMetricFor mapped only the four SQL Server keys; on a
PostgreSQL target PG_TPS (base severity 0 by design) could never move under the
absolute rule and a 10 -> 60 tps move banded stable in the window the detector
called 25 sigma. PG_TPS / PG_CONNECTION_SATURATION / PG_DEADLOCK_RATE /
PG_CPU_PERCENT now map to the pg_tps / pg_session_count / pg_deadlock_rate /
pg_cpu buckets PgTargetBaselineProvider stores, through a unit-reconciliation
seam (BaselinedValueFor): the saturation FRACTION is banded on its
peak_total_sessions count, a CPU fact holding the raw cpu_percent is withheld,
every SQL Server reading is its value unchanged. The plan_cache_churn note
speaks the engine of the facts (pg_ source prefix). SQL Server output is
byte-identical before/after (harness-diffed); pins in ComparisonBandingTests,
live wiring proof in CompareAnalysisPgBandingLiveTests.
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 19, 2026 10:33
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Coordinator read: BaselinedValueFor is the right seam (fraction never banded in count-sigma; raw-cpu facts withheld), PlanCacheChurn.PostgresTarget stays out of the payload because ToPayload() names its fields — SQL Server bytes unchanged. Armed. One residual, not for this PR: compare_analysis's tool description (both SKUs) still lists the banded keys as "(CPU %, read latency, connections)" — the PostgreSQL four belong in that parenthesis; batched into the next description pass on #3691.

@erikdarlingdata
erikdarlingdata merged commit 181b730 into dev Sep 19, 2026
7 of 8 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3691-compare-pg-banding branch September 19, 2026 10:33
This was referenced Sep 19, 2026
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