Skip to content

PG_CONNECTION_SATURATION: a PostgreSQL pool near its ceiling is graded as the outage it is — the window's peak session count over max_connections − superuser_reserved_connections (the engine's own line, read off lane 2's context facts), zero below the unmeasured 0.8 band and 1.0 at 0.9, the peak capture's active / idle-in-transaction / other breakdown and both levers with their costs in the prose, and PG_MONITORING_PERMISSIONS instead of a ratio when the monitoring login could not see session state (#3542 lane 3) - #3677

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

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Headline mechanism. A PostgreSQL target's analysis pass now grades the one cliff PostgreSQL has that SQL Server does not: connection refusal. PgTargetFactCollector.Sessions.cs reads ONE query over pg_session_states — the window's PEAK capture by the denormalised total_sessions, the newest capture, and how many stored rows were state_is_redacted — and composes PG_CONNECTION_SATURATION as peak_sessions / (max_connections − superuser_reserved_connections), reading the ceiling off the two context facts lane 2's Config.cs emitted a moment earlier in the same pass (CONFIG_PG_MAX_CONNECTIONS, CONFIG_PG_SUPERUSER_RESERVED; emission order Config → Sessions, the same in-memory seam Buffer and Write use for shared_buffers / max_wal_size). PgTargetScorer.Sessions.cs scores the ratio 0 below 0.8, 0.5 at 0.8, 1.0 at 0.9. PgTargetAdvice.Sessions.cs states the division with all three numbers — 90 / (100 − 3) = 93% — and the state breakdown of the peak capture (active / idle in transaction / other), so the operator sees WHICH population fills the pool, then names both levers with their counter-objectives. When the redacted share of the stored rows reaches one half, the collector emits PG_MONITORING_PERMISSIONS INSTEAD — the login's blindness is the finding, and no ratio is built on blank rows. Partial for #3542 (v1 step 3, lane 3); do not close.

The lie it stops telling

Nothing. Until tonight a PostgreSQL target's pass had no session fact at all: a pool at 96 of 97 usable slots — one deploy, one retry storm from FATAL: too many clients already for every application on the box — produced an empty verdict with coverage 1.0. That is not a slowdown the pass under-reported; it is an outage the pass could not see. The SQL Server sibling concept is THREADPOOL, a WAIT: SQL Server queues the request. PostgreSQL does not queue — it refuses, and the design (§3.6) calls it "the harshest PG cliff" for that reason. The advice says so in those words.

Why this shape

  • The ceiling is composed at COLLECT time, from the in-memory fact list, by KEY. The plan's resolution of the lane 2 / lane 3 file overlap was that step 3 reads max_connections off step 2's context facts rather than the config snapshot. The plumbing's base-severity seam is ScoreSessionsFact(Fact) — one fact, no lookup — so the ratio has to be ON the fact before scoring; the amplifier predicates are the only scorer arm that sees factsByKey, and a ratio is not an amplifier. facts.Find(f => f.Key == PgTargetFactKeys.ConfigMaxConnections) is the whole dependency; PgTargetConfigSnapshotSql is never named here (pinned). The three numbers ride the fact as metadata so get_analysis_facts readers can redo the division.
  • Zero below the warning band, not a fraction of it (THREADPOOL's self-gating shape). ApplyThresholdFormula grades any positive value below concerning as 0.5 × value / concerning, so a pool at 40% would score 0.25 — and be "fired" to every sibling predicate that asks BaseSeverity > 0 (lane 5's Lock-wait confirmer, lane 9's session-spike fold) on every server that ever accepted a connection. Saturation scores 0 below 0.8 and 0.5 AT it; a fired saturation fact always means the pool is near its cliff. Pinned at 0.79 → 0, 0.80 → 0.5, 0.85 → 0.75, 0.90 → 1.0.
  • Redaction takes precedence over the ceiling. The V86 design comment documents the silent-failure mode measured on a live instance: without pg_read_all_stats, PostgreSQL returns every other role's backend with state, xact_start, backend_type and the text blanked — the privileged role saw four idle-in-transaction sessions, the unprivileged one zero of the same nine. Under redaction the collector's own duration floors are NULL for every backend the login does not own, so WHICH captures stored rows and what they say about the pool is the login's blindness, not the server. The advisory states the peak COUNT it saw (a bare count(*) survives redaction) and explicitly not its share of the ceiling; the saturation key is not emitted at all (pinned both at the collector and through get_analysis_facts).
  • PG_MONITORING_PERMISSIONS roots at the story line (0.5), not the D5 advisory 0.4. It is not a config convention check and is not in PgTargetFactKeys.ConfigAdvisoryRoots (a shared file this lane may not edit); at 0.4 it would never root a card, which is the silence the V86 comment says this finding exists to replace. At 0.5 it is a WARNING-band card with no amplifier arm — it cannot climb, never pages, and surfaces on the quietest server. The constant is one edit if the coordinator prefers 0.4 + root membership; the report says so.
  • Named amplifier from the fact's own breakdown, not a v2 co-fire. Design §3.6 names idle_in_transaction_sessions share as an amplifier ("parked connections consuming slots"). The PG_IDLE_IN_TRANSACTION fact is v2, so the arm reads peak_idle_in_transaction_share off the saturation fact itself (bar 0.25, unmeasured); under partial redaction the idle-in-transaction count under-reports, so this can only fail to fire, never fire falsely. The second arm is the Aurora PG_CPU_PERCENT-fired co-fire (lane 9's key; inert until its bar lands — proven inert through the real ScoreAll). The offered-vs-delivered co-fire (sessions climbing while PG_TPS is flat) and the PG_IDLE_IN_TRANSACTION co-fire are written out as commented hooks with the metadata they would need.
  • The saturation chain adds NO edge in v1, deliberately. Both destinations the chain names are other lanes' facts (PG_WAIT_LOCK* — lane 5; PG_IDLE_IN_TRANSACTION — v2); an edge whose predicate asks BaseSeverity > 0 of a fact nobody emits is inert by construction but reads as a finished chain. The two hooks are in the file for the lanes that own their destinations; pinned that AddEdge( does not appear in code and the built graph has no edge from either key.
  • work_mem in the advice is THIS server's (D4, composition without RAM): when lane 2's CONFIG_PG_WORK_MEM fact is in the set, the memory cost of raising max_connections reads "work_mem is 4 MB here, so 97 connections each running one such node could claim 388 MB"; when it is not, the cost is stated as a shape and no figure is assumed (both pinned).

Lineage of every bar (#3538 A5/A7 via #3605)

Bar Value Lineage
Ceiling max_connections − superuser_reserved_connections from the snapshot engine-defined — PostgreSQL refuses at max_connections; the reserved slots are the engine's own carve-out for superusers
ConnectionSaturationWarning / Critical 0.8 / 0.9 unmeasured — chosen, not measured; calibrate against pg_session_states (per-server max(total_sessions) per window over that server's usable ceiling, p50 / p95 across the dogfood PostgreSQL fleet); every saturation fact carries threshold_lineage = 0, including the ones graded to 0
IdleInTransactionShareBar 0.25 unmeasured — calibrate against pg_session_states (per-capture idle_in_transaction_sessions / total_sessions)
ConnectionSaturationCoFireBoost 0.25 unmeasured — calibrate against analysis_findings co-fire rates
RedactedShareMajority 0.5 definitional — the majority the key's own doc names ("share ≥ ½"); redaction is ownership-based so the observed share is ~1.0 or 0, and the line between is not what decides; NOT stamped
MonitoringPermissionsBase 0.5 policy — the story line by reference in spirit (InferenceEngine.MinimumSeverityThreshold is private); not a measurement bar

No absolute-ms gate; nothing here divides by observed time (the fact is a peak, not a rate) — the family still returns early on an unobserved window for consistency with its siblings. PgTargetThresholdLineageTests green on the file.

What the family says about its own evidence (in the prose, every time)

  • total_sessions counts every backend pg_stat_activity reports, PostgreSQL's own background processes included (the collector could not filter to client backend because backend_type redacts). Those hold no connection slot, so the ratio reads a few points HIGH of the true client share — the safe direction for a cliff detector, and the advice says so beside the number, with the "other" bucket labelled as "idle client sessions and PostgreSQL's own background processes, which this series cannot tell apart".
  • pg_session_states is an exception table: a capture with no session past the collector's floors stores nothing, so the peak is over the captures that had something to report and a pool of short OLTP sessions may leave no capture at all. captures_with_rows rides the fact; the prose says "seen over N captures that stored session rows … quiet minutes are absent from this series". collection_log (the reader's honest-empty denominator) is not an analysis table and is not read (pinned).
  • PostgreSQL 16's reserved_connections (a second carve-out, default 0) is not in lane 2's snapshot name list; on a target that set it the ceiling reads that many slots high — noted in the collector's doc, not compensated.

What it does NOT do

No schema (no rung, no StorageVersion bump). No edit to any shared file — FactScorer, FactAdvice, InferenceEngine, PgTargetFactKeys, the three root partials, Config.cs, the census test — all untouched (git diff --stat origin/dev is the five files below). No edge. No PG_IDLE_IN_TRANSACTION (v2). No offered-vs-delivered co-fire (needs a TPS trend the database family does not yet carry). No change to PgSessionStatesCollector, DarlingPgSessionStatesReader or the get_pg_session_states tool. No AnomalyThresholds PG session floors (step 9). No Lite twin (D1 — Lite has no PostgreSQL target). No DDL, no GRANT statement — the permissions remediation names the role to grant in prose.

Blast radius

A PostgreSQL target with pg_session_states rows in the window and a pg_server_config snapshot at or before its end gains at most one pg_sessions fact per pass. A target with no session rows (the plumbing e2e's fixture) is byte-identical: the CROSS JOIN against two LIMIT 1 CTEs yields no row, the family returns, empty stays empty — executed here on the live rig. SQL Server passes read none of the changed files. FactScorer.KnownSources unchanged (the source was registered by plumbing).

Test plan

New — Darling/Darling.Tests/PgTargetSessionsTests.cs (17 ungated + 1 gated), every one EXECUTED on this machine through a net10.0 harness (AssemblyName=Darling.Tests) before push, alongside the wave's rosters — PgTargetFactCollectorTests (census, AllSql closure, dialect, FROM/JOIN targets), PgTargetThresholdLineageTests, PgTargetSharedSwitchRoutingTests (delegation equality — the static blocks are non-null and equal Compose(key, ∅)), PgTargetPostureIsolationTests, PgTargetMcpSurfaceTests, FactCollectorCommandTimeoutTests, FactCollectorFailureReportingTests, StoreSqlClockDisciplineTests, FactSourceRegistryTests, RepoFileAdoptionTests, CommentFilterAdoptionTests, DocCommentHygieneTests, LiveCleanupConversionRatchetTests, LivePostgresCollectionHygieneTests: 250 / 250 green with DARLING_TEST_PG set against a throwaway timescale/timescaledb:2.28.1-pg18 migrated to V131 (the three gated e2es in the harness all ran), torn down after. First CI execution is the Windows job.

  • Scorer: the five band points (0.79 / 0.80 / 0.85 / 0.90 / 0.97) with the lineage stamp on all; no ratio → 0 and no stamp; the probe key → 0; the permissions base at exactly the majority, at 1.0, and 0 below it; the permissions fact roots a one-card story through the REAL FactScorer + InferenceEngine(PgTargetRelationshipGraph) with no amplifier results and is not an advisory root by registration.
  • Amplifiers: two definitions, both at the co-fire boost; the parked predicate at 30/90 (fires), 22/90 (does not), exactly 0.25 (fires), empty lookup (does not); the CPU predicate on fired / quiet / absent; through the real ScoreAll the parked arm lifts 1.0 → 1.25 while the CPU stub stays inert; below the warning band nothing fires and no sibling lifts it.
  • Advice: the exit-criterion string 90 / (100 − 3) = 93%, the headline, the breakdown 40 active, 30 idle in transaction, 20 other, the PARKED sentence past the bar and its absence below it, the exception-table and background-process caveats, both levers with their counter-objectives, work_mem stated from lane 2's fact (388 MB) vs stated as a shape without it, the pending-restart sentence, no CREATE INDEX; the permissions block's redacted share, the measured four-vs-zero, the peak COUNT and no usable / max_connections anywhere in it; the static blocks claim no figure and every other key handed to the partial is null.
  • Collector shape by source: one NpgsqlCommand, one const in AllSql, FROM pg_session_states only, no collection_log, no pg_server_config, the MAX() picks, the peak ordering, the row-level redacted share, both config keys read via facts.Find, the redaction gate textually before the ceiling lookup, the shared deadline and degrade, no DateTime.UtcNow, filled by lane 3.
  • Graph: no edge from either key on the built graph; AddEdge( absent from code; both hooks named in the file.
  • Gated e2e ([Collection("live-postgres")], LiveStoreCleanup.RunAsync): two servers, each with 25 h of pg_database_stats, a REAL pg_server_config snapshot 30 min before the window's end (max_connections 100, superuser_reserved_connections 3, work_mem 4096 kB — lane 2's emitter, not hand-built facts) and 48 five-minute pg_session_states captures. Busy: totals 60 with one peak of 90 (40 / 30 / 20) two hours before the end → the collector alone yields 90/97 with every metadata figure pinned (peak_age_s 7200, config_snapshot_age_s 1800, captures_with_rows 48, …), ScoreAll gives base 1.0 / severity 1.25 / lineage 0; analyze_server (anchored as_of the planted end) returns the card at severity 1.25, category pg_sessions, the exact headline, the division, the breakdown, "2 h before the window's end", the 388 MB work_mem line, get_pg_session_states among next_tools, and no permissions card. Blind: 3 rows per capture, 2 redacted → the collector yields ONLY PG_MONITORING_PERMISSIONS (2/3, 96 of 144, peak count 95, no saturation_ratio / usable_connections key), analyze_server returns its card at 0.5 with the headline and prose and no saturation card, and get_analysis_facts source=pg_sessions shows one fact per server with the lineage stamp on the ratio and none on the advisory.

Builds (zero warnings of ours): PerformanceMonitor.Analysis, PerformanceMonitor.Darling.Analysis, PerformanceMonitor.Darling.Service, Darling.Tests, Lite.Tests, deprecated/Dashboard.Tests (-p:EnableWindowsTargeting=true for the test projects). The one Lite.Tests warning (LogTailOverlapThresholdPinTests.cs:95 xUnit2000) is pre-existing on dev from #3646.

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. Live store: throwaway timescale/timescaledb:2.28.1-pg18 on Docker context desktop-linux, migrated to the current ladder (V131), torn down after. No production store or fleet server was read. No SQL Server surface is touched.

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 files)
  • I have not introduced any hardcoded credentials or server names

Partial for #3542 (v1 step 3, lane 3). CHANGELOG deliberately untouched — the coordinator splices this wave's entries.

…inst the engine's own ceiling — the window's peak session count from pg_session_states over max_connections − superuser_reserved_connections read off lane 2's context facts, zero below the unmeasured 0.8 band and 1.0 at 0.9 with threshold_lineage = 0, the peak capture's state breakdown and both levers with their counter-objectives in the prose, and PG_MONITORING_PERMISSIONS in its place when the monitoring login could not see session state (#3542 lane 3)
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