Skip to content

Darling monitors PostgreSQL and Amazon Aurora PostgreSQL - #2213

Open
erikdarlingdata wants to merge 43 commits into
devfrom
feat/postgres-target-connection-seam
Open

Darling monitors PostgreSQL and Amazon Aurora PostgreSQL#2213
erikdarlingdata wants to merge 43 commits into
devfrom
feat/postgres-target-connection-seam

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Teaches Darling to monitor PostgreSQL and Amazon Aurora PostgreSQL alongside SQL Server. A monitored
server declares "engine": "postgres" and is collected by seven PostgreSQL collectors instead of the T-SQL
ones, into the same store, on the same naive-UTC contract and the same server_id identity. A mixed fleet
is one store, one viewer, one MCP endpoint — nothing is partitioned by engine.

Every collector definition declares the engine it targets and is never dispatched against the other one, so
a PostgreSQL target is never sent T-SQL and a SQL Server target never sees pg_stat_statements. A store
monitoring only SQL Server gains seven empty tables and nothing else changes.

What gets collected, and why each earns its place:

Collector Source Cadence Why
pg_wait_stats aurora_stat_system_waits() 1 min Aurora only. Core PostgreSQL has no cumulative wait counters in ANY version — pg_stat_activity.wait_event is an instantaneous sample, so there is no dm_os_wait_stats equivalent to read elsewhere
pg_statement_stats aurora_stat_statements() 1 min Aurora only. Per-query-shape totals, plus the storage-vs-cache I/O split and per-statement peak memory that stock PostgreSQL does not expose
pg_wraparound_stats pg_database, pg_class 5 min XID and MultiXact freeze headroom. Highest-consequence signal PostgreSQL has, no SQL Server counterpart: run out and the server stops accepting writes
pg_xmin_horizon pg_stat_activity, pg_replication_slots, pg_stat_replication, pg_prepared_xacts 1 min Why vacuum reclaims nothing. Five unrelated causes, identical symptom, completely different fixes — so it attributes the specific holder rather than reporting a number
pg_replication_slots pg_replication_slots 1 min An abandoned slot retains WAL without bound AND pins the vacuum horizon. Two independent ways to take a server down
pg_autovacuum_stats pg_stat_user_tables, pg_class 60 min Writers only, per database. Stores each table's OWN computed threshold beside its dead-tuple count, honouring per-table reloptions — without the threshold the count is not actionable
pg_io_stats pg_stat_io 1 min I/O by (backend_type, object, context) rather than by file. PostgreSQL 16+, valid on a standby

Three of the seven are outage predictors rather than performance metrics, and they alert — graded against
the target's own settings (wraparound against that cluster's autovacuum_freeze_max_age, not a constant),
delivered through the same deliverer, history and mute rules as every SQL Server alert. That is deliberate:
PostgreSQL's most damaging failures are quiet, slow, fully predictable days ahead, and nothing in the engine
raises its hand about them.

Also here: the engine-execution seam (ITargetProvider), per-database fan-out for PostgreSQL, fault
classification so an operator-actionable condition records a non-fatal skip with an explanation instead of
logging ERROR every cycle, and a read surface + MCP tool per collector.

Permissions are one GRANT pg_monitor. Nothing is created on the monitored instance — no Extended Events
sessions to provision, no server setting to bootstrap.

Two defects found while writing the runbook, worth calling out for review

  1. config.config_monitored_servers had no engine or port column. That registry is authoritative for
    the server list once seeded, so a PostgreSQL entry round-tripped as "sqlserver" (the property default)
    and the service opened a SqlConnection to port 5432 — on the FIRST start, since the seed is immediately
    followed by the load that replaces the file's list. Fixed by V68 plus the seed/read paths and
    add_servers. A new property-driven test fails when any round-trip-critical MonitoredServer property
    lacks a column.
  2. MapProbedSchemaVersion's newest arm was V60. RequiredStoreSchemaVersion is
    StorageVersion.SchemaVersion and the connect-time gate refuses a store below it, so a fully-migrated
    store would have been rejected as "older than this viewer". V68 gets an arm.

Also fixed seven stale literal pins in test files that the PostgreSQL rungs had been silently breaking since
V60, four of which are now asserted as the invariant the test's own name states rather than a literal.

Which component(s) does this affect?

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

PerformanceMonitor.Collectors is shared with Lite, so Lite rebuilds — but no Lite behaviour changes. Lite
has no PostgreSQL target, and the alerting deliberately rides a separate IPostgresAlertReadAdapter
consulted only for PostgreSQL targets rather than extending the shared IAlertReadAdapter, precisely so Lite
is not left implementing three methods that can only return empty. AlertEngine is untouched.

How was this tested?

Please read this section rather than the checklist — the honest answer is more specific than the boxes.

Verified against live Amazon Aurora PostgreSQL 16.11 and 17.7 (stage):

  • Every collector's generated SQL executes on both majors, returning the expected shape and real data
    (pg_stat_io: 37 rows on 16.11, 25 on 17.7, carrying Aurora-specific enum values the community docs do not
    list, e.g. aurora cache receiver process).
  • Every naive-declared column comes back carrying no timezone. This caught a real defect:
    pg_replication_slots.inactive_since and the four autovacuum timestamps are timestamptz, and Npgsql 10
    refuses to write a DateTime(Kind=Utc) into timestamp without time zone, so it would have failed at
    COPY time in production. Every probed instance is TimeZone=UTC, which is exactly what hides the wrong
    cast form.
  • Aurora's pg_stat_io write side really is NULL, not zero — backends there do not write data files. Every
    counter column is nullable on purpose and the read reports whether writes are TRACKED, so absent writes
    cannot be misread as no writes.
  • Each MCP reader's SQL executed on real Aurora with synthetic VALUES tables substituted for the store
    tables, asserting the arithmetic: counter-reset clamping, NULL handling, single-sample → 0, and the ratio
    ranking (a 10k-row table at 50× its threshold outranks a 50M-row table at 2.5×).
  • pg_autovacuum_stats is gated off standbys because pg_stat_user_tables reads fine on an Aurora reader and
    reports all zeros — measured on 17.7, same cluster and tables: writer 13,654,458 dead tuples, reader 0.
    Ungated, a replica target would have produced a confident report of perfect autovacuum health.

Every migration rung was diffed against the ladder generator, since V1 is generated from the collector
catalog and a hand-written rung must match it column-for-column.

Not tested, and I would rather say so than have it found in review:

  • Darling.Tests and Lite.Tests have never executed for this branch. They target net10.0-windows and
    reference the WPF Viewer, so they compile on macOS and cannot run there. ~200 new xUnit assertions are
    unexecuted. Local substitutes were used instead — the ladder-generator diff, standalone console harnesses
    referencing the real projects, live SQL probes, and source-scanning reproductions of the reflection-based
    MCP parity test and the DocCommentHygieneTests stacked-summary scan (the latter caught a real violation
    in this branch). CI is the first real run.
  • The service has never been pointed at a PostgreSQL target. Nothing has done connect probe → collector
    dispatch → binary COPY into a store → MCP read back out. The queries are proven; the wiring around them is
    not. docs/postgres-first-target-runbook.md is the procedure for closing that gap, with a proof point at
    every step, and it says plainly that it has not been executed.

dotnet build -c Debug0 errors, 16 warnings, all pre-existing on dev (the one warning this branch
introduced, a CA1859 on a new field, is fixed).

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug) — 0 errors and zero NEW warnings; 16
    pre-existing warnings remain, unchanged from dev.
    Not ticking a box I cannot honestly tick.
  • I have tested my changes against at least one SQL Server version — read as PostgreSQL here: live
    Aurora 16.11 and 17.7. No SQL Server behaviour changes; the shared-catalog changes are engine-gated.
  • I have not introduced any hardcoded credentials or server names

erikdarlingdata and others added 30 commits August 11, 2026 12:28
docs/how-collection-works.md described only the deprecated Full/Dashboard
edition (SQL Agent + T-SQL procs) and an out-of-date view of Lite in which
the DMV queries lived in RemoteCollectorService partials. Neither matches
the code: the 41 collector definitions live in the shared
PerformanceMonitor.Collectors library, Lite's partials are thin
delegations to those definitions, and Darling was not covered at all.

Rewritten around the actual shape - one collection brain, two storage
engines - documenting the definition model and its opt-in members, the
three registration tables, Darling's sweep loop and error/health
derivation, the store's column and partitioning conventions, schedule
override precedence, and the three retention mechanisms. Full/Dashboard
is now called out as deprecated with pointers to its own docs.

Also corrects counts that had drifted from the catalog:

- README.md and Darling/README.md said 38 collectors; the catalog and
  CollectorScheduleDefaults both hold 41
- README.md's collector table was missing database_states (1 min, feeds
  the database offline/unhealthy alert)
- Darling/README.md said schema v29; StorageVersion.SchemaVersion is 59.
  Points at the generated migration-ladder fixture as the complete
  schema rather than enumerating every rung, since that list is what
  drifted
- Darling/README.md's background-worker sizing said 41/52 for 39
  hypertables; HypertableCount is CollectorCatalog.All + 1 = 42, so the
  derivation gives 44 and 55
- llms.txt said 32 collectors and named Full + Lite as the two editions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groundwork for monitoring PostgreSQL targets. Nothing about the current
behaviour changes: both the definition side and the target side default
to CollectorTargetEngine.SqlServer, so every one of the 41 definitions
and every target the probes classify today gate exactly as before.

- CollectorTargetEngine: SqlServer / PostgreSql. Deliberately about SQL
  dialect, not hosting - Azure SQL DB, Managed Instance, and RDS for SQL
  Server are all SqlServer, since their differences are already carried
  as flags on CollectorTargetInfo and they run the same T-SQL.
- CollectorTargetInfo.Engine: what the target actually is.
- ICollectorSchemaInfo.TargetEngine: what dialect the definition speaks.
  A default interface implementation rather than a required member, so
  the two test doubles that implement this interface directly, and all
  41 definitions, need no change.
- CollectorCatalog.AppliesTo(definition, target): the composed gate -
  engine match AND the definition's own AppliesTo. The runners now call
  this instead of AppliesTo directly, which is what makes it impossible
  to dispatch a T-SQL definition at a non-SQL-Server target. Individual
  definitions stay free to reason only about hosting flavour and version
  floors within their own engine, and the 27 existing AppliesTo
  overrides are untouched.

Verified: PerformanceMonitor.Collectors, Darling.Service, Darling.Tests,
and Lite all build clean. The test suites target net10.0-windows and
cannot be executed on macOS (no WindowsDesktop runtime), so they were
compile-verified only and still need a run on Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the runner

Two gaps in the engine discriminator, both found by asking what a second
engine in one catalog actually does to the sweep.

1. A gated collector was still being logged. The runner's AppliesTo check
   returns zero rows, and RunOneAsync records that as SUCCESS - fine for
   the handful of Azure-gated collectors, but with two engines every
   target would log a fake success per foreign collector per cycle, most
   of them at a 1-minute cadence. That floods collection_log (60-day
   retention) and feeds phantom successes to the health bands and
   analysis, which key on status rather than row count.

   RunDueCollectorsAsync now drops wrong-engine collectors before the
   due-check via CollectorCatalog.EngineMatches: no dispatch, no log row,
   no NextDue churn. This is Darling's equivalent of Lite's pre-dispatch
   SKIPPED path.

2. TargetEngine defaulting to SqlServer is right for the 41 existing
   definitions but a silent footgun for the next one: a Postgres
   definition that forgot the override would advertise itself as T-SQL
   and be dispatched at SQL Server targets, failing every cycle.
   PostgresCollectorDefinitionBase seals TargetEngine to PostgreSql, so
   the dialect is structural rather than a line to remember, and a
   drift-guard test asserts every catalog definition is SqlServer today
   and names any that isn't.

Tests added in Lite.Tests/CollectorTargetEngineGateTests.cs: the drift
guard, the target default, both directions of the cross-engine gate,
that the composed gate still honours within-engine gating (agent_status
on RDS), and that an unknown name is not filtered.

Full solution builds clean. The test projects target net10.0-windows and
were compile-verified only - execution still needs Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Postgres

The collector definitions were already engine-neutral - they return query
text plus parameters and read through DbDataReader, and the collectors
library has zero PackageReferences. What was missing was the runner: it
constructed SqlConnection directly and mapped parameters to SqlDbType, so
nothing but SQL Server could ever execute.

- ITargetProvider (in the collectors library, System.Data.Common only, so
  its zero-dependency property holds): CreateConnection, CreateCommand,
  Classify.
- CollectorTargetFault names failures the way the collection loop reasons
  about them - Permissions, LockTimeoutYield, SessionMissing,
  ObjectMissing, FeatureDisabled, CommandTimeout, ConnectionFatal -
  instead of in one engine's error numbers. Unclassified is the default so
  an unexpected failure stays loud.
- SqlServerTargetProvider is a lift of the existing inline code; the
  parameter mapping and its throw-on-unmapped-type are unchanged, and
  Classify reproduces the error numbers the existing catch filters use. It
  deliberately does NOT produce SessionMissing: whether a 297 means "XE
  session gone" or "permission denied" is collector context, and the
  worker already raises its own exception type for that case.
- PostgresTargetProvider classifies by SQLSTATE, not message text.
  Every code was observed while probing our Aurora fleet: 42501 from a
  function needing rds_replication, 42P01 from pg_stat_statements in a
  database where the view was never created, 0A000 from pg_stat_wal
  (Aurora blocks it outright), 55-class from a feature that raises rather
  than returning empty when disabled. Npgsql was already a dependency -
  it is the store driver - so this needed no new package.
  DateTime2 maps to timestamp WITHOUT time zone to match the store's
  naive-UTC convention; timestamptz would make Npgsql reject the
  DateTimeKind.Unspecified values this product uses everywhere.

The two engine-agnostic runner paths now go through the provider. The
Azure per-database and master-enumeration paths stay SqlConnection-typed
because they are SQL Server features by definition, but they share the one
parameter mapping so a type cannot be mapped two ways.

Error-classification catch sites are NOT rewired here - that is a separate
change where catch ordering has to be reasoned about carefully. Until then
a Postgres failure falls through to the generic handler and is recorded as
ERROR, which is honest if coarse.

Tests: provider resolution for every declared engine, correct driver types,
the parameter mapping is total on both engines, null becomes DBNull,
wrong-engine connections are rejected, timeouts apply, and the Postgres
SQLSTATE table. Full solution builds clean; the test projects target
net10.0-windows so they were compile-verified only and still need a run on
Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the path from config to a probed target, which is what actually
makes the engine discriminator fire: nothing set CollectorTargetInfo.Engine
to PostgreSql before this, so no Postgres collector could ever dispatch.

- MonitoredServer.engine ("sqlserver" default, or postgres/postgresql/pg/
  aurora-postgresql/aurora). This is configuration rather than something
  probed because it has to be known BEFORE connecting - it decides which
  driver builds the connection string and which detection query runs. An
  omitted or misspelled value resolves to SQL Server rather than throwing,
  so every darling.json in the field keeps its exact present behaviour and
  one bad entry cannot stop the service from starting.
- MonitoredServer.port, for Postgres targets on a non-default port. SQL
  Server keeps its host,1433 convention; nothing there changes.
- A Postgres branch in the connection-string builder holding the same
  posture as the SQL Server path: 15s connect, 60s command, TLS
  fail-closed, and an application name visible in pg_stat_activity. MARS,
  ApplicationIntent, and MultiSubnetFailover are absent because the
  concepts do not exist - a Postgres read replica is its own endpoint, so
  point the entry at the reader's host. TrustServerCertificate maps to
  Require rather than disabling TLS, since the case it covers is Aurora
  presenting an RDS CA a stock trust store does not know. Integrated auth
  is rejected loudly instead of producing a string that cannot
  authenticate and failing further from the cause.
- A Postgres detection query built only from surfaces a pg_monitor login
  can read, verified against live Aurora 16.11 and 17.7: server_version_num
  (a division, not version() text parsing - that formatting has changed
  across releases), pg_is_in_recovery(), and a pg_proc lookup for
  aurora_version so stock PostgreSQL reads as "not Aurora" instead of
  failing the probe.
- Four Postgres facts on CollectorTargetInfo. PostgresMajorVersion gates
  the real 16->17 breaks; PostgresVersionNum exists because some gates are
  minor-level (aurora_stat_resource_usage needs 16.9+/17.5+ and is absent
  on 17.4, so a major-only check would call a function that is not there);
  IsAurora gates the proprietary surface, most importantly the cumulative
  wait counters core PostgreSQL does not have; IsInRecovery marks a reader,
  which on Aurora is a distinct monitoring identity with its own statistics
  rather than a shadow of the writer.

The SQL Server-only facts stay at their defaults on a Postgres target
because no Postgres definition consults them, and the engine check keeps
every T-SQL definition away regardless.

Tests: the SQL Server default when engine is absent, every accepted
spelling, typo fallback, the built connection posture, port handling,
the two TLS relaxations, both auth rejections, shared storage identity,
and that no T-SQL leaked into the Postgres detection query.

Full solution builds clean. Test projects target net10.0-windows so they
were compile-verified only and still need a run on Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cumulative Aurora wait counters with deltas computed on write - the
Postgres counterpart of wait_stats, and the signal Postgres monitoring
normally cannot provide at all.

Core PostgreSQL has no cumulative wait accounting in any version: only the
instantaneous pg_stat_activity.wait_event, with accumulation proposed and
rejected twice on overhead grounds. The usual workaround is a sampling
extension, and Aurora permits exactly thirteen preloadable libraries, none
of which are pg_wait_sampling, pgsentinel, or pg_stat_kcache. Aurora
instead exposes aurora_stat_system_waits() as a built-in, so on Aurora it
is not the convenient source, it is the only one - hence the gate is
IsAurora rather than a version.

Signatures in the query are verified against live 16.11 and 17.7, and the
AWS reference is wrong about one: aurora_stat_wait_event() returns THREE
columns with type_id first, not the four documented. Aliasing it wrong does
not error - the join matches nothing and every event name returns NULL,
which is how it was found. LEFT JOIN throughout, never the NATURAL JOIN the
AWS example shows, because the documented type list omits type 2 and
Limitless adds 12; an unnamed wait is still a wait worth recording, and the
tests pin that such rows survive. Columns are cast explicitly so reader
types are deterministic - Npgsql throws on GetInt64 over an int4 column.

wait_time_us carries its unit in the name because the AWS docs contradict
themselves (microseconds for system_waits, milliseconds for
backend_waits). Settled by measurement instead: read as milliseconds, the
observed totals imply tens of thousands of concurrent waiters against a
max_connections of 5,000. The name means nobody relitigates it.

Deltas key on the numeric event_id, NOT the event name, because wait-event
name casing differs between Aurora majors - AutoVacuumMain on 16.11 versus
AutovacuumMain on 17.7 - so a name-keyed delta would break its own history
across an upgrade, reading as one series ending and another beginning.
Same 300-second gap policy as wait_stats.

A type-level ignore list drops Activity, Client, and Timeout. Measured on
prod, Client:ClientRead alone accumulated 565,758,023 seconds and every
Activity event grows at ~1 second per second of uptime forever; left in
they are over 99% of the chart. Filtering by type rather than by event name
means a new background worker in a future release is excluded
automatically. An undecodable type is deliberately kept.

Registered in all three tables (catalog, schedule at 1 min / 30 days
matching wait_stats, dispatch) plus migration V60 and a StorageVersion
bump to 60. Verified with the ladder generator that the generated
fresh-store table is column-for-column identical to the hand-written rung,
index included - the invariant that otherwise silently breaks the binary
COPY on an upgraded store.

The catalog-count pins move 41 -> 42, and the engine drift guard is
rewritten to key on the pg_ naming convention rather than asserting every
definition is SQL Server, so it now catches both directions of mistake.

Also fixes generate-ladder-fixture.csproj, which has been unloadable since
it was added: its XML comment contained the CLI argument separator, and two
consecutive dashes are illegal inside an XML comment. The release-cut tool
could not build at all. The frozen v3.3.0 fixture is deliberately NOT
regenerated - it represents the previous release, and V60 applying on top
of it is exactly what the upgrade test should exercise.

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The viewer/MCP element that ships with the collector, per the one-with-each
rule.

A separate tool from get_wait_stats rather than a widened one, because the
two engines' wait models do not line up: PostgreSQL has a two-level
type/event taxonomy where SQL Server has one flat name, has no signal-wait
concept at all, and reports microseconds where SQL Server reports
milliseconds. Folding them together would mean either lying about a unit or
emitting mostly-null columns.

DarlingPgWaitReader aggregates the DELTA columns, never the raw cumulative
ones - summing cumulative counters across snapshots multiplies the whole
history by the snapshot count, which produces a plausible-looking number
that is wrong by orders of magnitude, and a test pins it. Microsecond ->
millisecond conversion happens once here so no consumer has to remember the
stored unit, and the division is float so sub-millisecond events do not
collapse to zero. HAVING excludes events that did not move in the window,
which otherwise pad the result with every event the instance has ever seen.

Unnamed events are surfaced, not filtered. wait_type and wait_event are
nullable because their lookups are LEFT JOINed in the collector, and an
event Aurora reports but cannot name is exactly the new-wait-type case worth
seeing, so it gets a synthetic label from the numeric ids rather than being
swallowed by the GROUP BY.

The tool reports each event's share of window wait time alongside the
absolute figure, since the absolute number alone does not say whether an
event is the story or a rounding error. An empty result is explained rather
than reported as "no waits": it means either no data, or that the server is
not an Aurora PostgreSQL target, and the message says which tool to use
instead.

Registered in the MCP host's explicit tool-type chain as well as the web
endpoint catalog and dispatch tables. Missing the host registration would
have left the tool working over HTTP but invisible to every MCP client,
which the catalog/endpoint parity test now covers.

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Postgres counterpart of query_stats, reading Aurora's extended
aurora_stat_statements(). Two column groups have no SQL Server equivalent
at all:

  - The Aurora I/O source split (storage_blks_read / orcache_blks_hit and
    their times) decomposes an otherwise opaque block read into "came from
    the distributed storage volume" versus "hit the local NVMe Optimized
    Reads tier". This is why a cache-hit ratio computed the community way
    is arithmetically misleading on Aurora: a "read" may have been a fast
    local hit.
  - total_exec_peakmem_bytes / max_exec_peakmem_bytes are the nearest
    thing PostgreSQL has to memory-grant data, which core PostgreSQL has
    no concept of. Verified populated on prod with no config change.

Per-query wal_bytes is likewise a signal no SQL Server DMV offers.

The query is built per major version rather than SELECT *-ed, because our
fleet spans both and the columns genuinely differ: 16.11 has blk_read_time
/ blk_write_time where 17.7 has shared_blk_read_time /
shared_blk_write_time. A SELECT * would not error on either version - it
would silently shift every ordinal, which is exactly how several
monitoring tools shipped broken PG17 collectors. Casts pin the reader's
types since wal_bytes is numeric and Npgsql's checking is strict.

Delta key is the full (queryid, dbid, userid, toplevel) identity, not
queryid alone: the same normalized statement run by a different user or
against a different database is a separate pg_stat_statements entry with
its own counters, so keying on queryid alone would interleave several
series and produce nonsense. queryid is not stable across major versions,
so a mass reset after an upgrade is expected and the existing
counter-regression handling covers it.

NO query text column, and that was a correction mid-change rather than an
omission. Text belongs in the shared query_text_dim rather than inline -
inline payload was 94% of a 250 GB field store - but registering a new
dim-feeding table cannot be done from a rung this late. V38 is GENERATED
from PayloadDimensions.All, so adding an entry made V38 emit
"ALTER TABLE pg_statement_stats ADD COLUMN query_text_digest", and on an
upgraded store V38 runs long before V61 creates that table: the ALTER
would hit a nonexistent table and fail the whole migration, bricking the
upgrade. Caught by running the ladder generator and diffing its output
against the hand-written rung. Retrofitting a dim-feeding table needs
either an existence-guarded V38 or a rung-aware registry - a design
change, not a drive-by. queryid is the identity meanwhile, which is the
join key anyway, and text is better served by a dedicated low-cadence
collector storing each statement once rather than once per snapshot.

Verified with the generator that the fresh-store emission is
column-for-column identical to V61 (34 columns each) and that no stray
digest ALTER remains. StorageVersion 61, catalog pins 42 -> 43.

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The viewer/MCP element paired with the collector, per the one-with-each
rule. Separate from get_top_queries_by_cpu because the two engines report
different things: no signal-wait split, an I/O source breakdown SQL Server
has no concept of, and identification by queryid rather than text.

The read is deliberately honest about a limitation of the collector's first
cut. Only calls, total time and rows have per-interval deltas in the store,
so those are SUMmed; the block, WAL and peak-memory columns are
cumulative-only and are therefore read as MAX - the latest reading, which is
at least a true number - rather than summed across snapshots, which would be
arithmetically meaningless. The tool states that distinction in its own
output so a caller cannot mistake a cumulative figure for a windowed one,
and a test asserts SUM() never appears over a cumulative column, since that
is the mistake most likely to be made when someone extends this query.

Surfaces Aurora's I/O split as a ratio, not just raw counts:
orcache_hit_pct_of_reads separates cheap local NVMe hits from network round
trips to the cluster volume. That distinction is the whole reason the
collector reads aurora_stat_statements instead of the vanilla view, and it
is what makes a community-style cache-hit ratio misleading on Aurora.

Grouped by (queryid, database_id) to match the collector's identity, and
HAVING excludes shapes that did not execute in the window -
pg_stat_statements retains an entry long after its last execution, so
without that the list is padded with idle shapes showing zero.

The empty-result message names the likeliest real cause rather than just
saying no data: on some of our clusters pg_stat_statements exists only in
the application database, not in postgres, so a collector pointed at the
wrong database returns nothing while looking healthy.

Registered in the MCP host's tool-type chain plus the web endpoint catalog
and dispatch tables.

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first Tier 0 outage predictor, and the first PostgreSQL collector that
is not Aurora-only: it reads core catalog surfaces, so it populates on any
PostgreSQL target. Gating it on Aurora would have silently dropped the
single most consequential PostgreSQL signal everywhere else, and a test
pins that it applies to non-Aurora targets.

PostgreSQL transaction ids are 32-bit and wrap, and the consequences
escalate on a documented ladder: forced anti-wraparound autovacuum at
autovacuum_freeze_max_age (200M default), failsafe mode abandoning cost
limits and index cleanup at 1.6B, warnings near 40M ids remaining, and at
3M remaining the server refuses to assign new transaction ids - writes and
DDL stop while reads continue. That last state is a write outage no
failover fixes, because every replica shares the condition.

MultiXact ids are collected as a first-class second counter because they
are independent and separately fatal, and are the thing almost nobody
monitors. They are consumed when a row is locked by several transactions at
once, so a SELECT FOR UPDATE-heavy or foreign-key-heavy workload burns them
far faster than plain transaction ids: a server can look comfortable on XID
age while being in trouble on MultiXact age. Both live on pg_database, so
collecting one and inferring the other would be a choice to be wrong.

Two percentages per counter, against deliberately different denominators:
distance to an emergency vacuum (autovacuum_freeze_max_age) and distance to
the wraparound ceiling (~2^31). Conflating them would make a routine
anti-wraparound vacuum read as an imminent outage - about a tenfold
overstatement at defaults - and a test pins both denominators. An
unreadable setting yields 0 rather than manufacturing a percentage, and
therefore cannot manufacture an alert.

Percentages are STORED rather than computed on read because their
denominators are per-server settings: recomputing later against whatever
autovacuum_freeze_max_age happens to be then would silently rewrite history
the moment someone tunes it.

Uses age()/mxid_age() rather than arithmetic on the raw xid, since both
handle the modular wrap that makes naive subtraction wrong precisely near
the boundary - the only region where being wrong matters. Reads the shared
pg_database catalog, so no per-database fan-out. No deltas: age is a
distance from a wall, not accumulated work, and it falls when autovacuum
freezes.

V62, StorageVersion 62, catalog pins 43 -> 44. Verified with the ladder
generator that all three PostgreSQL tables now match their rungs
column-for-column (pg_wait_stats 12, pg_statement_stats 34,
pg_wraparound_stats 16).

Per-relation attribution - which table is holding the freeze floor - is a
per-database read and belongs in its own collector; this one answers how
much time is left, which is the alerting question.

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read as a LEVEL, not a rate — the opposite discipline from the wait and
statement readers, and deliberately so. Freeze age is a distance from a
wall, not accumulated work: averaging it would blur the only number that
matters and summing it would be meaningless. So this takes the latest
reading per database (DISTINCT ON) plus the window PEAK for each counter,
and a test asserts SUM/AVG never appear over an age column.

The peak is what makes the pair useful. A current age below the window peak
means freezing has clawed age back at least once - the healthy sawtooth.
Equal to the peak means age has only ever climbed within the window, which
is the shape that ends in a write outage. The tool reports that comparison
as freezing_is_keeping_up rather than leaving a reader to eyeball two
numbers.

Severity comes from the documented escalation ladder, not from round
numbers, because each boundary is a real behaviour change: vacuum_failsafe
abandoning cost limits and index cleanup around 1.6B ids, server warnings
near 40M remaining, writes stopping near 3M remaining. Crucially, a database
past its emergency-vacuum threshold but far from the ceiling is classified
INFO, not warning - a forced anti-wraparound vacuum is normal operation, and
alerting on it is exactly how wraparound monitoring earns a reputation for
crying wolf. At defaults that state is 100% of autovacuum_freeze_max_age and
only ~9.3% of the way to the ceiling.

Both independent counters are surfaced with their own percentages and
remaining-headroom figures, since MultiXact exhaustion is separately fatal
and a server can look fine on transaction IDs while being in trouble on
MultiXacts.

The response leads with the worst database and its severity, because one
database hitting the wall stops writes for the whole instance - the worst
database IS the server's state. The thresholds themselves are included in
the payload so a consumer can see what the percentages mean without
consulting documentation.

Registered in the MCP host chain plus the web endpoint catalog and dispatch.

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reason autovacuum can run, report success, and reclaim nothing. Four
unrelated causes present IDENTICALLY from the symptom side - dead tuples
accumulate, autovacuum logs success, the table never shrinks - and the
remedy is completely different for each: kill a session, drop a replication
slot, disable standby feedback, or resolve an orphaned prepared
transaction. Doing the wrong one is at best useless.

So attribution is the deliverable, not a nicety. The collector emits the
oldest holder PER SOURCE and stamps which source is actually setting the
horizon, rather than a single aggregate age that would leave a reader
exactly where they started.

Five sources, and two of them come from pg_replication_slots on purpose: a
slot's xmin holds back ordinary row cleanup while its catalog_xmin holds
back CATALOG cleanup specifically - what a logical decoding slot pins - and
the two can differ by a lot. Collapsing them would misattribute one as the
other. A test asserts every source is queried, because missing one does not
degrade the answer, it inverts it: the collector would crown a different
winner and send someone to fix the wrong thing.

DISTINCT ON bounds output to the oldest holder per source, at most five
rows a cycle. pg_stat_activity can carry hundreds of backends with an xmin,
and storing all of them every minute would be a great many rows saying one
thing.

is_winner is stamped at collection, not derived on read, so a stored row
names the winner as of the moment it was measured. Deriving it later would
depend on which rows a query happened to select, and a filtered read could
crown a holder that never held the horizon. Exactly one winner is stamped
even on a tie, so a stored row is never ambiguous.

Zero rows is the HEALTHY state and must never read as a collection failure,
so nothing throws or synthesizes a placeholder. Every branch is
independently empty-tolerant, which also covers Aurora: pg_stat_replication
is expected to be empty there, since replicas read the same storage volume
rather than streaming WAL.

Per-minute cadence, unlike its wraparound sibling at five: an xmin holder
is the fast-moving leading indicator, and the useful catch is the session or
slot that appeared minutes ago, before it has cost anything. Not
Aurora-gated - core catalog surfaces only.

Worth noting the two Tier 0 collectors compose: a pinned horizon also blocks
freezing, so an unattended xmin holder is an upstream CAUSE of the
wraparound risk pg_wraparound_stats measures.

V63, StorageVersion 63, catalog pins 44 -> 45. All four PostgreSQL rungs
verified column-for-column against the generator (12, 34, 16, 9).

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reports the current holder per source joined to that source's PERSISTENCE
across the window, because presence alone does not tell you what to do. A
slot that won 58 of 60 samples is a standing problem someone must own; a
session that won twice was a query that ran long and finished. Reporting
only current state makes those identical; reporting only the window hides
which cause holds the horizon right now.

Each source carries its own concrete remedy, which is the entire payoff of
attributing the cause: kill or bound a session versus drop an abandoned slot
versus chase a stalled logical consumer versus fix a replica query versus
resolve a two-phase transaction by gid. Tests assert all five remedies are
genuinely distinct rather than one message with the noun swapped, and that
an unrecognized source says so instead of guessing.

The no-holder case gets a real finding rather than an empty envelope. An
operator reaches this tool BECAUSE bloat is growing, so "nothing is holding
the horizon" is informative: it redirects the investigation from "vacuum is
blocked" to "vacuum is not being triggered", and the response says exactly
that instead of leaving a dead end.

The response leads with the winning source, its holder, and the recommended
action, since that triple is the whole answer.

Registered in the MCP host chain plus the web endpoint catalog and dispatch.
Fixed a missing System.Linq using in the new test file.

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Worth its own collector even though pg_xmin_horizon already reads slots,
because an abandoned slot has a SECOND, independent failure mode that the
horizon view cannot see. A slot retains every WAL segment its consumer has
not confirmed, and with max_slot_wal_keep_size at its default of -1 that
retention is UNBOUNDED: the slot holds WAL until the volume fills, and a
full WAL volume stops the server. Same slot, two outages on different axes -
disk exhaustion and vacuum starvation - so both xmin counters are recorded
here as well and slot state is legible without a join.

retained_wal_bytes is COMPUTED from restart_lsn rather than read from
safe_wal_size, because safe_wal_size is NULL whenever max_slot_wal_keep_size
is -1. Depending on that column would mean reporting nothing on a stock
server, which is precisely where retention is unbounded. -1 is stored as the
not-applicable sentinel so a consumer cannot mistake "no limit configured"
for "no data collected".

The LSN reference switches on pg_is_in_recovery(), because
pg_current_wal_lsn() ERRORS on a standby and Aurora readers are legitimate
targets - without the switch the whole collection would fail on a reader
rather than degrading. A test pins it.

inactive_since and invalidation_reason are PG17+, conflicting is PG16+; on
older majors the collector substitutes NULL/false so the payload shape is
identical across a mixed-version fleet and a chart does not change shape at
an upgrade. A test asserts both versions select the same number of
expressions. inactive_since is the column that turns "this slot is inactive"
into "this slot has been inactive for three weeks" - the difference between
a consumer between polls and an orphan.

Per-minute, 90-day retention: retained WAL grows at whatever rate the server
generates WAL, which on a busy writer fills a volume in hours rather than
days, and the question after an incident is how long the slot was orphaned,
which has to outlive the incident.

V64, StorageVersion 64, catalog pins 45 -> 46. All five PostgreSQL rungs
verified column-for-column against the generator (12, 34, 16, 9, 20).

Full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the fifth vertical slice. The read pairs the latest state per slot
with that slot's earliest reading in the window, because retained WAL is only
actionable as a trend: a slot holding 45 GB steadily is a consumer that is
behind but keeping pace, while one that grew from 2 GB to 45 GB in an hour is a
volume filling in front of you, and a single current figure cannot tell them
apart. Severity turns on that conjunction — WAL retained because of this slot,
nobody consuming it, and the pile still growing — rather than on size.

Also fills the documentation debt the last five commits accumulated. The
Darling README's collector count, schema version, and the TimescaleDB
background-worker derivation were all stale, and the worker numbers are
load-bearing: an unmanaged store sized to 44/55 while carrying 47 hypertables
starves the policies that compress and expire data. Verified against the
catalog rather than extrapolated (HypertableTables is CollectorCatalog.All, so
it is 46 collector tables plus collection_log).

The new PostgreSQL Targets section documents what the seam actually does,
including the pg_monitor grant and the two Aurora-gated collectors, and states
plainly what is still missing (per-database fan-out, alerting, analysis).
Writing it surfaced a real gap: the README claimed integrated auth on a
PostgreSQL target is rejected at config validation, but it was only rejected
where the connection string is built — which for a service means the
misconfiguration appears in a log after deployment instead of in
--test-connection before it. Validate() now catches it, and range-checks the
optional port while it is there.

Clears the five build warnings this branch introduced.
The fan-out seam first. Per-database collection on SQL Server has two
possible shapes — switch the connection's catalog, or stay put and prefix the
query with EXECUTE [db].sys.sp_executesql — and PostgreSQL has only the first,
because a connection there is bound to one database for its lifetime. So
ITargetProvider gains the one shape both engines support (WithDatabase) plus
BuildDatabaseListPlan, which packages "where to ask for the database list" with
"what to ask": SQL Server hops to master, PostgreSQL reads pg_database where it
already is. The Azure enumeration now goes through that same plan, so the
sys.databases query lives in exactly one place instead of two.

What deliberately did NOT move into the provider is the failure policy. An
inaccessible master on Azure SQL DB has a real fallback (collect the one
connected database) and a re-probe throttle; a PostgreSQL login that cannot read
pg_database cannot monitor the server at all, so inventing a fallback there would
turn a permissions problem into a quiet partial collection.

pg_autovacuum_stats is the collector that exercises it. Dead-tuple counts are
the standard PostgreSQL autovacuum metric and they are close to useless alone,
because autovacuum fires at a threshold derived from the table's own row count:
500,000 dead tuples is routine on a 50-million-row table and urgent on a
10,000-row one. This computes each table's threshold and stores it alongside the
count, honouring per-table reloptions overrides rather than only the GUCs —
those overrides are common on exactly the big hot tables where the global
default is wrong, so reading the GUC alone would report a threshold the server
is not using. The read surface ranks by that ratio, and sorts a table with
autovacuum switched off above everything regardless of its count.

Two traps worth naming. The activity filter needs the insert clause, not just
dead tuples: an append-only table has no dead tuples at all, so a dead-tuple
filter drops precisely the tables that never get vacuumed and therefore never
get frozen. And reltuples is -1, not 0, on a never-analyzed table, which
unfloored yields a negative threshold and makes such a table read as
permanently overdue.

Cadence is hourly rather than per-minute because on PostgreSQL a per-database
collector means one connection per database per cycle.

Verified V60-V65 against the ladder generator: all six identical, 24 columns for
the new rung. Fleet numbers in the README re-derived rather than incremented
(48 hypertables, so 50/61 for an unmanaged store).
This read was mixing two time bases in one row. calls, total_exec_time_ms and
rows_returned came from the stored delta columns and covered the requested
window, while the block, storage/orcache and WAL figures were MAX() — the latest
LIFETIME cumulative reading, since the last pg_stat_statements_reset(), possibly
weeks earlier. Nothing in the output distinguished them, so a consumer reading
total_exec_time_ms for the last hour beside shared_blks_read since forever would
derive per-call I/O ratios that are pure nonsense. The old note admitted the
limitation, which does not help: a caller that reads the note still cannot
recover the windowed number.

Fixed at read time rather than by storing more deltas. The data is already
there, sampled per minute, so the difference is computed with a window function
instead of adding eight delta series per query shape to the store.

GREATEST(value - LAG(value), 0) is what makes that safe, and the reason a plain
last-minus-first would not do. A counter reset — an explicit reset, an eviction
and re-entry, or a major-version upgrade, since queryid is not stable across
majors — makes one interval negative, and last-minus-first reports that as a
large negative figure. Clamping each interval at zero drops exactly the reset
interval and keeps the rest, which is the same rule the stored delta machinery
already applies.

The LAG partition is the full series identity (queryid, database_id, user_id,
toplevel), matching how the stored deltas are keyed, with the roll-up to
(queryid, database_id) happening after. Differencing at the coarser grain would
interleave separate pg_stat_statements entries and produce garbage intervals.
max_exec_time_ms and max_exec_peakmem_bytes stay MAX: they are high-water marks,
not counters.

Verified against real Aurora PostgreSQL 16.11 and 17.7 (stage, read-only), not
just by text assertion — probe_validate_reader_sql.py substitutes a synthetic
VALUES table for the store table, keeps the query body byte-identical, and checks
the arithmetic. It confirms the reset case sums to 100 rather than -10, and that
a series with one sample in the window reports 0 rather than its lifetime total.
The same harness covers the autovacuum reader and proves its ranking: a table 50x
past its threshold with 50k dead tuples sorts above one 2.5x past with 500k,
which is the inversion the ratio ordering exists to fix.
Two defects found by probing live Aurora before building the next collector,
neither of which the build or a text assertion would have caught.

The replication-slot collector's PG17+ branch selected inactive_since bare, and
that column is `timestamp with time zone`. Npgsql 10 maps a timestamptz read to
DateTime with Kind=Utc and refuses to write a Kind=Utc DateTime into the store's
`timestamp without time zone` column, so collection would have failed at COPY
time on any Aurora 17 target holding a slot that had ever gone inactive — which
is to say on exactly the servers the collector exists for. The PG16 branch
substituted NULL::timestamp and was correctly typed all along, which is what hid
the asymmetry.

The autovacuum collector used `x::timestamp` on four timestamptz columns. That
form converts, but it renders the instant in the SESSION's TimeZone before
dropping the offset, so it agrees with UTC only while every parameter group says
UTC. Verified that all probed instances do say UTC today — which is precisely
what would keep the bug invisible until one of them didn't. Both now use
AT TIME ZONE 'UTC', the only form that is correctly typed AND
timezone-independent, pinned by test.

The bigger find: pg_autovacuum_stats now gates off standbys. Not for
permissions or availability — pg_stat_user_tables reads fine on an Aurora
replica and reports ALL ZEROS. Same cluster, same database, same 15 tables, on
17.7: the writer reported 13,654,458 dead tuples and 150,790,506 live tuples
while the reader reported 0 for n_dead_tup, n_mod_since_analyze,
n_ins_since_vacuum and n_live_tup. Those are the writer's stats-collector
numbers and they are not replicated. Ungated, a replica target returns no rows,
the activity filter reads that as "nothing has pending work", and the tool
reports perfect autovacuum health for a cluster 13 million dead tuples behind. A
confidently wrong healthy answer is worse than no answer.

The same reasoning applies more weakly to slots, so get_pg_replication_slots'
empty result now says it is per-instance and points at the writer, instead of
claiming neither WAL retention nor pinned vacuum is possible.

Verified the fixed queries execute on live 17.7 and that no store column
declared naive comes back carrying a timezone. Ladder regenerated and confirmed
byte-identical: this changes SQL and a gate, not schema.
pg_stat_io attributes I/O to a (backend_type, object, context) triple rather
than to a file, so "the database is doing 40k reads/sec" becomes "autovacuum
workers are reading relations in the vacuum context". The context dimension has
no SQL Server counterpart and is the one that changes the remedy: it separates
ordinary buffer-pool misses, where more shared_buffers or a better index helps,
from sequential scans that deliberately bypass the pool through a small ring
buffer, where neither will. High bulkread volume looking like memory pressure and
not being memory pressure is the standard misreading of this view, so the tool
says so per row.

NULL is preserved end to end and never coalesced, which drove most of the design.
PostgreSQL uses NULL for "this counter does not apply to this combination" — the
checkpointer performs no reads or hits, bulkread never extends, the normal
context has no ring buffer to reuse — and on Aurora the ENTIRE write side is NULL
because backends there do not write data files, the storage layer does. Probed
before writing any of it: on 17.7 writes/write_time/writebacks/writeback_time/
fsyncs/fsync_time all come back NULL, and on 16.11 writebacks and fsyncs do. A
zero in any of those places would claim a measurement nobody took, and a consumer
averaging write latency would divide by it. So this is the one Postgres collector
here that uses NULL rather than a -1 sentinel: -1 suits a level a consumer reads
directly, but these are cumulative counters that get differenced, and -1
differenced against a real value is a garbage interval.

The read therefore reports write_counters_tracked alongside the numbers, so a
caller can tell "no writes happened" from "writes are not measured here". Differencing
uses the same clamped positive-interval rule as the statement read.

Two things the probe settled that a guess would have got wrong. The enum values
differ between majors — 17.7 showed a walreplay context and Aurora-specific
backend types ('aurora cache receiver process', 'aurora wal replay process',
'slotsync worker') that 16.11 did not — so nothing filters on them; a whitelist
would silently drop rows. And PG18 REMOVED op_bytes, so it is substituted there
rather than left to fail with "column does not exist"; the replacement per-operation
byte counters are deliberately not added speculatively, since they measure something
different and deserve their own columns decided against a real PG18 target.

Per-minute cadence, unlike the autovacuum collector: this is cluster-wide, so one
connection and no fan-out, and it returned 25-37 rows per snapshot on the fleet —
the same order as pg_wait_stats.

Verified rather than assumed. The collector SQL executed on live stage 16.11 and
17.7 returning 37 and 25 rows with stats_reset arriving naive. The reader SQL ran
on both majors against a NULL-bearing fixture and computed every case: the reset
clamp summing to 100 instead of -10, NULL writes reporting tracked=false, the
checkpointer's real writes reporting tracked=true at 600, bulkread's NULL extends
summing to 0 while its reuses summed to 150, and an idle combination filtered out
rather than returned as zeros. All seven PG rungs diffed identical to the ladder
generator (22 columns for this one).
…R forever

The engine seam has had PostgresTargetProvider.Classify since the first commit on
this branch, but nothing in the worker consulted it — every SQLSTATE-bearing
failure fell through to the general handler, which logs ERROR and records ERROR.

That is worst for the conditions that never resolve on their own. pg_statement_stats
against a database where the extension was never created raises 42P01 on every
cycle; a source Aurora does not implement raises 0A000 on every cycle; a feature
gated off in the parameter group raises 55006 on every cycle. At a one-minute
cadence each of those is 1,440 identical errors a day, which is how a real
finding becomes noise nobody reads. These are the exact PostgreSQL analogue of
the 8189 sys.traces denial that already degrades to PERMISSIONS, and for the same
stated reason: a legitimate least-privilege or platform reality should not scream
every cycle.

The store has five statuses and none of them means "this feature is not
installed", so those cases take the non-fatal-degradation bucket and the MESSAGE
carries the truth — following the AzureDmvPermissionHint precedent, and saying
explicitly "NOT a missing grant" plus the fix (CREATE EXTENSION, or the parameter
group), because PERMISSIONS on its own would send someone hunting a GRANT that
cannot help.

Two discriminations worth calling out. A statement_timeout (57014) is an ERROR
but is NOT connection-fatal, so it does not trip the reconnect-and-reprobe path —
dropping the connection over a slow query would turn a tuning problem into a
reconnect storm. And the general handler's reconnect trigger now recognises a
Postgres connection failure (08 class, 57P0x) where before it only knew about
SqlException, so a dead socket on a PG target went unnoticed and poisoned every
subsequent collector on that server.

An unrecognized SQLSTATE stays loud, deliberately: the quiet bucket is for
conditions we have identified, not a catch-all.

Verified against the real shipped code path rather than a copy — a console
harness referencing the service project ran the full SQLSTATE truth table
through DarlingWorker.PostgresFaultOutcome and PostgresTargetProvider.Classify:
12 states mapped as intended, every emitted status one the store already
understands, the yield branch reachable for a collector that opts in, and 57014
confirmed distinct from the five connection-fatal codes.
The three Tier 0 outage predictors are collected, stored and readable, and
nothing pages anyone about them. That is the biggest remaining gap for replacing
DBM, so this writes down what the work actually is instead of leaving it as "add
alerting".

The finding is that it is NOT another vertical slice. The previous seven
collectors were purely additive; the alert engine is SHARED WITH LITE and is what
SQL Server monitoring alerts through today, so one new PostgreSQL alert changes a
contract two SKUs implement, four test files reference, and the viewer's Settings
window exposes — plus a migration for the new settings columns. Highest blast
radius on the branch.

That surfaces a real architecture question rather than a coding one: does a
PostgreSQL-only signal extend the shared IAlertReadAdapter, forcing Lite to
implement three methods for an engine it cannot monitor, or sit behind a separate
adapter the engine consults only for PostgreSQL targets? The note recommends the
second, for the same reason the collector seam gates by engine instead of having
every definition claim every target — but it touches the shared brain, so it is
Erik's call, and starting the implementation before that is decided would mean
guessing at a seam and rewriting it.

The note also records the thresholds each alert should use, derived from what the
collectors already measure rather than invented: wraparound against
autovacuum_freeze_max_age instead of a raw XID count, xmin against the winning
holder's age AND persistence (the collector already attributes the cause, and the
four causes need different fixes), slots against wal_status plus whether retained
WAL is growing. Each maps to a severity the read surface already computes, so the
engine's job is threshold, edge-trigger and dedup — not re-deriving the finding.

And the trap an alert would otherwise inherit: anything reading autovacuum state
must keep the standby gate, because pg_stat_user_tables reports all zeros on an
Aurora reader.
The three Tier 0 predictors were collected, stored and readable, and silent.
Now they page. Wraparound risk, a blocked vacuum horizon and replication-slot
retention are evaluated on the alert cadence and delivered through the SAME
deliverer, history and mute rules as every SQL Server alert, so they land in the
same places and obey the same suppression rather than becoming a second
notification path nobody configured.

Option B as chosen: a separate IPostgresAlertReadAdapter consulted only for
PostgreSQL targets. Extending the shared IAlertReadAdapter would have forced
Lite — which has no PostgreSQL target and no PostgreSQL collectors — to implement
three methods that can only ever return empty, leaving permanent dead code in a
shipping SKU to satisfy a contract it has no stake in. This mirrors what
collection already does: CollectorCatalog.AppliesTo gates by engine instead of
having every definition claim every target.

B turned out cheaper than the scoping note estimated. AlertEngine was not touched
at all — the evaluator is a pure function of (rows, settings) beside it, and the
host calls it after the shared sweep behind an engine check. So Lite,
IAlertReadAdapter, IAlertEngineSettings and all four existing alert test files are
untouched, and the blast radius collapsed to new files plus one gated call site.
Failure-isolated too: a broken PostgreSQL read must not cost a server its CPU or
blocking alerts, so it cannot.

The thresholds are derived from PostgreSQL's own mechanics rather than picked,
which is what makes them defensible as constants for now. Wraparound grades
against the SERVER'S OWN autovacuum_freeze_max_age, not a fixed number: warning at
90% of it (before autovacuum force-starts its own prevention vacuum, while a
planned one is still an option) and critical at 2x. That scaling matters — 400
million transactions is critical on a stock 200-million server and completely
unremarkable on one tuned to 1.5 billion, and a constant would either never fire
for the second or constantly for the first. A missing or non-positive setting
silences instead of firing, because every derived threshold would otherwise be
zero and alert on every database forever.

xmin gates on persistence as well as age, which is the whole difference between a
chronic holder and a report that ran long — without it this fires on any slow
query, which is how an outage predictor earns a mute rule and stops being one. The
message carries the remedy for the specific cause, since the five causes are
indistinguishable by symptom and need completely different fixes. Slots fire at
any size for lost/unreserved (failures that have already happened) and grade the
inactive-plus-growing-plus-over-the-line conjunction as the disk-fill emergency,
with each part alone a warning.

Thresholds are NOT configurable yet: no new config_alert_settings columns, no
migration, no Settings-window work. Deliberate first cut, and the design note says
so plainly — the moment someone wants a different number, that is the work.

Verified against the real shipped evaluator (harness kept alongside the probes):
29 checks over the boundary values, the scaling behaviour, the silencing cases,
all five xmin remedies, and the slot grading conjunction. Every boundary asserted
on both sides.
Settling the decisions before writing code, because doing it that way for
alerting is what made that implementation fast — and this one has more traps than
it looks.

The load-bearing ones. pg_blocking_pids() takes ShareLock on the lock manager
partitions per call, so calling it per row of pg_stat_activity on a
5,000-connection instance makes the monitoring query the incident; filter to
wait_event_type = 'Lock' first, which is the only population that can have
blockers, so the filter costs nothing. Store the edge list rather than a rendered
tree, since root blocker, chain depth and fan-out are all cheap over edges and
expensive to recover from a string. Capture the BLOCKER's own state and not just
its pid — a chain rooted in "idle in transaction" is an application bug and one
rooted in a long query is a tuning problem, and the pid alone does not say which,
which is the most common gap in homegrown PostgreSQL blocking monitoring.

Also written down: this is a SAMPLING collector, not a blocked-process-report
equivalent. PostgreSQL has no ring buffer and no server-side threshold that
materialises a report, so blocking shorter than the cadence is invisible. That
belongs in the collector's own docs or it will be mistaken for the SQL Server
surface it resembles.

Two gates NOT to inherit: it should run on standbys (recovery conflicts are real
blocking and pg_stat_activity reports the standby's own backends — the autovacuum
collector's IsInRecovery gate exists for a reason that does not apply here), and
it should not declare YieldsOnLockTimeout, since reading pg_stat_activity takes no
table locks and the branch could never fire.

And the trap that has already bitten twice on this branch: pg_stat_activity's
timestamps are timestamptz, so AT TIME ZONE 'UTC' or store server-computed
durations instead.
--test-connection is the deployment gate, and against a healthy Aurora cluster it
printed "SQL major version 0, Unknown (0), msdb access: yes". Every field in that
line is a SQL Server fact a Postgres target does not have: the major version and
edition are zero because nothing probed them, and HasMsdbAccess is true only
because that is its default. A PASS that reads like a misconfiguration is worse
than a FAIL on the one verb whose job is to be believed.

The probe already knew better -- ConnectPostgresAsync fills in the major, the
version_num, Aurora detection and pg_is_in_recovery -- but ProbeAsync dropped all
four on the floor, so nothing downstream could see them.

So carry them, and branch on engine. A Postgres target now reports version, writer
vs reader, Aurora vs not, and then the number that actually answers "will this
target give me what I expect": how many of the seven PostgreSQL collectors clear
the gate, naming the ones that do not.

  [PASS] aurora-reader: PostgreSQL 17 (server_version_num 170007), reader (in
         recovery), Aurora - 6 of 7 PostgreSQL collectors apply (skipped:
         pg_autovacuum_stats)

That count is computed by asking CollectorCatalog.AppliesTo the same question the
runner asks, via ConnectionProbeResult.ToTargetInfo(), rather than by keeping a
parallel list that can rot. A stock-PostgreSQL 15 reader clears three of seven,
and finding that out at pre-flight is the difference between "this is configured"
and "this will collect" -- otherwise the first symptom is an empty table someone
has to explain weeks later.

The two format sites that had each grown their own copy of this string -- the CLI
PASS line and the add_servers MCP detail -- now call one describer, so they cannot
drift; that also settles the small existing divergence in their msdb wording. The
new facts ride alongside the old ones in the test_connect result_json rather than
replacing anything, so an existing consumer keeps working, plus a ready-made
`facts` string for the Viewer dialogs when they get there.

The PostgreSQL fields are trailing optional record parameters, so every existing
construction site still compiles and still means "a SQL Server target".

Verified: solution builds clean; harnesses/probecheck (new) exercises the real
describer against the four target shapes a real fleet has -- Aurora 16/17 writer,
Aurora reader, stock PG 15 reader -- and independently recomputes each count from
the catalog gate. 7/7, 6/7, 7/7, 3/7, all matching. The xUnit assertions are in
DarlingCliCommandsTests and remain unexecuted on macOS.
Writing the first-target runbook found the defect the runbook would have died
on at step 5: none of this worked at all against a real store.

config.config_monitored_servers is the AUTHORITATIVE server list once seeded --
darling.json seeds it when empty and is ignored afterwards, which is deliberate
and documented. Every MonitoredServer field had a column there except Engine and
Port. So a PostgreSQL entry was written without its engine, read back as the
"sqlserver" property default, and connected to with SqlConnection. Not on a later
restart -- on the FIRST start, because SeedIfEmptyAsync is immediately followed by
the LoadViewAsync whose result replaces the file's list.

Nothing failed to compile, no test covered the round trip, and the seven
collectors, the fan-out, the fault classifier and the alerting on top were all
built above a target that could never connect.

V67 adds both columns, NOT NULL with defaults that make every existing row mean
exactly what it means today; the seed writes them, the read restores them. Engine
is stored as the raw string the operator wrote, alias and all, so
MonitoredServer.TargetEngine stays the single place that interprets it.

add_servers can now onboard one, which matters more than it sounds: it is the ONLY
path into an already-seeded store, so without this a PostgreSQL target was
unaddable to every existing install regardless of the columns. It validates
engine STRICTLY, unlike the file parser that resolves anything unrecognized to SQL
Server -- the parser's leniency stops one bad line from taking a fleet down at
startup, while onboarding is a single deliberate act where "postgress" silently
becoming a SQL Server target yields a connection failure against 5432 with nothing
naming the cause.

Then the pins. Three "the newest rung is N" assertions were still at 59, so the
seven PostgreSQL rungs had been breaking DarlingObservabilityTests,
PvsStatsStoreTests and StoreSelfMetricsTests since V60 and could not say so on a
Mac. Four more in PgSchemaGeneratorTests were literals where the test's own name
states an invariant: uniqueness of tables and names asserted as 45 while the real
figure tracked the catalog count, and "emits every table" asserted as 46 of 48,
which had quietly become a subset check. Those four now assert against
CollectorCatalog.All.Count, so they cannot rot again; the catalog count itself
stays a deliberate literal.

harnesses/pincheck is new and exists because of that class of bug: it evaluates
every literal pin against the real assemblies and prints pinned vs actual. It is
the only thing here that catches a number a test file three directories away
asserts, since the test projects cannot execute on this machine.

Also: --test-connection's PASS line and add_servers' detail now report the probe's
PostgreSQL facts through one shared describer (a Postgres target read as "SQL major
version 0, Unknown (0), msdb access: yes"), and both name how many of the seven
collectors clear the gate for that target -- 7 for an Aurora writer, 6 for a
reader, 3 for a stock PostgreSQL 15 reader.

docs/postgres-first-target-runbook.md is the procedure with a proof point per
step, and says plainly that it has never been run end to end.

Verified: solution builds 0 errors, 17 warnings all pre-existing (the CA1859 on
_alertDeliverer was mine and is fixed). Ladder generates 66 rungs, top V67, with
the ALTER ordered after the CREATE it depends on. All six harnesses pass, incl.
pincheck's 15 pins. avcheck/iocheck no longer abort after passing when SQLOUT is
unset. Nothing here has run against a live store or a live Aurora instance.
dev had moved 53 commits and taken V60 for database-state-edge-memory (#2166)
while this branch was using V60 for pg-wait-stats. Two different rungs with the
same version number: the applier would run one and skip the other depending on
where a store already was, and PgMigrations.Scripts would carry a duplicate
version.

So dev's V60 keeps its number and the eight rungs here shift up one:

  60  database-state-edge-memory   (dev, unchanged)
  61  pg-wait-stats
  62  pg-statement-stats
  63  pg-wraparound-stats
  64  pg-xmin-horizon
  65  pg-replication-slots
  66  pg-autovacuum-stats
  67  pg-io-stats
  68  monitored-server-engine

SchemaVersion is 68. The three "newest rung is N" pins conflicted three ways --
dev said 60, this branch said 67, the answer is 68.

Nothing else conflicted semantically. dev's #2188 per-database collector_state
prune looked like it might: it keys the existence list on database_states, which
is a SQL Server collector gated off PostgreSQL targets, so an empty list could
have retired pg_autovacuum_stats' per-database state every cycle. It cannot --
the prune is scoped to QueryStorePerDatabaseState.PrunableKeys and runs once per
query_store cycle, query_store never runs on a PostgreSQL target, no PostgreSQL
collector writes collector_state at all, and the SQL no-ops on an empty snapshot
anyway. Checked rather than assumed because it is exactly the shape of thing that
would have looked like a collector bug months later.

Verified on the merged tree: builds 0 errors / 16 warnings, all pre-existing.
Ladder generates 67 rungs, top V68, strictly ascending, no duplicate versions,
and dev's V60 ALTER lands before pg_wait_stats' CREATE. All six harnesses pass,
including pincheck's 15 pins now reading 68. Still nothing run against a live
store or a live Aurora instance.
The connect-time gate refuses a store BELOW RequiredStoreSchemaVersion, which is
StorageVersion.SchemaVersion. MapProbedSchemaVersion's newest arm was V60, so a
fully-migrated v68 store probed as 60 and the viewer would have refused it with
"older than this viewer -- upgrade/restart the service so it migrates the store"
on a store that was already current. Every store this branch touches.

dev's own V60 arm spells out why the arm has to exist even when nothing in the
viewer would 42703: the invariant is that a fully-migrated store maps to EXACTLY
SchemaVersion. So V68 gets an arm sensing config_monitored_servers.engine, plus
the probe column and reader ordinal that go with it.

V61-V67 get no arms on purpose -- they add tables in `collect` that no viewer read
names, so a store sitting between them is only ever transient mid-migration, and
the invariant that must hold is about the top rung.

Three more pins the merge moved: RequiredStoreSchemaVersion is derived from
SchemaVersion on dev, so the literal 60s in CollectorMemoryKnobTests,
PvsStatsStoreTests and StoreSelfMetricsTests all read 68 now.

pincheck grew three checks for this, because the probe is a second place a version
has to be updated and nothing links the two: the newest arm must equal
SchemaVersion, the reader ordinals must be contiguous from 0, and the probe SQL's
top-level column count must equal the ordinal count. It reads the viewer's source
rather than referencing the project -- the Viewer is net10.0-windows + WPF and
cannot be referenced from a net10.0 console app, which is noted in the harness so
nobody retries it.

Verified: builds 0 errors / 16 pre-existing warnings; pincheck 18/18.
…ase note

PostgresFaultOutcome was inserted directly above IsPermissionError's doc block in
a8a98bd, which pushed that block up onto PostgresFaultOutcome and left
IsPermissionError undocumented. DocCommentHygieneTests forbids exactly this
(#1745, #2190) and would have failed the nightly.

Per that test's own instruction, the fix is to MOVE the block back rather than
delete the first summary -- seven of the eight found in #1745 were displaced blocks
whose real member had been left undocumented, and deleting them lost the
documentation instead of deduplicating it. IsPermissionError has its block again
and nothing was rewritten.

Verified by reproducing the detector's StackedSummaryRuns scan over every .cs in
the tree: 1 offender before, 0 after.

While in the same sweep, reproduced the reflection-based MCP parity test the same
way: 99 tools, 80 /api/read endpoints plus 19 documented exclusions, zero tools
without an endpoint, zero endpoints without a tool, and all seven PostgreSQL tools
present in the dispatch AND the descriptor catalog. (My first pass at this reported
73 false positives because it sliced from BuildReadDispatch's first MENTION rather
than its definition -- worth knowing, since the wrong answer looked alarming.)

CHANGELOG gets the release note this branch never had, which for a nightly is the
only place any of it is user-visible. No issue number: nothing on the branch cites
one, and filing is not mine to do.
The status note said no instance had been monitored end to end, which read as "no
Aurora instance has been touched" and undersold what is actually proven: every
collector's generated SQL and every MCP reader's SQL has been executed against live
Aurora 16.11 and 17.7, confirming shape, no timezone on naive columns, and correct
windowed differencing.

The real gap is one layer: the service has never done connect probe -> dispatch ->
COPY into a store -> read back out. Naming the layer matters more than the
disclaimer, because that is where the last defect was -- a PostgreSQL target could
not survive its own registration while all of its SQL was correct and live-proven.
First real run of the suite: 561 of 4760 failed. Five causes, and the first is the
one worth reading.

1. The collector table was named pg_replication_slots -- which is also
   pg_catalog.pg_replication_slots, the system view it reads. pg_catalog is
   searched implicitly and FIRST, ahead of every search_path entry, so an
   unqualified reference to that name resolves to the system view whatever the
   store holds. Two failures with very different characters:

   LOUD: V1's generated schema emits an unqualified CREATE INDEX ... ON
   pg_replication_slots. Indexing a view is 42809, so MigrateLockedAsync threw,
   the store never came up, and 531 tests failed behind the collection fixture.

   QUIET, and much worse: DarlingPgSlotReader and DarlingPostgresAlertReadAdapter
   both did FROM pg_replication_slots against the STORE. That would have returned
   the monitoring store's OWN slot list -- normally empty. get_pg_replication_slots
   would have reported "no slots" forever and the retention alert would never have
   fired. A silently muted outage predictor is worse than none, and it is exactly
   what the alerting note says must not happen.

   Renamed the table to pg_replication_slot_stats; the collector keeps the name
   pg_replication_slots after the view it reads, which is established practice
   here (query_store -> query_store_stats, and three more). Schema-qualifying every
   reference instead would have fixed the loud half and left the quiet half one
   forgotten qualifier away.

   Verified on live Aurora 16.11 and 17.7 (probe_catalog_name_collisions.py, new)
   that pg_replication_slots is the ONLY one of the seven that collides and that
   pg_replication_slot_stats is clear on both majors. A live-store test now asserts
   no collector table shadows a catalog object, checked against the real catalog
   rather than a hardcoded reserved list, with a teeth test so it cannot pass by
   matching nothing.

2. CI's throwaway cluster is sized off HypertableCount, and seven new tables moved
   44/55 to 51/62. build.yml and nightly.yml carry those literals -- a 14th
   registration point I did not know about. pincheck now checks both workflows and
   the README, so the next collector cannot miss them.

3. ViewerCollectorCoverageTests: the seven PostgreSQL tables have no WPF reader.
   Allow-listed as UNBUILT UI with the reason -- they are read through MCP and
   /api/read today, and the viewer's surfaces are SQL-Server-shaped. One per line
   so removing one is a one-line diff.

4. RequiredStoreSchemaVersion_TracksTheBuildSchemaVersion_AndTheProbeCoversIt
   passed 43 hand-counted `true`s; my V68 sentinel was the 44th and defaulted to
   false, so "a fully-migrated store" quietly meant "one rung short" and the
   assertion failed on the version instead of on the call site. Now built from the
   method's own arity by reflection, which cannot drift from the signature.

5. My own TargetProviderTests handed one connection string to both engines;
   SqlConnectionStringBuilder rejects Host=. Split into a Theory with an
   engine-appropriate string each, plus a separate every-engine-has-a-provider
   case for the part that genuinely must loop the enum.

Verified: builds 0 errors / 16 pre-existing warnings. Ladder 67 rungs, top V68,
with the renamed table created and moved correctly on the fresh-store path. Six
harnesses pass incl. pincheck's now-23 pins. Doc-hygiene scan 0 offenders, MCP
parity 99 tools / 80 endpoints / 19 excluded / 0 unregistered.
…ore SQL-Server-only

Darling PG tests went 561 failures -> 1, and the one left was the fail-closed
security gate doing its job: config_monitored_servers.engine and .port were
unclassified in DarlingManagedRoles.ViewerRestrictedConfigTables, so the live
split test refused them rather than letting a new column reach `viewer` by
default. Both are non-secret -- an engine name and a port are exactly as sensitive
as the host, which is already readable -- so they join NonSecretColumns.

The build job (Lite.Tests, which had not finished on the previous run) was the
other red: 10 failures, all one question. Lite's DuckDB schema is generated from
the shared engine-mixed catalog, so the seven PostgreSQL tables started appearing
in it.

They should not. Lite has no PostgreSQL target and cannot acquire one -- the engine
gate never dispatches a PostgreSQL definition there -- so those tables would be
seven permanently-empty tables in every seat's local DuckDB file forever. Darling
makes the opposite call deliberately, because its central store may gain a
PostgreSQL target at any time; Lite's file is per-seat and disposable.

So DuckDbSchemaGenerator grew StoredCollectors (the SQL Server subset) and the
three generation loops walk it instead of the catalog -- filtered in ONE place, so
table, index and name generation cannot drift apart the way three independent
walks eventually would. Both ArchivableTables lists follow, or Lite would archive
tables it never created.

Note this makes Lite's emitted schema IDENTICAL to dev's. The PR says no Lite
behaviour change and that is still true: filtering preserves today's behaviour,
whereas letting the tables through would have changed it.

The test pins moved from literals to the derived set, except one deliberately left
alone: DuckDbSchemaEquivalenceTests' golden stays pinned at 41 by hand, because it
is a frozen historical oracle and is SUPPOSED to fail when a SQL Server collector
is added. Also:

- CollectorGateSurfacePinTests compared the RAW definition.AppliesTo against the
  COMPOSED by-name gate. Equivalent only while every collector was SQL Server: the
  composed overload also requires the engine to match, so a PostgreSQL definition
  whose own gate returns true unconditionally (slots) legitimately disagrees with
  it for a SQL Server target. Now compares composed-to-composed, which is the claim
  that matters -- Lite gates by name, Darling's runner by definition.
- The seven MCP tools joined KnownLiteMissingMcpTools as architectural entries, not
  to-dos, with the reason.
- Lite's schedule table is compared against the SQL Server subset of the shared
  defaults; a phantom row for a collector Lite can never run would surface in its
  own schedule UI.

Verified: 0 errors, 0 warnings. Six harnesses pass.
The viewer column ACL is expressed twice on purpose -- once in C#
(DarlingManagedRoles.ViewerRestrictedConfigTables, applied by the service on a
managed start) and once in Darling/tools/provision-roles.sql (the script an
operator runs by hand against a bring-your-own store) -- and
ProvisionRolesAclDriftTests compares them so neither can quietly diverge. I
updated the C# and not the SQL, so the drift test failed exactly as designed.

engine and port added to the GRANT SELECT list. Non-secret, like host.

pincheck now compares the two ACLs both directions, since this is the second
registration point in two runs that only CI could tell me about. Writing that
check found a bug in itself first: IndexOf("SecretColumns:") also matches inside
"NonSecretColumns:", so the block ended before it began and the C# side parsed as
zero columns -- which reads as 'the C# list is empty' rather than as a parse error.
Anchored past the opening label instead.
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Not touching this PR — it's yours — but flagging something about its review before anyone merges on the strength of a green check.

The review ran for real and posted nothing. Run 31582450960: 09:20:25 → 09:26:04, conclusion success, no workflow validation failed skip signature, and total_cost_usd: 3.53 of genuine token spend. Then: /pulls/2213/reviews → 0, /pulls/2213/comments → 0, and no issue comments on the thread at all.

So this is not the green-but-skipped case we already know about (where the action self-skips because the workflow differs from the default branch and reports success anyway). This is a new variant: the review executed, spent five and a half minutes and three and a half dollars on 14,666 changed lines, and its output did not land. Either it genuinely found nothing to say across eight schema rungs and a new engine target, or the posting step failed silently.

Worth knowing because the discriminator we've been using — skip signature plus token evidence — says "genuine review" here, and it would have been reasonable to read that as reviewed. The check that actually catches it is cruder: did any comment appear?

I'd want the review output before this lands, given what's in it: StorageVersion.SchemaVersion 60 → 68, PgSchemaGenerator, and both workflow files. Not least because the rung range matters to me directly — my #2211 map migration takes V69 behind it, and a rung numbered inside your range would be silently skipped rather than rejected, which for the map table means every read returns empty and the cutover reads as "plan not yet collected" while holding nothing.

Suggest re-running the review workflow on this head and checking a comment actually appears. Happy to leave that to you.

erikdarlingdata added a commit that referenced this pull request Aug 12, 2026
Numbered 69 rather than 61, and gapped deliberately. #2213 claims V61-V68 and
moves StorageVersion.SchemaVersion to 68, and the number is readable from its
diff now -- it does not have to land first for me to know it. The gap costs
nothing, because the runner skips any rung at or below the store's current
version and stamps what it applies; a COLLIDING number is the expensive mistake,
since it would be silently skipped rather than rejected and a map table that
never got created reads as 'plan not yet collected' on every lookup. The cutover
would look healthy and hold nothing.

If #2213 grows past V69 before it merges, that surfaces as a rebase conflict on
this list, which is a visible failure rather than a silent one.

DDL is byte-identical to QueryStorePlanMap.CreateTableSql -- checked, not
assumed, since a migration that drifts from the class the code reads through is
the same silent-empty-table outcome by a different route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Full review: three dimensions plus the first live run of the feature

Repo-side session. Review ran as three parallel deep passes (engine seam/dispatch, storage/migrations V61-V68/data contract, collectors' SQL/alerting/read surfaces - every finding below was verified in source at head f4dfe26 before inclusion) plus the thing the PR body says nobody had done: the service was pointed at a real PostgreSQL target. Local rig, this branch's build: PostgreSQL 18.4 store (BYO, fresh), stock PostgreSQL 18.4 target on a second cluster, darling_monitor with pg_monitor, seeded activity, service run in console mode through multiple sweep cycles.

Verdict: the foundation is genuinely strong and the feature is currently non-functional against a live target. The defects cluster in exactly the layer the honesty section predicted - "the queries are proven; the wiring around them is not."


The live run, first ever

What passed, with the runbook's own proof points:

  • Pre-flight is excellent: [PASS] e2e-pg-target: PostgreSQL 18 (server_version_num 180004), writer, not Aurora - 5 of 7 PostgreSQL collectors apply (skipped: pg_statement_stats, pg_wait_stats) - exactly the promised shape, Aurora detection correct on stock PG.
  • Store migration clean: Postgres store ready (schema v68, 67 migration(s) applied), all 20 CAGGs ready, collection loop started.
  • The per-database path (the ONE engine-neutral branch) works end to end: pg_autovacuum_stats => 0 rows (sql:14ms, pg:0ms), SUCCESS - and the zero is HONEST (the target's own autovacuum had cleaned my seeded 25k dead tuples before the sweep; n_dead_tup=0 confirmed on the target).

What failed, verbatim:

collector_name         | status  | count     (260s window)
pg_autovacuum_stats    | SUCCESS |     1
pg_io_stats            | ERROR   |     2
pg_replication_slots   | ERROR   |     2
pg_statement_stats     | SUCCESS |     2   <- Aurora-only, this target is not Aurora
pg_wait_stats          | SUCCESS |     2   <- same: fake success, no skip, no explanation
pg_wraparound_stats    | ERROR   |     1
pg_xmin_horizon        | ERROR   |     2
database_config        | SUCCESS |     2   <- T-SQL on-load collectors, fake-success on a PG target
database_scoped_config | SUCCESS |     2
server_config          | SUCCESS |     2
server_properties      | SUCCESS |     2
trace_flags            | SUCCESS |     2
[e2e-pg-target] pg_xmin_horizon => ERROR: Keyword not supported: 'host'.
[e2e-pg-target] pg_replication_slots => ERROR: Keyword not supported: 'host'.
[e2e-pg-target] pg_io_stats => ERROR: Keyword not supported: 'host'.
[e2e-pg-target] pg_wraparound_stats => ERROR: Keyword not supported: 'host'.        (x25 in ~4 min)
[e2e-pg-target] Failed to ensure XE sessions: Keyword not supported: 'host'. - deadlock/blocked-process collection will read zero rows until resolved

Two runbook corrections from executing it (the runbook asks to be corrected from what you actually see): a non-SSL lab target cannot connect at all until "encryptMode": "optional" is set (fail-closed TLS means stock ssl=off PostgreSQL is unreachable; the runbook never mentions it); and there is no --console verb - the bare exe IS console mode, worth one sentence at step 5.


BLOCKING

B1. Six of seven PG collectors are handed a SqlConnection - confirmed live above. DarlingCollectorRunner.RunAsync's per-database branch (line 249) resolves TargetProviders.For(...); the else branch at line 478 does new SqlConnection(server.ConnectionString) and serves everything that is not RunsPerDatabase - which is six of the seven (only pg_autovacuum_stats fans out). SqlClient rejects the Npgsql keywords before any query runs; the exception is neither SqlException nor PostgresException, so it misses both classification arms and lands as raw ERROR, per collector, per sweep, forever. The three Tier-0 outage predictors this PR is built around collect nothing. Fix shape: route the else branch through TargetProviders.For(server.Target) exactly as line 269 already does, plus ONE runner-level test asserting a PostgreSQL target yields an NpgsqlConnection - the assertion whose absence hid this (both sides test green today; the seam does not).

HIGH (alert correctness - the wraparound alert is wrong in three independent ways)

H1. MultiXact age is graded against the XID GUC. PostgresAlertEvaluator.cs:137-139 computes thresholds from AutovacuumFreezeMaxAge and compares against WorstAge = max(XidAge, MultiXactAge). Stock defaults: 200M for XIDs, 400M for MultiXacts (PG docs, runtime-config-autovacuum) - so MultiXact warnings fire 2.2x premature, and the alert body prints autovacuum_freeze_max_age {N} while WorstCounter says MultiXact: self-contradicting text. The data is already collected (autovacuum_multixact_freeze_max_age, pct_toward_multixact_emergency); the adapter just does not select it. Grade each counter against its own setting.

H2. No absolute floor - Critical is unreachable on tuned clusters. criticalAt = 2.0 x setting with the XID wall at 2^31: at a setting >= ~1.07B (tunable to 2B), Critical exceeds the wall and can never fire, on exactly the clusters most at risk. The PR already has the correct absolute ladder in DarlingMcpPgWraparoundTools (74.5% failsafe / 98% / 99.86% writes-stop); the alert ignores it. OR-in the absolute arm.

H3. pg_xmin_horizon cannot see an idle-in-transaction backend - its own headline case. The session branch filters WHERE a.backend_xmin IS NOT NULL (PgXminHorizonCollector.cs:77); in READ COMMITTED an idle-in-transaction writer releases its snapshot (backend_xmin NULL) but keeps backend_xid, which still holds the horizon - PG docs name both columns. backend_xmin IS NOT NULL OR backend_xid IS NOT NULL, age = GREATEST of both.

H4. PostgreSQL alerts bypass mute rules entirely. DarlingWorker.cs:2448 hardcodes Muted: false; the deliverer only honors the flag; the mute check lives in AlertEngine and EvaluatePostgresAlertsAsync never consults muteRuleService. Contradicts the PR body and the code's own comment. A muted "PostgreSQL Wraparound Risk" still emails.

H5. A dead PostgreSQL connection never triggers reconnect. The reconnect arm is ex is PostgresException (DarlingWorker.cs:3806); a dead socket surfaces as plain NpgsqlException, which the provider correctly maps to ConnectionFatal - but the call site cannot reach the classifier. Runtime stays "connected", Server Unreachable never fires, every collector errors forever. Straight asymmetry vs SqlClient's Class>=20 arm.

MAJOR

  • On-load dispatch has no engine gate (confirmed live: the five fake-success T-SQL rows above). EngineMatches guards the scheduled sweep (DarlingWorker.cs:3122) with a comment explaining exactly why; the on-load loop (3039-3059) never got it.
  • XE session provisioning runs SqlClient against PG targets on every connect (confirmed live, verbatim above) - gate DarlingXeSessions.EnsureAllAsync by engine, not just IsAzureSqlDb.
  • Aurora-only collectors log SUCCESS on stock PG, not a skip (confirmed live). Within-engine AppliesTo gates produce zero-row SUCCESS with no explanation - ~2,880 fake rows/day/server at 1-min cadence, and the PR body's "graceful skip with explanation" is not what ships. Wants a SKIPPED-class outcome or pre-dispatch filtering with a probe-time note (the probe already names them).
  • No per-alert cooldown on the PG path - every AlertEngine site gates on CooldownElapsed; EvaluatePostgresAlertsAsync has none and the sweep is 30s. A database parked over a threshold writes ~2,880 history rows/day.
  • Finding.Subject computed then discarded (Context: null) - two databases over the line collapse into one metric-level dedup key; the second is silently dropped for the cooldown window. Same for two bad slots.
  • xmin persistence gate counts by source, not holder - sixty different sessions winning once each renders as "pid X held it 60/60"; and zero-row-when-healthy means the denominator counts only observations WITH a holder, so a transient holder reads 100% persistent. Fix together with:
  • The collector attributes its own connection - no pid <> pg_backend_pid() filter, so Darling's own snapshot is a permanent session holder, making zero-rows-healthy unreachable and masking the denominator bug.
  • GREATEST(x,0) does not preserve NULL - PG docs: GREATEST ignores NULLs, so GREATEST(NULL,0) = 0. DarlingPgIoReader's comment claims the opposite ("NULL survives the arithmetic"). Outputs are unchanged today (coalesce + write_counters_tracked computed on the raw column), but the comment licenses the next person to build a fabricated-zero bug. Fix comment or make it truly NULL-preserving; same for op_bytes NULL-on-PG18 flattening to 0 with no discriminator.
  • Three ::text timestamptz renders shift on non-UTC servers - xact_start/query_start (PgXminHorizonCollector.cs:74-75) and prepared (:120) render in session TimeZone; the branch's own rule (stated three times, e.g. PgReplicationSlotsCollector.cs:85-89) says why this is wrong. UTC-only probes could not see it - the exact class the PR fixed everywhere else. Detail-text severity, but the contract is naive UTC.
  • Whole-file line-ending renormalization on three files - build.yml (876 lines), nightly.yml (487), CHANGELOG.md (2,692; also loses its 6 bare CRs). Real content: 4+4+8 lines. This poisons every sibling PR with whole-file conflicts (Plan-fetch candidate window and cut-safe watermark advance (#2210) #2211 is in flight against the same CHANGELOG) and destroys blame. Must be fixed pre-merge: re-apply the real edits over dev's blobs byte-precisely.

MEDIUM (summary - full detail available on request)

FetchFailedJobsAsync engine-blind (SqlClient per alert cycle on PG); Npgsql-wrapped TimeoutException classified ConnectionFatal (the covering test passes a bare TimeoutException, a shape the driver never throws); server_id identity carries neither engine nor port (SQL Server + PG on one host collide into one interleaved history; two PG clusters on one host ditto - and port was added as first-class config precisely for that); QueryStoreBackfill bypasses the composed gate via direct AppliesTo (latent - PG major reads as "assume newest"); two assertions now tautological (f(d,t)==f(d,t) in CollectorGateSurfacePinTests; StoredCollectors counted against itself in DuckDbSchemaGeneratorTests) - no net coverage loss but both would pass against broken code, notable given the suites' first-ever execution was CI; PgTableTuning hand-list omits pg_statement_stats (the per-minute twin of query_stats misses the insert-scale-factor override the file's own EXPLAIN rationale demands); MCP wraparound severity ignores MultiXacts (sorts by the max, labels by XID-only - a MultiXact-critical database sorts first labelled "ok"); autovacuum reader's ranking buries insert-only tables (ratio 0, below LIMIT - the collector's own comment calls that a classic wraparound route).

MINOR / LOW

xids_remaining overstates by the 3M stop margin (the MCP tool's 99.86% is right; the column disagrees); wraparound read excludes templates but the cluster stop-limit derives from ALL of pg_database (template0 aging is a real failure; shared catalog needs no connection - worth confirming engine behavior); aurora_stat_wait_event join on event_id alone (safe today by the id>>24 invariant; add type_id for free); hardcoded LIMIT 50 in the wait reader; PR-body "one GRANT pg_monitor / nothing created" vs README's correct pg_stat_statements prerequisites (body-only inaccuracy); stale "runner calls definition.AppliesTo directly" comments at three sites; PgIoStatsCollector fail-closed on unknown major while the SQL Server convention is assume-newest (defensible, unstated); registry test's column half is table-blind substring matching (the property half is the real ratchet); SharedCollectorDefaultsPinTests lost orphan detection for removed collectors; workflow comments still say "= 42" two lines above the correct 51/62. Observation worth its own issue: get_pg_top_queries returns queryid with no statement text, and queryid is unstable across majors - post-upgrade history joins to nothing human-readable.

VERIFIED CLEAN (attacked, held)

The catalog gate composition (fail-closed, one surface, all three runner entry points); engine declarations pinned by tests with no typo surface (sealed on the PG base, string parse lenient-in-config/strict-in-add_servers, both tested); V68 registry round-trip (seed $16/$17, load ordinals 13/14, add_servers $15/$16, NOT NULL DEFAULT, non-secret classification, provision-roles grants); MapProbedSchemaVersion V68 arm newest-first with correct under-report-and-refuse for mid-ladder stores; both migration spot-checks column-for-column exact (30 and 18 payload columns), all rungs idempotent, fresh-path V1/V8 interplay correct; retention/compression/hypertable machinery correctly untouched (catalog-driven; all seven inherit conversion, segmentby, purge; horizons 30/90d; the 4-day raw tier does NOT apply - RawTierCoverage excludes them); worker sizing 51/62 derives from HypertableCount=49 and the test parses the real lines; the naive-UTC WRITE path complete (all six Timestamp columns use AT TIME ZONE 'UTC'); CollectorTableCatalogShadowTests is the strongest addition - positive control, caught the real pg_replication_slots pg_catalog shadowing that would have silently returned the store's own empty slots forever; the reflection-arity fix on ViewerDataServiceTests closes a real optional-parameter hole; Lite isolation thorough and derived, not asserted; TLS fail-closed with the right VerifyFull/Require/Prefer ladder; DPAPI credential parity, nothing in logs; fault classification for PostgresException is genuinely operator-shaped (42501 vs 42P01 vs 0A000 distinguished, statement_timeout excluded from reconnect); pg_monitor covers every view read (docs-verified); reloptions threshold math exact incl. the reltuples=-1 sentinel; counter-reset clamping and single-sample-zero hold in the REAL read SQL with correctly-partitioned LAG; no viewer grids added, so #1949 pins are untouched and correctly so.


Gate

Before merge: B1 (with the runner-level connection-type test), the on-load engine gate, XE gating, H1-H5, the mute/cooldown/dedup cluster, the xmin self-attribution + persistence pair (together - fixing one alone worsens the other), the three ::text renders, and the line-ending renormalization (byte-precise re-apply; #2211 is in flight against the same CHANGELOG). Same PR strongly recommended: Aurora-skip logging as a real skip; the GREATEST/NULL comment-vs-code resolution. Follow-up issues per the repo's deferral rule: server_id engine/port identity (product decision - it changes what a re-registered server means), PgTableTuning coverage, queryid-without-text, the tautological-assertion repairs, and the minors above.

The honest close: the probe layer, the migration ladder, the catalog gating, and the store contract are the hard parts of a second engine, and they are done well here - several of the tests this PR adds are the strongest in the repo. The wiring defects are all one family (four call sites that reach a monitored server and never learned about engines), they were exactly where the PR body predicted the risk lived, and one of them is a mechanical fix that turns this from non-functional to collecting on five of seven within a day. The live-run transcript and rig recipe are available if useful for the fix round - the same two-cluster setup reproduces everything above in under ten minutes.

erikdarlingdata added a commit that referenced this pull request Aug 12, 2026
CI caught two things and the second one is mine to own.

The mechanical failure: I added a rung and never bumped
StorageVersion.SchemaVersion, so ten invariants asserting the version tracks the
newest script went red (ScaffoldTests.StorageSchemaVersion_TracksLatestMigrationScript
and friends, plus the live-Postgres tests that migrate a store).

The real error: I numbered it 69 to sit above #2213's 61-68 and argued at length
that 'the gap costs nothing'. It costs everything in one order. The runner skips
any rung at or below the store's stamped version, so if THIS branch merges first
and a store stamps 69, every one of #2213's 61-68 is skipped SILENTLY on every
upgraded store. I wrote three paragraphs warning about that exact trap and then
walked into it from the other side.

Gapping is only safe when the gap-filler lands first, which one branch cannot
guarantee about another. So this is now 61 = max(dev) + 1 with no gap: whichever
PR merges second renumbers to sit immediately above the other, and the ordering
is settled by a rebase conflict on the migration list rather than by assumption.
A collision is loud. A gap is silent, and a map table that was never created
reads as 'plan not yet collected' on every lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

To the author, a coordination question plus a concrete offer - answer here and I will see it within minutes.

QUESTION: are you currently mid-fix on B1 (the runner else-branch SqlConnection defect)? One word is enough - 'mine' or 'take it'.

IF MINE: the fix shape from the review stands - route DarlingCollectorRunner.RunAsync's else branch (line 478) through TargetProviders.For(server.Target) exactly as the per-database branch at line 269 already does, using provider.CreateConnection plus the provider command path, and add one runner-level test asserting a PostgreSQL target yields NpgsqlConnection (that missing assertion is what hid this). My reproduction rig is two local clusters from the repo's own pg-runtime.zip - extract, two initdbs (store: superuser darling, timescaledb preload, workers 51/62; target: stock, darling_monitor with pg_monitor), darling.json with managed:false store + one servers entry with engine postgres and encryptMode optional (REQUIRED for a non-SSL lab target - fail-closed TLS refuses stock ssl=off otherwise), run the service exe bare (no verb - bare IS console mode). Under ten minutes total; every finding in the review reproduces.

IF TAKE IT: I build the B1 fix plus the on-load engine gate and the XE gating (the three findings that share one family) on a branch tonight, run it green against the live rig, and hand you the branch to cherry-pick into this PR - integration and the rest of the gate stay yours.

Retrieval note so nothing else gets lost: my review and this comment are PR CONVERSATION comments - fetch via the REST endpoint repos//issues/2213/comments (the review is comment id 5266321251). They do NOT appear under pulls/2213/reviews, and the newest comment on this thread is often a bot summary - filter user.login != claude[bot] when polling.

… grade each freeze counter properly

Review findings B1, the on-load/XE/failed-jobs engine gates, H1 and H2. The live
run in the review is what found the first four; every one is a call site that
reaches a monitored server and never learned engines exist.

B1 (BLOCKING). DarlingCollectorRunner had two paths. The per-database branch
resolved TargetProviders.For(target) correctly; the branch serving everything else
did `new SqlConnection(server.ConnectionString)` literally. Six of the seven
PostgreSQL collectors take that path -- only pg_autovacuum_stats fans out -- so they
were handed a SQL Server connection. SqlClient rejects Npgsql's keywords while
PARSING the string, before any query runs, and the resulting ArgumentException is
neither SqlException nor PostgresException, so it missed both classification arms
in DarlingWorker and recorded a raw ERROR every sweep forever. All three Tier 0
outage predictors collected nothing.

The provider is now resolved once for both branches, the connection comes from it,
and all five command sites in that branch use the provider-aware overload.

Connection creation is extracted to CreateTargetConnection so it can be PINNED,
because the absence of that assertion is what hid this: both providers were correct
and both were separately tested, and nothing checked that the RUNNER asked them.
New CollectorRunnerConnectionEngineTests asserts a PostgreSQL target yields an
NpgsqlConnection, a SQL Server target still yields a SqlConnection, and -- so the
reason is recorded rather than just described -- that a SqlConnection cannot even
parse a PostgreSQL connection string and that the throw is neither exception type
the classifier handles.

Plus a source guard. My first version banned constructing a connection at all,
which flagged the Azure SQL master hop -- legitimately opening a SqlConnection from
a plan SqlServerTargetProvider built, because per-database enumeration on Azure SQL
DB is a SQL Server feature. A rule that flags correct code teaches people to
suppress the test, so it is narrowed to the actual hazard: never build a connection
from server.ConnectionString, whose engine is whatever the target is.

Three more engine-blind call sites, all confirmed in the review's live transcript:

- The ON-LOAD dispatch loop had no engine gate, though the scheduled sweep has one
  with a comment explaining exactly why. A PostgreSQL target ran server_config,
  database_config, database_scoped_config, trace_flags and server_properties as
  T-SQL and logged five fake SUCCESS rows. Fake successes are worse than errors
  here: the health bands and analysis key on status.
- XE session provisioning ran SqlClient on every connect, logging "Failed to ensure
  XE sessions ... deadlock/blocked-process collection will read zero rows" -- alarming
  and meaningless on an engine with no Extended Events, whose deadlock collectors
  are engine-gated off anyway.
- The failed-jobs feed opened a SqlConnection per alert cycle. msdb and SQL Agent
  are SQL Server concepts; it gated Azure SQL DB and never considered engine.

H1. The wraparound alert graded MultiXact age against autovacuum_freeze_max_age.
The governing setting is autovacuum_multixact_freeze_max_age -- 400M by default
against the XID's 200M -- so MultiXact warnings fired 2.2x premature while the body
printed "autovacuum_freeze_max_age N" beside a counter named MultiXact,
contradicting itself. The collector has stored both settings since V63; the adapter
simply never selected the second one. Each counter is now graded against its own
setting and the worse RELATIVE breach wins, severity first, which is the only
comparison that means anything when the denominators differ. One unusable setting
no longer silences the other counter.

H2. criticalAt was 2x the setting with the wall at 2^31, so above a setting of
~1.07B (tunable to 2B) Critical sat beyond the wall and could never fire -- on
exactly the clusters closest to a write outage. The absolute arm is OR-ed in at
vacuum_failsafe_age, ~74.5% of the space, matching the ladder
DarlingMcpPgWraparoundTools already classifies against, and the message says which
arm fired.

Verified: builds 0 errors. probecheck pins the connection types and the source
guard; alertcheck is 43 assertions green including the premature-MultiXact case,
the tuned-cluster reachability case, and both partial-setting cases. Two harness
call sites turned out to be wrong rather than the code -- a parameter rename made
`fm:` mean the multixact setting where it used to mean the XID one, which silently
repointed three existing assertions. Corrected, and both settings now have names
that cannot be confused.
…PG alerts mutes, cooldown and per-subject dedup

Review findings H3, H4, H5, the xmin self-attribution/persistence pair, the three
::text renders, and the wrapped-timeout classification.

H3 + the xmin pair, fixed together because fixing one alone makes the others worse.

  The session branch filtered WHERE backend_xmin IS NOT NULL. Under READ COMMITTED an
  idle-in-transaction writer has RELEASED its snapshot -- backend_xmin is NULL -- while
  still holding backend_xid, which pins the horizon just as hard. So the collector
  was blind to idle-in-transaction: its single most-cited cause, and the one the read
  surface leads with. Now GREATEST of both ages, with either column qualifying.

  The collector also attributed the horizon to ITS OWN snapshot. Darling's read sits
  in pg_stat_activity with a backend_xmin like any other session, so it was a
  permanent 'session' holder -- zero-rows-when-healthy was unreachable, and it padded
  the persistence denominator so a real transient holder read as chronic. Excluded
  via pg_backend_pid().

  And persistence was counted per SOURCE, not per holder: sixty different sessions
  each winning once rendered as "pid X held it 60/60", the exact shape of a chronic
  holder and the opposite of the truth. Now counted per (source, holder). This only
  became reachable once the self-attribution went, because while our own backend was
  always a holder every collection had one and the distinction was invisible.

H4. PostgreSQL alerts bypassed mute rules entirely -- Muted was hardcoded false and
the mute check lives in AlertEngine, which this path deliberately rides alongside.
"Alongside" got read as "without". A muted PostgreSQL Wraparound Risk still emailed.
The same MuteRuleService.IsAlertMuted delegate the engine binds is now consulted, so
a mute-rule reload takes effect on the next sweep exactly as for every SQL Server
family.

Also missing on that path, both of which every AlertEngine site has:
  - A cooldown. The sweep is 30 seconds, so a database parked over a threshold wrote
    ~2,880 history rows a day. Now gated per (server, metric, subject) on the
    configured cooldown, stamped even when muted so unmuting does not produce a
    backlog.
  - A real dedup fingerprint. Finding.Subject was computed and then discarded
    (Context: null), so the send-side IncidentCooldown fell back to its metric-level
    key and two breaching databases -- or two bad slots -- collapsed into one incident
    with the second silently suppressed. The subject now travels as a #1140
    AlertIncident DedupKey, identity only, so a recurrence of the same subject
    collapses and a different subject does not.

H5. The reconnect arm required `ex is PostgresException`, and a dead socket surfaces
as a plain NpgsqlException with no SQLSTATE. The provider ALREADY classifies that as
ConnectionFatal; the call site simply could not reach it, so the runtime stayed
"connected", Server Unreachable never fired, and every collector errored forever.
Any exception on a PostgreSQL target now goes through the classifier.

While in that classifier: a command timeout arrives from Npgsql as an
NpgsqlException WRAPPING a TimeoutException. Only the outer type was checked, so
every real timeout fell through to the NpgsqlException arm and was classified
ConnectionFatal -- a slow query forcing a reconnect, the exact storm the SQLSTATE arm
is careful to avoid. The bare TimeoutException the covering test constructs is a
shape the driver never throws.

The three ::text renders on timestamptz (xact_start, query_start, prepared) now use
AT TIME ZONE 'UTC' like the rest of the branch.

Verified: builds 0 errors / 0 warnings. avcheck pins all five xmin properties;
alertcheck 43 green. The fixed xmin SQL executes on live Aurora 17.7 and returns
ZERO rows, which is the self-attribution fix showing its work -- before it, our own
connection was always a holder.

And a new probe finally PROVES the timestamptz class instead of asserting it:
probe_timestamptz_render_nonutc.py sets the session to UTC+14, where the bare cast
renders 2026-08-13 02:28:27+14 and the UTC render 2026-08-12 12:28:27 -- fourteen
hours and a different calendar day apart. Every previous probe ran at TimeZone=UTC,
where the two are byte-identical, which is precisely why this class kept surviving
review.
…rmalize three files

Two remaining pre-merge items from the review.

Aurora-only collectors logged SUCCESS on stock PostgreSQL. The pre-dispatch gate
covered the wrong DIALECT (EngineMatches) but said nothing about a collector that is
right-dialect and simply inapplicable to this target: pg_wait_stats and
pg_statement_stats read Aurora-only functions, so on stock PostgreSQL they
dispatched, returned 0 rows, and RunOneAsync recorded SUCCESS. At a 1-minute cadence
that is ~2,880 fake successes a day per server, feeding the health bands and analysis
which key on status -- and the PR body promised a graceful skip with an explanation.
Confirmed on the review's live stock-PostgreSQL run.

Both dispatch loops now also consult the composed gate. I took pre-dispatch
filtering over adding a SKIPPED status because a sixth collection_log status widens
a contract shared with Lite, and the outcome is not silent either way:
--test-connection already names exactly which collectors do not apply to a target,
before the service runs.

Scoped to PostgreSQL targets deliberately. On SQL Server the same zero-row-SUCCESS
path covers a long-established handful of Azure-gated collectors, and silencing
those changes a shipping SKU's log semantics -- that deserves its own decision rather
than riding along in a PostgreSQL PR.

Line endings. build.yml, nightly.yml and CHANGELOG.md were whole-file
renormalizations: 1754, 976 and 5384 changed lines carrying 4, 4 and 8 lines of real
content. That poisons every sibling PR with whole-file conflicts -- #2211 is in flight
against the same CHANGELOG -- and destroys blame.

Restored dev's exact blobs and re-applied the real edits at the byte level. Now 4/4,
4/4 and 2/0, and the CHANGELOG's 6 bare CR characters survive (verified by count
before and after, not by eye).

Worth recording how this happened, because the trap is easy to re-enter: the working
tree holds LF and git stores CRLF through a filter, so a tool that reads a file,
edits text and writes it back can flip the whole file without touching a line of
content. Byte-level replacement of exact substrings is the only safe method here. My
first attempt at measuring it was also wrong -- double-escaped \r\n in a shell
heredoc made every CRLF count read zero, which briefly looked like an empty file.

Verified: builds 0 errors / 0 warnings; six harnesses green.
… automatic

DocCommentHygieneTests was the only CI failure -- 4771 of 4772 passed. Inserting
CreateTargetConnection put its doc block directly beneath
OpenDatabaseConnectionAsync's, so that member lost its documentation and mine
carried two summaries. Per the test's own instruction the displaced block is MOVED
back to the member it documents, not deleted.

This is the second time today. The first time I wrote down 'check the stacked-summary
scan' as a habit; a habit is not a check. It now runs inside pincheck, which I
already run every round, so forgetting is no longer possible.

Verified: builds 0 errors / 0 warnings; scan reports 0 offenders; pincheck green
including the new check.
Both were MEDIUM findings in the review, both were my own doing while fixing CI, and
both are cheap enough to repair rather than defer.

CollectorGateSurfacePinTests compared AppliesTo(definition, t) against
AppliesTo(name, t). The by-name overload just looks the name up and calls the
by-definition one, so only a corrupt name->definition map could fail it and a bug in
the composed rule itself would sail through. The expected value is now STATED
independently -- TargetEngine == target.Engine && definition.AppliesTo(target) -- and
both overloads are checked against it, so the lookup AND the composition are pinned.

DuckDbSchemaGeneratorTests counted the filtered sequence against itself, which cannot
fail. It now compares the emitted table NAMES against the stored collectors' names as
a set, and asserts no PostgreSQL table leaks into Lite's DuckDB -- which is the actual
invariant that file guards after the engine filtering.

Also moved the local verification harnesses to Darling/tools/pg-harnesses and
gitignored them. They previously lived on one SQLServerSetup branch, so every branch
switch there deleted them mid-session; they now sit in the repo whose branch I
control. Ignored rather than committed because whether pincheck belongs in the repo
is a decision, not a default -- it is the only check for the CI workflow cluster
numbers, the C#-vs-SQL viewer ACL, the schema-probe arity, and now the
stacked-summary scan, none of which any test covers.

Verified: builds 0 errors / 0 warnings; pincheck green, incl. the rebuilt tree scans
(99 MCP tools / 80 endpoints / 19 exclusions, 0 stacked summaries).
The last 'same PR strongly recommended' item from the review. The comment claimed
'NULL survives the arithmetic on purpose'. It does not, in two places:
GREATEST(NULL, 0) returns 0 because GREATEST ignores NULLs, and the outer
coalesce(SUM(...), 0) would flatten it regardless.

Outputs are unchanged -- write_counters_tracked is computed on the RAW column and
already carries the information -- so this is a comment fix, not a behaviour fix. But
the comment was worse than useless: it would have licensed someone to drop the
tracked flag believing the NULLs were carrying the distinction. The comment now says
plainly that the numbers DO come back as 0 and that the flag is the only
discriminator between 'no writes happened' and 'writes are not measured here'.

Verified on live Aurora 17.7 rather than reasoned about: GREATEST(NULL,0) = 0,
GREATEST(NULL-NULL,0) = 0, and the CASE-guarded form preserves NULL.

I also overstated this in the pganalyze gap analysis, claiming Darling was more
correct because it preserves NULL where they coalesce. Corrected there and in memory:
neither product preserves the NULL through the read, and Darling's genuine advantage
is narrower -- it returns a trackedness flag and pganalyze has no equivalent.
…s, and correct the runbook

Three more review items, two MEDIUM and one from executing the runbook.

The MCP wraparound severity graded the XID percentage only, so a database at 80%
toward MultiXact wraparound and 3% on XIDs was labelled 'ok' -- directly contradicting
the tool's own description, which promises 'a server can look fine on transaction IDs
and be in trouble on MultiXacts'. Both counters are now graded on the same ladder,
the worse label wins, and the label says '_multixact' when that is the reason, since
the remedy differs and a reader would otherwise go looking at transaction IDs.

The autovacuum reader ranked on dead tuples over threshold, which buries append-only
tables at ratio 0 -- below the LIMIT -- even though never-vacuumed is a classic
wraparound route and the collector gathers inserts_since_vacuum precisely for it. Now
ranks on the WORSE of the two ratios. The -1 not-applicable sentinel is kept out of
the arithmetic by a CASE rather than producing a negative ratio.

Validated on live Aurora 17.7 with a fixture built for the failure: an insert-only
table at 10x its insert threshold with zero dead tuples now ranks above a table at
0.5x its dead threshold, a disabled table still sorts first, and the -1 sentinel
yields no ratio. A ranking bug is invisible without a row that exercises it.

Runbook, from the reviewer actually running it:
 - A target with TLS off entirely needs encryptMode 'optional'.
   trustServerCertificate relaxes VERIFICATION, not the requirement, so stock
   self-hosted PostgreSQL with ssl=off -- the normal lab shape -- is unreachable until
   the other setting changes. The failure gives no hint of that.
 - There is no --console verb; the bare exe IS console mode. Worth a sentence because
   hunting for the flag reads like a missing feature.

Verified: 0 errors / 0 warnings; four harnesses green.
Housekeeping from the review's MINOR list, all verified rather than reasoned about.

The wait reader's LIMIT was hardcoded at 50 while the tool advertised a
caller-supplied limit and applied it with Take(limit). A caller asking for more than
50 silently got 50, and every smaller request fetched rows to discard them. Now $4,
matching every other read in this store, and the redundant Take is gone.

The aurora_stat_wait_event join keyed on event_id alone. That is correct TODAY only
because Aurora packs the type into the event_id's high byte (event_id >> 24 =
type_id) -- an undocumented encoding, while the function hands us type_id in the same
row. A future Aurora reusing an event_id under a different type would have produced a
row-multiplying join that doubled every affected wait figure. Now keyed on both.

Verified on live Aurora 17.7 before and after, because a join-key change can multiply
rows: 32 source rows, 32 with the old join, 32 with the new one, and all 340 rows of
the wait_event catalog satisfy the high-byte invariant -- which is precisely why the
old form worked and why relying on it was still wrong.

Both workflow files' comments still described 'the 41-collector catalog +
collection_log = 42' two lines above the corrected 51/62 numbers. Now 48 + 1 = 49.
Patched at the byte level, so the files stay 4/4 against dev.

Lite's gate comment still said Darling's runner 'calls definition.AppliesTo(target)
directly'. It calls the COMPOSED CollectorCatalog.AppliesTo(definition, target),
which also requires the engine to match -- the distinction the whole two-engine gate
rests on.

Verified: 0 errors / 0 warnings; four harnesses green.
… templates

The last two wraparound items from the review's MINOR list, both verified on live
Aurora 17.7 rather than reasoned about.

xids_remaining counted to the raw 2^31. PostgreSQL refuses new write transactions
with roughly 3,000,000 ids still on the clock, so the column overstated the runway by
that margin -- and disagreed with the MCP tool's own 99.86%-of-space figure, which
already accounts for it. Now counts to the stop point, clamped at 0 so a database past
it reports none left rather than a negative.

The read excluded templates. The cluster-wide stop limit derives from the oldest
datfrozenxid anywhere in pg_database, so excluding template0/template1 could
understate cluster risk by exactly the amount that matters, and template0 aging
without ever being vacuumed is a documented route there -- usually after a major
upgrade.

The reviewer flagged that the shared-catalog behaviour was worth confirming rather
than assuming, so I confirmed it: datfrozenxid comes from pg_database, which is
SHARED, and template0's row reads fine despite datallowconn = false. No connection is
involved, so the setting that blocks the per-database fan-out is irrelevant here.

That makes two queries with opposite and both-correct answers, which is a trap for
the next reader: the per-database enumeration MUST keep excluding templates because it
opens a connection per database and template0 refuses them. The Lite test now asserts
the inclusion and names the Darling test that pins the exclusion, so nobody aligns
them in the name of consistency.

Worth noting the harness earned itself: my new doc block landed above an existing
one-liner -- the same stacked-summary trap, third time -- and pincheck caught it in
seconds instead of eight minutes of CI. That is the whole reason it went in there.

Verified: 0 errors; four harnesses green; ladder still 67 rungs / top V68 (the column
set is unchanged, only the computed values and the row filter).
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Migration-number update for your fix round: #2211 has correctly claimed V61 (its CI is green on it after a ladder-pin fix - dev's tests enforce a CONTIGUOUS ascending ladder, so the earlier V69-with-a-gap approach was structurally impossible, which is worth knowing for any future claim). Your V61-V68 rungs must shift to V62-V69 when you rebase for the fix round, and StorageVersion/probe arms move with them. If #2211 has not merged by the time you push, re-verify dev's actual MAX at that moment - whoever lands second renumbers.

QueryStoreBackfill.RunServerSliceAsync gated on QueryStoreCollector.Instance
.AppliesTo(target) — the definition's own override, which never checks the engine. It
reads SqlMajorVersion, and CollectorTargetInfo treats 0 as 'assume newest', so a
PostgreSQL target (which has no SqlMajorVersion at all) sailed straight through into a
method that opens SqlConnections. Latent today only because the caller is reached from
a SQL-Server-shaped path; one new call site and it is B1 again. Now the composed
CollectorCatalog.AppliesTo, which requires the engine to match.

PgTableTuning omitted pg_statement_stats from the per-table
autovacuum_vacuum_insert_scale_factor overrides. It is query_stats' per-minute
PostgreSQL twin — same shape, same cadence, same pure-insert hypertable chunks — so the
file's own EXPLAIN-backed rationale applies unchanged: the stock 0.2 leaves the day's
hot chunk stale before the TimescaleDB rollover and the Index Only Scan degrades to
heap fetches. Missed because the list is hand-maintained rather than derived from the
catalog.

Which is also why the test pinned Statements.Count at a literal, and adding a
statement broke it — the same count-pin class that has now bitten several times. Pin
updated to 11 with the arithmetic spelled out, and pincheck pins both the count and
the number of tables carrying the override, so the next addition is caught locally.

Verified: 0 errors; four harnesses green.
…L substring

The only CI failure on the last push -- 4765 of 4772 passed. The test matched
"l.dead_tuples::numeric / NULLIF(l.vacuum_threshold, 0) DESC" verbatim, and adding the
insert-only ratio reformatted that clause onto separate lines. The behaviour it exists
to protect was intact and strictly improved; the test failed on whitespace.

Now asserts the ORDER BY's structure: both ratios present, each dividing by its own
threshold, the denominators guarded, both combined with GREATEST so the WORSE one
decides rather than one being a tie-break behind the other, and the three tiers in
order -- disabled, then ratio, then raw counts.

Verified the assertions against the real generated SQL before committing, since I wrote
both sides of this one and a structural assertion that quietly matches nothing is worse
than the brittle version it replaced. The behaviour itself is separately proven on live
Aurora, where an insert-only table at 10x its threshold with zero dead tuples ranks
above a table at 0.5x its dead threshold.

Verified: 0 errors; four harnesses green.
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Migration-number traffic update for your fix round: I am claiming V62 for #2171's plan_xml_compression store setting (building now, targeted at 3.5.0). With #2211 holding V61, your rungs shift to V63-V70 when you rebase. Same rule as before: whoever pushes re-verifies dev's actual MAX at that moment.

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