Skip to content

pg_statement_stats: the pg_stat_statements_info epoch read is gated on the relation's existence, not the server major — the collector runs again on clusters whose extension is below 1.9 or outside public (#3818) - #3824

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3818-pss-info-to-regclass
Sep 20, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

The mechanism

#3694 appended the statements epoch to pg_statement_stats as a scalar subquery over public.pg_stat_statements_info, gated on postgresMajorVersion >= 14 with the comment "extension 1.9, the same release as toplevel, so the same major guards both". That gate conflates the engine major with the extension's catalog version. pg_stat_statements_info is created by the extension's 1.8→1.9 update script, and a 14+ engine whose database was upgraded in place or restored keeps pg_stat_statements at 1.8 until someone runs ALTER EXTENSION pg_stat_statements UPDATE — RDS and Aurora do not run it for you. On 23 of 50 PostgreSQL clusters in an upgraded fleet the whole collector failed 42P01 on that one column every cycle for ~25 hours, its base view readable the entire time, and the stored status said the extension was not installed. The hard-coded public. was the second failure mode: a relocatable extension lives wherever CREATE EXTENSION ... SCHEMA put it.

What was measured, and why the fix has the shape it has

The issue proposed (SELECT i.stats_reset FROM pg_stat_statements_info AS i WHERE to_regclass('pg_stat_statements_info') IS NOT NULL) as "a subquery that yields NULL when the relation is absent and never raises". That claim is false, and this PR records the measurement: on postgres:14 with CREATE EXTENSION pg_stat_statements VERSION '1.8' it raises 42P01 — a FROM item is resolved at parse analysis, before any WHERE runs. So does CASE WHEN to_regclass(...) IS NULL THEN NULL ELSE (SELECT stats_reset FROM pg_stat_statements_info) END: CASE short-circuits evaluation, not name resolution.

Only a read whose relation name is resolved at execution can be conditional on the relation existing, and core PostgreSQL has exactly one function that runs SQL text and returns its rows: query_to_xml. The shipped form, now PgStatementStatsCollector.StatementsEpochSql:

(SELECT CASE
        WHEN to_regclass(format('%I.pg_stat_statements_info', n.nspname)) IS NULL THEN NULL::timestamp with time zone
        ELSE substring(
                 query_to_xml(format('SELECT stats_reset FROM %I.pg_stat_statements_info', n.nspname), true, false, '')::text
                 FROM '<stats_reset>([^<]+)</stats_reset>')::timestamp with time zone
    END
 FROM pg_catalog.pg_extension AS e
 JOIN pg_catalog.pg_namespace AS n ON n.oid = e.extnamespace
WHERE e.extname = 'pg_stat_statements')

The schema comes from pg_extension rather than the search_path, deliberately: the issue's unqualified to_regclass('pg_stat_statements_info') read NULL on the PG16 rig with the extension created in a schema off the path, which would have left the epoch dark there for a different reason. Measured on postgres:13, 14 and 16 containers (desktop-linux): extension at 1.8 → NULL, no error, on 13 and 14; ALTER EXTENSION UPDATE to 1.9 → filled, and equal to a direct read of the view; 1.10 created in schema ext off the search_path → filled; extension not created at all → NULL (the outer read fails first on its base object, which is the fault the EXTENSION_MISSING sentence is for). EXPLAIN shows InitPlan 1 (returns $0) — once per statement, not per row. query_to_xml renders timestamptz as 2026-09-20T17:54:26.803822+00:00, which the timestamptz input routine accepts, so ordinal 27 keeps its type and ReadAsync is untouched.

The >= 14 guard stays on toplevel as a proxy with its limit written down: measured on the same PG14 rig at 1.8, toplevel does not exist either (42703) — the column belongs to the extension's view, not the engine. A column reference cannot be made conditional without a probe, the failing case is loud (42703 → Unclassified → ERROR) rather than silently wrong, and the failing production population is Aurora, whose aurora_stat_statements() carries the column regardless. The comment says all of this; the brief's premise that "the server major is correct there" is not, and is reported as such.

The status text: a missing companion is not the extension missing

PostgresFaultOutcome's #3240 arm recorded every 42P01/42883 on a collector that declares an extension as EXTENSION_MISSING — "the pg_stat_statements extension this collector reads is not installed on this target", with CREATE EXTENSION and a shared_preload_libraries restart as the remedy. All three false here. PgExtensionDependency gains Companions: objects the extension gains at a later version, which the collector reads beside its base object. pg_statement_stats declares pg_stat_statements_info at 1.9. A new arm, checked first because it is the narrower claim, reads the object the server named off the message (relation "…" does not exist / function …( — Npgsql's relation/function fields are not consulted because PostgreSQL fills them for constraint errors, not unresolved names) and, when it is a declared companion, records the general non-fatal bucket with a sentence that names the object, says the extension is installed in the named database, gives both producing states and ALTER EXTENSION pg_stat_statements UPDATE as the remedy — never EXTENSION_MISSING, whose status word every health surface reads as an optional module at rest. The inference "the base object resolved" rests on PostgreSQL resolving FROM before the select list and naming the first object it cannot find; the declaration's doc comment names that as the property a declarer must hold.

Before: relation "public.pg_stat_statements_info" does not exist (SQLSTATE 42P01) — the pg_stat_statements extension this collector reads is not installed on this target. This is NOT a missing grant, and no GRANT will change it: run CREATE EXTENSION pg_stat_statements in database 'appdb' … The module also has to be in shared_preload_libraries first, which takes a server restart …

After: relation "public.pg_stat_statements_info" does not exist (SQLSTATE 42P01) — the missing object is pg_stat_statements_info, which is NOT the pg_stat_statements extension's base object: that resolved, so the extension IS installed in database 'appdb' and this is not the extension missing. pg_stat_statements_info is created by the extension's 1.9 update script, so either the extension is present at a catalog version below 1.9 (… runs ALTER EXTENSION pg_stat_statements UPDATE in database 'appdb' — a statement, with no restart and no shared_preload_libraries change; RDS and Aurora do not run it for you) or the object is installed outside the schema the query text names (… check pg_extension.extversion and extnamespace). This is NOT a missing grant …

What this does NOT do

Tests

  • Lite.Tests/PgStatementStatsFlavorTests: the epoch column is the existence-gated constant on every major (13/14/17) and both flavors; no NULL::timestamp with time zone AS statements_stats_reset arm remains; no static FROM … pg_stat_statements_info; the gate reads the extension's own schema and resolves the relation at execution; both CASE arms typed; the companion declaration (name, version, lowercase, bare, read by both flavors' text, and no other collector declares one). The Aurora pin that forbade \bpg_stat_statements\b is refined to a FROM of the view, since the epoch now names the extension as an extname literal.
  • Darling.Tests/PostgresFaultOutcomeTests: companion 42P01 (qualified and bare) → PERMISSIONS, the sentence above, none of the three lies; base object missing (view 42P01, Aurora function 42883, and the bare "boom" every other pin sends) → EXTENSION_MISSING as before; the companion arm is scoped to the declaring collector and falls back on an unknown database; MissingObjectNamedBy reads the last segment of relation and function messages and returns null for anything else.
  • Darling.Tests/PgStatementStatsInfoGateLiveTests (new, gated on DARLING_TEST_PG_TARGET — a superuser connection to a PostgreSQL with pg_stat_statements preloaded; skips in CI, whose live cluster is the store): at 1.8 the epoch read is NULL alone and inside the collector's whole statement over a readable base view; after UPDATE the collector's own BuildQuery text fills it with the same instant a direct read returns, Kind Utc; created in schema ext off the search_path it still fills. Ran green against the postgres:14 rig.
  • Executed on this Mac: 74/74 in the three Darling classes above plus PgExtensionDependencyContractTests; 37/37 in a Lite.Tests harness (flavor, delta-skip, Aurora-gate scan); the BRIEF census list (337 tests, 1 known Mac-only PresentationFramework red in LivePostgresCollectionHygieneTests, identical on clean dev); full Darling.Tests 11,868 with 188 platform reds (WPF/PresentationCore, SqlClient native config, Windows paths, PowerShell scripts), none in the touched area. Builds -c Release -p:EnableWindowsTargeting=true with 0 warnings on every touched project.

Closes #3818

…n the relation's existence, not the server major — the collector runs again on clusters whose extension is below 1.9 or outside public (#3818)

The #3694 epoch column read pg_stat_statements_info behind `postgresMajorVersion >= 14`,
conflating the engine major with the extension's catalog version: the view is created by the
extension's 1.9 update script, and a 14+ engine upgraded in place keeps the extension at 1.8
until ALTER EXTENSION ... UPDATE is run by hand. On 23 of 50 clusters in an upgraded fleet the
whole collector failed 42P01 on that one column for 25 hours with its base view readable.

Measured on postgres:13/14/16 with the extension at 1.8, 1.9 and 1.10 (in a schema off the
search_path): a static FROM of the view raises at parse analysis whatever WHERE or CASE
surrounds it, so the read is query_to_xml of SQL text - resolved at execution - behind a
to_regclass test on the name qualified with the extension's own schema from pg_extension.
NULL at 1.8 with no error, filled after UPDATE, filled outside public, InitPlan once per
statement. Ordinal 27 stays timestamptz; ReadAsync is unchanged.

The fault mapping learns the difference between the extension's base object and a companion it
gains at a later version: PgExtensionDependency.Companions, declared by pg_statement_stats for
pg_stat_statements_info at 1.9, and a PostgresFaultOutcome arm that names the object the server
named, says the extension IS installed, and gives ALTER EXTENSION ... UPDATE as the remedy -
recorded as the general non-fatal bucket, never EXTENSION_MISSING, which every health surface
reads as an optional module at rest.

Pins in both test projects; a gated live-target proof (DARLING_TEST_PG_TARGET) that ran against
the rig at 1.8 -> UPDATE -> schema ext.
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