Skip to content

A PostgreSQL target learns its own normal: five baselines over the pg_* raw hypertables and five anomaly detectors whose facts score, escape the cap, corroborate, fold onto their parents and page through the shared machinery — so a 6× surge at 3 am is no longer indistinguishable from a quiet Tuesday (#3542 lane 9) - #3689

Merged
erikdarlingdata merged 1 commit into
devfrom
feat/3542-pg-anomalies-baselines
Sep 19, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

PARTIAL for #3542 — lane 9 of v1, the last content lane. A PostgreSQL target's analyze_server pass now baselines five of its own metrics over its own 30 days of one-minute history and fires five anomaly detectors against those buckets — and, decisively, a PostgreSQL anomaly can now score, escape the tuning-class cap, corroborate its siblings, fold onto its regular parent, and page, through the same shared scorer, reconciler and notification machinery the SQL Server engine uses, without any of that machinery knowing which engine it is looking at.

The lie this stops telling

Before this lane, PgTargetBaselineProvider answered null for every metric and PgTargetAnomalyDetector returned an empty list, so a PostgreSQL target had no notion of its own normal: a 6× throughput surge at 3 am, a connection pool that tripled, a serverless instance pinned at its configured ceiling for the first time in a month — none of it was distinguishable from a quiet Tuesday unless it also crossed an absolute bar some other family owned. And had the anomalies existed without this lane's scorer arms, #3584's exact defect would have shipped again for PostgreSQL: every ANOMALY_PG_* fact falls past the SQL Server literal lists, scores 0 unless it happens to carry a ratio, and is never released from the 1.49 cap — the product's best per-server-calibrated statistics structurally its quietest.

What each family concludes, and from which table

Metric / anomaly Source What the number is
pg_tps → ANOMALY_PG_TPS pg_database_stats transactions per second per collection: the same reset-aware per-database difference the PG_TPS fact takes (LAG under PARTITION BY database_name, GREATEST(raw, 0), ROW_NUMBER() > 1 AND stats_reset IS DISTINCT FROM LAG(stats_reset) — the NULL→timestamp first-reset trap that read records, not re-derived), summed and rated over the collection's own gap
pg_deadlock_rate → ANOMALY_PG_DEADLOCK_RATE pg_database_stats deadlocks per hour per collection off the same difference, so the bucket mean is in PG_DEADLOCK_RATE's unit and the ratio is unit-free; never pg_deadlocks for the rate (adversarial item C)
pg_session_count → ANOMALY_PG_SESSION_SPIKE pg_session_states the capture's denormalised total_sessions (MAX per collection_time is a pick, not an aggregate — the V86 design); the exception-table caveat (quiet minutes absent; background processes counted) is stated on both sides
pg_wait_ms_per_sec → ANOMALY_PG_WAIT_PROFILE pg_wait_stats (Aurora only) all-types wait ms per second under the wait partial's three-state interval (stored → NULLIF, NULL → LAG, a restart collection is not a sample), CPU excluded exactly as the wait facts' share denominator excludes it
pg_cpu → ANOMALY_PG_CPU_SPIKE, and the PG_CPU_PERCENT fact pg_cpu_utilization (Aurora only) acu_utilization_percent — percent of the CONFIGURED capacity ceiling — never cpu_percent (#3281: on the serverless class the whole measured fleet runs, cpu_percent reads 100 whenever one core is busy for a minute while the instance sits at a third of its ceiling). The raw reading rides as "a core was pinned" and is never graded

Every clean CTE ends in the ONE RobustTierScaffold (pinned), reads >= $2 AND < $3 with bounds the base class binds naive-UTC off context.TimeRangeStart (anchored, #2506 — no UtcNow anywhere in the provider or the detector), and the seam is the single protected override ResolveBaselineQuery — nothing of the base machinery is redeclared (pinned by reflection).

D10, the retention dependency, stated and pinned: all four source tables carry 30-day service-side retention in CollectorScheduleDefaults and none is in TimescaleSupport.RawTierCoverage (the 4-day tier #1757 found under the SQL Server baselines), so the supply is exactly the window. What this does NOT cover is said in the provider's doc and reported below: retention is user-editable and DarlingRetention.BaselineServingRawCollectors floors the purge horizon only for the SQL Server raw-reading arms.

The detectors

Same posture as PgAnomalyDetector, verbatim where it could be: the pg_database_stats gate (already plumbed), each of the five detectors fenced in its own catch … when (!AnalysisShutdown.IsExpectedAbandon(…)), CommandTimeout = AnalysisCommandTimeoutSeconds on every command, the token on every store call. Three z-families through AnomalyGate.EvaluateZScore (robust modified-z at 3.5 when the bucket carries median/MAD, classical 2σ otherwise; magnitude floor AND-ed on the trusted path; the strictly-higher absolute bar on an untrustworthy one — never AND-ed into blindness), writing key-for-key the metadata the shared ramp and extremity escape read: deviation_sigma, fire_threshold, baseline_low_quality, fallback_exceedance, baseline_samples, confidence, the tier and median/MAD. Two ratio families (deadlock rate; the Aurora wait profile on modified_z when robust, ratio otherwise — the statistic the SQL Server profile fact lands on, so it is registered in IsPgRatioAnomalyKey, not as deviation-scored).

The deadlock rate divides by context.ObservedDurationMs (#3538 A7), never the nominal window, and is stamped with the database that deadlocked most — as the regular fact is — because the reconciler folds on (family, database) and a server-scoped anomaly would never meet a database-scoped parent.

A first occurrence is not a ratio. An untrustworthy bucket fires on the absolute bar with is_new = 1, fallback_exceedance = rate ÷ bar and no sentinel ratio (the SQL Server NoBaselineRatio = 100 device is not reused); ScoreRatioAnomaly grades the exceedance (0.5 at the bar, 1.0 at 2×) and the advice renders "first occurrence, no baseline yet", never "100× its baseline".

Known, deferred residue, copied rather than fixed (#3538 A8): the window statistic is the PEAK per-collection value and the bucket is a per-sample distribution, so "Nσ" is biased high in proportion to log k. Same on both engines; correcting it is a cross-engine change to the gate and its calibration. Stated in the detector's doc so the shape is not mistaken for a per-sample one.

The lineage shape of every bar

No PostgreSQL bar is a SQL Server constant reused by value (pinned: PgTpsFloor ≠ BatchRequestFloor, PgSessionCountFloor ≠ SessionCountFloor, … each pair). Every number:

  • unmeasured, with its calibrating read named and threshold_lineage = 0 stamped on every fact: PgTpsFloor 50 / PgTpsFallback 500 tx/s; PgSessionCountFloor 20 / PgSessionCountFallback 200 (COUNTS — a fraction of max_connections is meaningless as a constant, the detector cannot see the config fact, and the ceiling-aware arm is PG_CONNECTION_SATURATION, which this anomaly folds into and which is the never-blind arm on a young store); PgCpuFloorPct 40; PgWaitProfileFallbackMsPerSec 500 (half a backend continuously waiting); PgDeadlockRateFloorPerHour 1; PgRatioAnomalyThreshold 3 (the same multiple the SQL Server event-ratio detector uses, chosen on its own argument and not an alias so the two can calibrate apart); the ratio saturation at 3× the multiple; the wait profile's modified-z span (10σ past the heavy-tail cutoff, which is used by reference as the shared robust-statistic cutoff); the +0.3 co-fire boost (derived from the 1.5 notify floor: one corroborator 1.3, two 1.6 — Extreme, corroborated anomaly findings can now cross the notify floor, so the baseline engine is no longer notification-inert at shipped settings (Fixes #3526) #3584's rule).
  • measured, by alias: PgDeadlockRateFallbackPerHour = PgTargetScorer.DeadlockWarnPerHour (p99.94 over 14 days × 43 servers, A single resolved deadlock bands a server Critical: DeadlockSeverity is count > 0 with no magnitude tier, so Critical stops discriminating on any OLTP fleet #3368) — a young store's deadlock anomaly fires exactly when the regular fact does and folds into it.
  • the fleet ladder, repeated and pinned equal: CpuCapacityWarningPercent 80 / CpuCapacityCriticalPercent 95 and PgCpuFallbackPct 95 equal ServerHealthThresholds.CpuWarningPercent / CpuCriticalPercent (this assembly references nothing; PgTargetAnomalyTests pins each pair), so the card, the High CPU alert and this pass cannot disagree about the colour of one minute — and still marked unmeasured, because that ladder is stated against a quantity, not read off a distribution.

PgTargetThresholdLineageTests scans every PgTargetScorer*.cs bar (executed locally, green); a new pin applies the same rule to every Pg* constant in AnomalyThresholds.

The co-fire gating

PgTargetScorer.Anomaly.cs: the LOAD family (TPS, session spike, CPU spike) corroborate one another (+0.3 each, self-key omitted) and are confirmed by the MEASURED PG_CPU_PERCENT at the capacity warning bar — the confirmer reads capacity_measured first, so a raw percent-of-allocated reading of 100 confirms nothing (pinned). On stock PostgreSQL there is no CPU fact and the arm is inert; the advice never implies CPU was checked (D6). The wait profile takes the load arms plus "a named PG_WAIT_* standout crossed its own threshold". The deadlock rate has no arm — its impact lives in the never-capped PG_DEADLOCK_RATE it folds into. The ratio families never escape the cap (not graded in sigmas the multiple was calibrated for).

PgTargetScorer.Cpu.cs: PG_CPU_PERCENT grades only when capacity_measured = 1, zero below the warning bar (the session family's self-gating shape — ApplyThresholdFormula would read a 40% instance as 0.25 and lane 3's "CPU fired" amplifier asks BaseSeverity > 0), 0.5 at 80, 1.0 at 95. A fact with no capacity sample scores 0 whatever its raw percent — "Unknown, never Healthy", the card's #3271 rule — and the collector (PgTargetFactCollector.Cpu.cs, one read, FleetCpuProvenance.CpuBandInputPercent picks WHICH percentage; it calls neither ServerHealthClassifier.CpuSeverity nor builds a ServerHealthMetrics, so the FleetCardPostgresCpuTests decider census is untouched) says why.

Advice

The SQL Server anomaly composer's shape, engine-neutral, with PostgreSQL lineage in every noun: "Transactions per second reached 1,234/sec this window, 6.2σ above its 280/sec baseline median for this hour-of-week (over 120 baseline samples)" + the hedge. A low-quality z-fact and an is_new ratio-fact print no sigma and no multiple. The wait profile says "the engine measured … CPU excluded", never "spent", never "estimated from sampling" (it exists only for the Aurora series). The CPU anomaly's number is "% of the configured capacity ceiling" with the raw reading beside it as "a core was pinned". PG_CPU_PERCENT's own block states peak/avg capacity, the ACUs at the peak, both levers with their counter-objectives (raise the ceiling: pays every hour and hides the query; fix the query: durable, takes finding it), and "not graded" when no capacity sample exists.

What it does NOT do

  • Stock PostgreSQL gets no wait-profile anomaly in v1. The pg_wait_sampling estimate is per-backend-sample, quantised at profile_period, with a different noise distribution from a measured microsecond sum; a bucket that mixed the two, or a bar tuned on one applied to the other, would be the unit error adversarial item A names. When a sampled arm arrives it is its own metric name and its own bar — so the brief's PgSampledWaitProfileFallbackMsPerSec is deliberately NOT declared tonight (a constant with no user is dead code, and declaring it would imply the arm exists).
  • No SetDeviationThreshold knob on the PostgreSQL detector (defaults over config).
  • The wait-profile anomaly stays a solo card: the reconciler's dominant-contributor resolution is keyed on the literal ANOMALY_WAIT_PROFILE and PgTargetFactKeys.AnomalyToFamilies has no entry for it — both shared files, outside this lane (reported).
  • AnomalyThresholds/BaselineMath.AbsStdDevFloorFor gives pg_cpu no absolute dispersion floor (the SQL Server CPU has 5.0); out of this lane's files, reported. The 25σ display cap and the 40% magnitude floor hold a variance-collapsed bucket sane meanwhile.
  • The Analysis-engine campaign: coverage honesty, measured thresholds, config attribution (adversarial review, 11 findings) #3538 A8 peak-vs-per-sample bias — inherited, stated, not fixed.

v1's exit criterion — is it met?

The plan's exit criterion: a 30-day planted series → z anomalies fold into lanes 2/3/5's parents; analyze_server on a PG target under planted load returns scored, storied, advice-bearing findings — persists, mutes, occurs, and can notify through the existing machinery. Met, and executed rather than asserted, on a throwaway timescale/timescaledb:2.28.1-pg18 store against the exact test that ships in this PR: 31 days of one-minute pg_database_stats and pg_session_states and five-minute pg_cpu_utilization on an aurora-postgres-stamped server, steady for 30 days, spiking in the last four hours → the REAL analyze_server returned ANOMALY_PG_TPS at the 25σ display cap corroborated by the session and CPU anomalies and the measured confirmer, severity 1.9 — above the 1.5 notify floor, the lone-anomaly cap released by the extremity escape; ANOMALY_PG_CPU_SPIKE sharing PG_CPU_PERCENT's incident_id; the first-occurrence ANOMALY_PG_DEADLOCK_RATE (24 deadlocks over 4 observed hours, 6/h ≥ the measured 5/h tier) sharing PG_DEADLOCK_RATE's; advice in PostgreSQL nouns and get_pg_* next-reads. The persist/mute/occurrence/notify path is the one pass body both engines share (plumbing #3665), so a finding that reaches analyze_server reaches it. One caveat to state plainly: get_analysis_facts does not run the anomaly detector on either engine (CollectAndScoreFactsAsync is collector + scorer only), so threshold_lineage = 0 on anomaly facts is visible in the finding, not in that read — pre-existing, reported.

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?

New: Darling/Darling.Tests/PgTargetAnomalyTests.cs (39 tests, 1 gated live). Every CTE's table and shape; the D10 supply/retention pin; the detector SQL dialect / FROM-JOIN census / observed-time divisor / per-detector fence / anchored gate / A8 statement; deviation-vs-ratio membership (every ANOMALY_PG_* in exactly one, the wait profile ratio); the ratio ramp's values (2.9→0, 3→0.5, 6→0.75, 9→1.0), the first-occurrence exceedance ramp, the wait profile's modified-z ramp; the extremity escape reachable — ANOMALY_PG_SESSION_SPIKE at 10.5σ/3.5 with two co-fires = 1.6, not capped; alone 1.0; a 7σ one with the same co-fires = 1.49; the 25σ cap bound; the low-quality path at 3× its bar; the ratio families never escaping; ScoreCpuFact's values and the three ServerHealthThresholds equalities; the load confirmer refusing a raw 100; every Pg* constant's lineage marker and the no-reuse-by-value pairs; the advice pins; the CPU read's shape and its non-participation in the decider census; the gated e2e above.

Edited: PgTargetSharedSwitchRoutingTests — two pins moved deliberately, as their own comments said they would when lane 9 landed (the ratio-routing pin now uses a ratio the two ramps grade DIFFERENTLY, 6× → 0.75 PostgreSQL vs 0.714 SQL Server; the ANOMALY_PG_WAIT_PROFILE advice pin moves from null to "the PostgreSQL block, and not the SQL Server one"). PgTargetSessionsTests — the "CPU is a stub" comment falsified by this lane is corrected, and the test now also drives a capacity-measured 95% through ScoreAll to 1.5 (the D7 shape); lane 3's saturation-chain re-pin is dev's (#3686), untouched here.

Executed locally, not just compiled, via a net10.0 harness with AssemblyName=Darling.Tests against a throwaway timescale/timescaledb:2.28.1-pg18 on Docker context desktop-linux: 654 Darling tests green across PgTargetAnomalyTests, PgTargetThresholdLineageTests, PgTargetSharedSwitchRoutingTests, PgTargetSessionsTests, PgTargetFactCollectorTests, PgTargetWaitTests/LiveTests, PgTargetVacuumTests/LiveTests, PgTargetTempTests, PgTargetKnobsTests, PgTargetQueriesTests, PgTargetPosture*Tests, PgTargetMcpSurfaceTests, BaselineTierJoinShapeTests, BaselineSupplyTests, SharedBaselineModelPinTests, DarlingAnomalyBaselineTests, StoreSqlClockDisciplineTests, FactSourceRegistryTests, FactCollector*Tests, AnalysisPass*Tests, AsOfWindowAnchorTests, DarlingAnalysisServiceEngineRoutingTests, RobustBaselineTests, BaselineTimeoutIsNamedTests, plus DocCommentHygieneTests, RepoFileAdoptionTests, CommentFilterAdoptionTests, LiveCleanupConversionRatchetTests, LivePostgresCollectionHygieneTests run in-tree (109 green). The harness caught three miscounts in my own first draft (an inclusive generate_series bound, the AnomalyPrefix constant in a reflection sweep, the wait profile's fifth amplifier) — each was the test, not the product. CI is the first Windows execution. Rig torn down.

Builds, zero new warnings (-p:EnableWindowsTargeting=true): PerformanceMonitor.Analysis, PerformanceMonitor.Darling.Analysis, PerformanceMonitor.Darling.Service, Darling.Tests, Lite.Tests (one pre-existing xUnit2000 in LogTailOverlapThresholdPinTests, untouched), deprecated/Dashboard.Tests.

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 (PostgreSQL-target lane; executed against PostgreSQL 18 + TimescaleDB 2.28.1 — the SQL Server switches are pinned unchanged by PgTargetSharedSwitchRoutingTests.SqlServerAnomalies_AreUnaffected_ByThePgMembershipArm)
  • I have not introduced any hardcoded credentials or server names

…CTEs over the pg_* raw hypertables under the one RobustTierScaffold (TPS and deadlocks/hour off the reset-aware per-database difference, the capture's total_sessions, the Aurora all-types wait rate under the three-state interval with CPU excluded, percent of the configured ACU ceiling), five detectors through the shared AnomalyGate writing the same metadata the #3584 ramp and extremity escape read, a PostgreSQL ratio ramp with no sentinel for a first occurrence, the load-family co-fires confirmed by a capacity reading and never a raw one, PG_CPU_PERCENT graded only when the capacity percent was measured, and advice in PostgreSQL nouns (#3542 lane 9)

Unmeasured floors and bars carry their lineage and every anomaly fact carries threshold_lineage = 0. Stock PostgreSQL's sampled wait estimate gets no anomaly baseline in v1.
@erikdarlingdata
erikdarlingdata merged commit f887409 into dev Sep 19, 2026
5 of 7 checks passed
@erikdarlingdata
erikdarlingdata deleted the feat/3542-pg-anomalies-baselines branch September 19, 2026 03:45
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…ch arms, censuses re-pinned (partial #3691) (#3715)

Declares the v2 sources (pg_io, pg_replication, pg_bloat), keys and baseline metric names once; stubs the
I/O, replication and bloat families in their own partials with "/* filled by lane N */" markers (collector,
scorer, advice, graph) and the three v2 detectors / baseline arms (Io, Replication, Wal) as reachable inert
partials; wires one delegating arm per shared switch; pre-fills the tool-recommendation rows.

One behavioural item, the v1 residue from #3689 s5: ANOMALY_PG_WAIT_PROFILE now folds onto the wait card
its dominant contributor names (standout then rollup) and can leave the 1.49 tuning-class cap on its own
evidence through the same three readings its SQL Server twin has, anchored on its own firing multiple.

Censuses re-pinned deliberately: collect surface 12 -> 15, KnownSources 26 -> 29 (both SKUs' descriptions),
anomaly keys 5 -> 8, sources 11 -> 14, the FactScorer delegation count, the v2 whitelist tables by name.
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