Skip to content

The vacuum family of a PostgreSQL analysis pass: a table persistently past its own autovacuum trigger line is named with its ratio, slope and whether autovacuum ran and lost, the wraparound counter and the xmin holder grade on the very constants the Tier-0 alerts page on, and the three chain into one story (#3542 lane 4) - #3674

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

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Headline mechanism. A PostgreSQL target's analysis pass now has its vacuum family — the [D3] "workload-triggered maintenance" failure class the design names as the one the OtterTune authors lacked metrics for. Three facts under pg_vacuum, each read from a table the collectors already keep, each graded against a line PostgreSQL itself draws, chained into one story with value-stated advice:

  • PG_AUTOVACUUM_BACKLOG — from pg_autovacuum_stats (hourly, per table, the collector having already computed each table's OWN trigger line with reloptions honoured). The fact is the worst table whose latest three consecutive hourly samples sit past that line — the dead-tuple arm (n_dead_tup > autovacuum_vacuum_threshold + scale_factor × n_live_tup) or the append-only insert arm — with the ratio to its own threshold, the slope of the gauge across the backlog run (a level, differenced by the run's own span — never the nominal window, never an assumed cadence), the autovacuum runs during the run (the cumulative autovacuum_count differenced, never summed; a negative difference is a statistics reset and reads as "unknown", not zero), last_autovacuum, the per-table autovacuum_enabled reloption, and how many other tables met the gate. One fact per pass, the way ANOMALY_OBJECT_GROWTH carries its one table in ObjectName — the engine keys facts by Key, and the rest of the list is get_pg_autovacuum_health.
  • PG_WRAPAROUND_TREND — from pg_wraparound_stats: the latest reading per database with the window's peak and first reading per counter. XID and MultiXact are graded separately against their own settings (PostgresAlertInfo's per-counter argument, never collapsed) through the same four arms the alert evaluator walks — vacuum_failsafe_age's 74.5% of the 2^31 space, half the space, 2× and 1.0× autovacuum_freeze_max_age with the PG wraparound WARNING fires on every healthy database — 0.9 × autovacuum_freeze_max_age is routine, not a risk #2689 "keeping up" gate (latest below the window peak). The relatively-worse (database, counter) carries the fact. Analysis adds what only analysis can: the window slope → hours to the wall, with an explicit not-computable branch when the age fell or held.
  • PG_XMIN_HOLD — from pg_xmin_horizon: the latest winning holder and how persistently that (source, holder) won — the alert's identity arm on the alert's bars (≥ 50M back, majority of holder-bearing collections, ≥ 5 observations). The fact carries holder_source (encoded) and source:holder as ObjectName, so the advice names the fix per kind: end the idle-in-transaction session, drop the abandoned slot, fix the logical consumer, disable the standby's feedback, COMMIT|ROLLBACK PREPARED. Severity ramps from 0.5 at the bar to 1.0 where the held age reaches the server's own autovacuum_freeze_max_age — engine-defined, because a horizon held that far back stops the forced anti-wraparound vacuum advancing relfrozenxid (that IS §3.3's chain, as a bar); flat at 0.5 when the wraparound read supplied no setting.

D9, made structural. The eight outage-predictor constants (WraparoundWarningFractionOfFreezeMaxAge, …CriticalMultiple…, WraparoundCeiling, the two ceiling fractions, XminAgeWarningThreshold, XminPersistenceFraction, XminMinimumObservations, plus SlotRetainedWalWarningBytes for completeness) move to PostgresOutagePredictorThresholds with their full derivation comments; PostgresAlertEvaluator keeps every name as an alias (public const X = PostgresOutagePredictorThresholds.X, the shape its poison-wait constants already take from PoisonWaitEvaluator), so the read adapter, host, MCP wraparound tool and tests keep compiling. Three pins hold it: value equality of every alias; a source pin that neither PostgresAlertEvaluator.cs nor PgTargetScorer.Vacuum.cs carries 0.745 / 50_000_000 / 2_147_483_648 in code and that each evaluator declaration IS an alias; and a grid pin that walks fourteen (age, setting, keeping-up) points through PostgresAlertEvaluator.EvaluateWraparound and PgTargetScorer.GradeWraparoundCounter and asserts null ↔ 0, Warning ↔ [0.5, 1), Critical ↔ 1.0.

Where the class lives, and why not Common: the plan said PerformanceMonitor.Common. PerformanceMonitor.Analysis references nothing — no project, no package — by design, and a Common reference would drag ModelContextProtocol + CredentialManagement into its closure and rewrite six locked packages.lock.json files for one static class. Alerting already reaches Analysis transitively (Alerting → Notifications → Analysis; AnalysisNotificationService is the existing consumer), so the class sits in Analysis at zero project-edge cost. The doc comment on the class states this.

Lineage of every bar (PgTargetThresholdLineageTests executed green)

Bar Shape
Backlog concerning = ratio 1.0 (the table's own trigger line) engine-defined
Backlog critical = 10× its line (BacklogCriticalRatio) unmeasured — fact carries threshold_lineage = 0
Backlog persistence = 3 consecutive hourly samples (BacklogPersistenceSamples, bound as $4) unmeasured — stated as judgment (three hourly captures ≈ a hundred-odd autovacuum_naptime offers)
Wraparound: all four arms engine-defined — the shared PostgresOutagePredictorThresholds symbols
Xmin: age bar, majority fraction, minimum observations engine-defined — shared symbols
Xmin critical = the server's own autovacuum_freeze_max_age engine-defined
Every amplifier boost (0.2–0.5), BacklogManyTables = 5 unmeasured — marked

No absolute-ms gate anywhere; slopes divide by the series' own timestamps.

Co-fire gating and the chain

Amplifiers (all read the sibling's BaseSeverity, never Severity — see below): backlog ← rising slope AND autovacuum ran (running and losing), per-table autovacuum_enabled off, PG_XMIN_HOLD, CONFIG_PG_AUTOVACUUM_OFF, ≥ 5 tables in backlog; wraparound ← hold, backlog; hold ← wraparound, backlog; CONFIG_PG_AUTOVACUUM_OFF / CONFIG_PG_MAINT_WORK_MEM ← backlog (their D5 half — the base is the config lane's ScoreConfigFact, their source being pg_config).

Graph: the three facts are a mesh (both directions, six edges) with the two config keys as leaves under the backlog. The engine roots at the highest amplified severity and each fact lifts the others, so a one-directional chain rooted at the wraparound let the hold root first and split the incident into three one-fact stories — executing the first draft's pins showed exactly that. The mesh is the shape the SQL Server CPU chain (SOS_SCHEDULER_YIELD ↔ CXPACKET ↔ THREADPOOL) already takes; every edge description names its hop's mechanism so it reads correctly from either end. Pinned: three facts → one story, wraparound-led in one arrangement, hold-led in another, backlog-led in the live e2e.

Advice (OtterTune doctrine)

Every number read from the fact: the table's threshold "sits at 10.5% of the table", the slope per hour, the run count, the setting the counter is graded against, the holder's own age and win fraction. Every recommendation names its counter-objective. The per-table statements carry the table's own figures — ALTER TABLE public.hot SET (autovacuum_vacuum_cost_delay = 0) (0 is the engine's own "unthrottled") and SET (autovacuum_vacuum_scale_factor = 0.053) (half the table's current effective fraction, computed, not a folklore constant) — in prose, not RemediationTsql. Wraparound states routine vs emergency by arm (at the setting and not recovering → confirm the forced vacuum is winning; past 2× or vacuum_failsafe_age → VACUUM (FREEZE) now with vacuum_cost_delay = 0), and every vacuum block says never disable autovacuum — a regex pin holds that no block recommends set/turn/switch autovacuum off or autovacuum_enabled = false while allowing the prohibition to be stated. No CREATE INDEX (D8).

What it does NOT do

Blast radius

PostgresAlertEvaluator: constants aliased, behaviour byte-identical (PostgresAlertEvaluatorTests 35/35 green here; the grid pin cross-checks). Every other change is inside the four lane-4 partials, two new test files and the new constants class. The plumbing e2e (Assert.Single(facts) on a store with no vacuum rows) still holds because every read is evidence-gated: no rows → no fact.

Test plan

New (compile-verified AND executed here through a net10.0 harness — AssemblyName=Darling.Tests — against a throwaway timescale/timescaledb:2.28.1-pg18 on desktop-linux, migrated to V130; 376 green, 0 skipped, incl. the gated e2e; first CI execution is the Windows job):

  • PgTargetVacuumTests (53): D9 alias equality; source pin (no retyped literal, alias shape); 14-point grader agreement grid; warning-ramp ordering; backlog persistence gate + ratio ramp + lineage flag; amplifier predicates (slope×runs, reloption, hold co-fire both ways); xmin identity arm (5 rows incl. the The xmin horizon alert false-fires on 1-of-1 observations and misses rotating holders #3537 3-of-4 false-fire shape) + freeze-age ramp + degenerate setting; holder-source round trip; three-fact one-story wraparound-led; hold-led one-story; two-fact reach; healthy sawtooth + transient holder → server_health; advice value pins (backlog with slope/runs/ALTER statements, insert arm + not-computable branches, wraparound routine vs emergency + time-to-wall + MultiXact naming, xmin per-source remedy ×6, transient-as-context, static blocks for all five keys, config co-fire blocks).
  • PgTargetVacuumLiveTests (gated, [Collection("live-postgres")], LiveStoreCleanup): plants 3 tables × 5 hourly samples (rising-while-running, insert-arm, a run-of-two that must NOT qualify), 2 databases × 49 wraparound readings (climbing at the setting; healthy sawtooth), 41 xmin captures (31 session wins, 10 slot wins) + the 25 h span/coverage series; asserts every metadata number (ratio 5.0, slope 1083.3/h, 2 runs, 2 tables in backlog; arm 1, slope 5M/h, 385.5 h to wall; 31/41, peak 70M, freeze age handed across), the scores, then drives the REAL analyze_server: findings, ONE story PG_AUTOVACUUM_BACKLOG → PG_WRAPAROUND_TREND → PG_XMIN_HOLD, no second root for the other two, advice text, next_tools = get_pg_* only; get_analysis_facts source=pg_vacuum shows the three.

Pre-checked rosters (all executed green): PgTargetThresholdLineageTests, PgTargetFactCollectorTests (census, AllSql closure, dialect, FROM-target set, e2e), PgTargetSharedSwitchRoutingTests, PgTargetMcpSurfaceTests, FactCollectorCommandTimeoutTests, FactCollectorFailureReportingTests, AnalysisPassCommandTimeoutTests, AnalysisPassTokenThreadingTests, StoreSqlClockDisciplineTests, FactSourceRegistryTests, LiveCleanupConversionRatchetTests, RepoFileAdoptionTests (no LF reader used → no roster entry), CommentFilterAdoptionTests, DocCommentHygieneTests, AsOfWindowAnchorTests, PostgresAlertEvaluatorTests, DarlingPgXminReaderTests.

Two defects the execution caught that compile-only would have shipped: (1) amplifier predicates reading Severity matched by emission order (Layer 2 assigns it fact-by-fact) — now BaseSeverity; (2) the one-directional chain splitting the incident — now a mesh.

Builds, zero warnings of ours: PerformanceMonitor.Analysis, PerformanceMonitor.Alerting, PerformanceMonitor.Darling.Analysis, PerformanceMonitor.Darling.Service, Darling.Tests, Lite.Tests (the one xUnit2000 in LogTailOverlapThresholdPinTests.cs:95 is pre-existing on dev), deprecated/Dashboard.Tests, all with -p:EnableWindowsTargeting=true where windows-targeted.

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. Throwaway TimescaleDB 2.28.1 / PostgreSQL 18 container on Docker context desktop-linux, torn down. No production store or fleet server was read.

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 (no SQL Server read is touched; the alert evaluator's behaviour is pinned byte-identical by its existing suite)
  • I have not introduced any hardcoded credentials or server names

Partial for #3542 (lane 4, the vacuum family). CHANGELOG deliberately untouched — the coordinator splices this wave's entries.

… past its own autovacuum trigger line is named with the ratio to that line, its slope and whether autovacuum ran and lost; the wraparound counter and the xmin holder are graded on the very constants the Tier-0 alerts page on, moved to one shared definition; and the three chain into one story (#3542 lane 4)

PG_AUTOVACUUM_BACKLOG reads pg_autovacuum_stats and emits one fact for the worst table whose latest three
hourly samples sit past the engine's own per-table line (dead/threshold or the insert arm), with the slope of
the gauge over the run, the cumulative autovacuum_count differenced (never summed), and the per-table
autovacuum_enabled reloption. PG_WRAPAROUND_TREND reads pg_wraparound_stats, grades XID and MultiXact
separately against their own settings through the same four arms PostgresAlertEvaluator walks, and adds the
slope → time-to-wall estimate with its not-computable branch. PG_XMIN_HOLD reads pg_xmin_horizon and grades
the alert's identity arm (majority of holder-bearing collections, ≥ 5 observations, ≥ 50M back), naming the
holder kind so the advice states the fix per source.

D9: the eight outage-predictor constants move to PostgresOutagePredictorThresholds in PerformanceMonitor.Analysis
(the assembly has no project references and Alerting already reaches it through Notifications, so no
csproj or lock file moves); PostgresAlertEvaluator keeps the names as aliases, and a source pin holds that
neither consumer retypes a literal. A grid pin walks both graders and asserts the verdicts agree.

Lineage: the wraparound and xmin bars are engine-defined (the shared symbols); the backlog's concerning
bar is the engine's own trigger line, its 10× critical multiple and the three-sample persistence gate are
unmeasured and the fact carries threshold_lineage = 0; every amplifier boost is marked unmeasured.

Executed here through a net10.0 harness against a throwaway TimescaleDB/PG18 store (376 green incl. the
gated e2e); the execution caught two defects reading would not have: amplifier predicates must read
BaseSeverity (Severity is assigned fact-by-fact), and a one-directional chain lets an amplified leaf root
first and split the incident — the chain is a mesh, as the SQL Server CPU chain is.
@erikdarlingdata
erikdarlingdata merged commit ddd9b28 into dev Sep 19, 2026
5 of 7 checks passed
@erikdarlingdata
erikdarlingdata deleted the feat/3542-pg-vacuum-family branch September 19, 2026 01:06
erikdarlingdata added a commit that referenced this pull request Sep 22, 2026
…three PostgreSQL cards stop hiding table names in metadata keys (#3691) (#3864)

* A summary fact can rank its top few objects by name, so a card stops saying "two more, 3.1x for 5 h" without saying which (#3691)

Fact carried one string per scope (DatabaseName, ObjectName) and a doubles-only
Metadata, so a top-N card could name exactly one object. Three closed lanes each
worked around it differently: #3761 rode ranks 2 and 3 as numbers without names,
#3722 encoded the name into the metadata KEY (growth_bytes_<schema.table>), #3674
capped its read at one row and deferred the list to a tool.

Fact.Ranked is a typed, capped list of RankedObject (ObjectName, DatabaseName,
Value, Figures) whose [0] IS the fact's own subject, so a card has one truth about
what it is about. Cap three (FactRanked.MaxObjects) - the card is a summary, not
the list. Fact is never persisted (only AnalysisFinding is; get_analysis_facts
re-runs the pass), so the seam is ephemeral by construction: no migration rung, no
store column. Metadata stays doubles-only.

FactAdvice.NameTheRest is the one grammar for the clause every migrated family
gains ("; and two more: public.orders (3.1x for 5 hours), ..."), empty below two
ranked objects - which is the byte-identity arm for every fact that ranks nothing,
including every SQL Server fact. Each family passes its own figures formatter
because each ranks in its own units.

* The autovacuum-disabled card names the other disabled tables instead of describing their shape (#3691, #3761)

M1 of the Fact.Ranked migration. The disabled read has returned three rows since
#3761, but only the worst one had a name: ranks 2 and 3 rode as
disabled_rank_2_ratio / disabled_rank_2_hours, doubles in a doubles-only
dictionary, so the best the card could do was "2 more disabled tables have met
the same gate (3.1x for 5 h, 1.4x for 2 h)" - the reader was told the shape of two
problems and had to call a tool to learn which tables they were.

Every returned row now rides on Fact.Ranked with its name, its ratio and its
hours, the worst included as Ranked[0] (the seam's invariant: [0] IS the fact's
ObjectName/Value). The clause lands on the sentence that states the SUBJECT's
ratio and hours, through FactAdvice.NameTheRest, precisely because [0] is the
subject - "and two more" can then only be read as "besides the table just
described", never against the population count that follows. That population
sentence keeps its own number and its own pointer to get_pg_autovacuum_health: the
gate's population can exceed the three rows the read returns, so the named clause
is a subset of it on purpose.

The two rank-key builders are deleted rather than left beside Ranked. Two places
to read the same rank is how a card ends up disagreeing with itself. A row whose
name triple is entirely null is not ranked at all - an unnamed entry would break
the list's own promise. The row limit is now FactRanked.MaxObjects by name, which
is the three this read chose first and the seam then adopted.

Executed on macOS against the real composer before the pins were written.

* The bloat trends' top objects stop hiding their names inside metadata keys (#3691, #3722)

M2 of the Fact.Ranked migration. Since #3722 the two bloat trends have carried
their top three objects as KEY-ENCODED metadata: growth_bytes_<schema.table>,
growth_pct_<...>, dead_tuples_<...> - the object's name inside the key, because
Fact.Metadata is doubles-only - and PgTargetScorer.BloatNamedObjects recovered
those names with a StartsWith test over every key on the fact. A naming convention
doing a type's job, and the graph predicate that reads it had to trust that the
convention held.

The rows now ride on Fact.Ranked, one typed entry per returned row with its name,
its database and its three figures under the fact's own UN-PREFIXED metadata names
(growth_bytes, growth_pct, dead_tuples), the worst as Ranked[0] per the seam's
invariant. The three prefixes are deleted, not kept beside the list: while both
existed an object could be named in one and not the other, and the graph predicate
reading one would disagree with the advice reading the other.

BloatNamedObjects keeps its signature and its semantics exactly - it is a
graph/amplifier predicate helper (PgTargetRelationshipGraph.Bloat, two scorer
amplifiers, the index card's table co-fire) - and only its SOURCE moved, including
the parent-table projection that turns schema.table.index into schema.table. It
still unions Fact.ObjectName in rather than trusting Ranked[0], because hand-built
facts reach it and a fact that names an object without ranking must still match.

The cards' prose is byte-identical: "Next by growth: public.events (300 MB, 40%,
20,000 dead tuples now); sales.ledger (10 MB)." renders the same characters
through the shared NameTheRestList grammar, executed on macOS against the real
composer before the pins moved. A fact with one object over the floor emits no
such sentence at all, exactly as before. The database-growth family's
growth_bytes_<name> encoding (PgTargetAdvice.Growth.cs) is a different family and
is out of this lane; its reader stays where it was, named as a candidate for the
same move.

* [WIP M3] The backlog card reads three rows and ranks two more tables beside the worst (#3691, #3674) — advice prose and pins unfinished

* M3 done: the backlog card's live pins name the tables it ranks (#3691, #3674)

* Both engines' analysis payloads name the objects their cards rank, instead of leaving an agent to parse them out of the prose (#3691)

* The ranked seam's own pins: the invariant every ranking collector owes, the payload's two-or-more rule, and both SKUs' parity (#3691)
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