From a0c07f0fe983afaaf30072ab328c91cbbe13e45b Mon Sep 17 00:00:00 2001 From: Jeffery Lofoneh Asamani Date: Tue, 29 Sep 2026 09:18:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(findings):=20replica=5Fidentity=5Fmissing?= =?UTF-8?q?=20=E2=80=94=20a=20published=20table=20that=20can't=20be=20upda?= =?UTF-8?q?ted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table published for UPDATE or DELETE needs a replica identity so the subscriber can find the row. Without one Postgres rejects the write itself: "cannot update table … because it does not have a replica identity and publishes updates". Reads and INSERTs keep working, so the failure lands on the first UPDATE after a migration, not at deploy time. A catalog-only gauge collector reads pg_publication and pg_class: DEFAULT with no primary key, NOTHING, or USING INDEX whose index is gone or invalid. Insert-only publications need no identity and are not reported. Scope is schema, so it runs under --profile=schema and pgbot lint on an empty CI database — the migration-PR path where this is worth catching. The integration fixture proves the UPDATE fails before asserting the finding, then that a primary key clears both. New replica_identity section in --json; SchemaVersion 1.5.0 (additive) — #38 took 1.4.0 first, so this is the next minor. --- CHANGELOG.md | 13 +- README.md | 10 +- docs/findings/README.md | 1 + docs/findings/replica_identity_missing.md | 134 ++ internal/collect/collector.go | 2 + internal/collect/replident.go | 47 + .../collect/replident_integration_test.go | 103 + internal/collect/sql/replident.sql | 32 + internal/findings/catalog.go | 8 + internal/findings/catalog_test.go | 5 + internal/findings/findings.go | 43 +- internal/findings/replident_test.go | 48 + internal/model/context.go | 18 +- internal/model/schema_version.go | 5 +- schema/pgbot-context-1.5.0.json | 1796 +++++++++++++++++ 15 files changed, 2255 insertions(+), 10 deletions(-) create mode 100644 docs/findings/replica_identity_missing.md create mode 100644 internal/collect/replident.go create mode 100644 internal/collect/replident_integration_test.go create mode 100644 internal/collect/sql/replident.sql create mode 100644 internal/findings/replident_test.go create mode 100644 schema/pgbot-context-1.5.0.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 704c10d..90411ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to pgbot are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and the project aims for [Semantic Versioning](https://semver.org/). The `--json` contract is versioned -separately by `model.SchemaVersion` (currently 1.3.0). +separately by `model.SchemaVersion` (currently 1.5.0). ## [Unreleased] @@ -21,6 +21,17 @@ separately by `model.SchemaVersion` (currently 1.3.0). `pgbot ask "why is it slow?"`. ### Added +- **`replica_identity_missing` finding.** A table published for `UPDATE` or + `DELETE` with no usable replica identity — `DEFAULT` and no primary key, + `NOTHING`, or `USING INDEX` whose index is gone — makes Postgres reject the + write itself (`cannot update table … because it does not have a replica + identity and publishes updates`). Reads and INSERTs keep working, so the + failure lands on the first UPDATE after a migration rather than at deploy + time. Read from `pg_publication` and `pg_class`, so it is `schema` scope and + runs under `--profile=schema` / `pgbot lint` on an empty CI database. + Insert-only publications need no identity and are not reported. New + `replica_identity` section in `--json`; `SchemaVersion` → **1.5.0** (additive; + a 1.4.0 consumer parses it unchanged). - **`collation_version_mismatch` finding** (PG15+). The collation library (libc or ICU) that defines text sort order changed version under the data — an OS upgrade, a new base image, a restore onto a different host — so every diff --git a/README.md b/README.md index 39f40eb..e581c3c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Provider notes

-> **Status: beta.** The `--json` contract is versioned (currently `1.3.0`, JSON +> **Status: beta.** The `--json` contract is versioned (currently `1.5.0`, JSON > Schema published in [`schema/`](schema/)) and breaking changes to it are > treated as breaking changes to the tool. The human-readable report is **not** > a stable interface — parse `--json`, not the terminal output. @@ -821,21 +821,21 @@ All from SQL — connections, cache-hit ratio, TPS and rollback ratio, WAL and I rates, checkpoints, locks and blocking chains, replication lag, replication-slot WAL retention and logical-subscription health, top queries (`pg_stat_statements`), table/index sizes, dead tuples and vacuum activity, -unused and missing indexes, non-default settings, and collation version drift -(PG15+). Counters +unused and missing indexes, non-default settings, collation version drift +(PG15+), and published tables with no replica identity. Counters (`pg_stat_database`, `pg_stat_wal`, IO) are **double-sampled** to produce live rates; the rest are point-in-time reads trended against the baseline. ## The `--json` contract `--json` (and `--format=json`) is the interface to build on — a versioned, -PII-free document (`schema_version`, currently `1.3.0`) whose machine-checkable +PII-free document (`schema_version`, currently `1.5.0`) whose machine-checkable JSON Schema is published in [`schema/`](schema/). Every section carries an `exactness` label — `sampled`, `cumulative`, `scraped`, or `unavailable` — so a consumer never mistakes a cumulative total for a live rate. Versioning policy: additive fields bump the minor version and are not breaking — -a `1.2.0` consumer parses `1.3.0` output unchanged; breaking changes to the +a `1.4.0` consumer parses `1.5.0` output unchanged; breaking changes to the contract are treated as breaking changes to the tool. `pgbot advise --json` has its own schema ([`schema/pgbot-advise-1.0.0.json`](schema/pgbot-advise-1.0.0.json)). diff --git a/docs/findings/README.md b/docs/findings/README.md index cbf1e69..172a393 100644 --- a/docs/findings/README.md +++ b/docs/findings/README.md @@ -21,6 +21,7 @@ Lost durability, corruption, wraparound, replication — things that end in an o - **[full_page_writes_off](full_page_writes_off.md)** · Critical — full_page_writes off — a crash can leave torn pages - **[ignore_checksum_failure_on](ignore_checksum_failure_on.md)** · Critical — ignore_checksum_failure is on — corrupt pages are returned, not caught - **[index_invalid](index_invalid.md)** · Critical — a failed CREATE INDEX CONCURRENTLY left an invalid index — critical if it's still maintained on writes, warn if it's failed-build debris +- **[replica_identity_missing](replica_identity_missing.md)** · Critical — a published table has no replica identity, so UPDATE and DELETE on it fail - **[sync_rep_degraded](sync_rep_degraded.md)** · Critical — fewer synchronous standbys connected than the config requires - **[archiving_disabled](archiving_disabled.md)** · Warn — archive_mode is off — no continuous WAL archive for PITR - **[collation_version_mismatch](collation_version_mismatch.md)** · Warn — the collation library changed version under the data — text indexes may be silently out of order diff --git a/docs/findings/replica_identity_missing.md b/docs/findings/replica_identity_missing.md new file mode 100644 index 0000000..30563aa --- /dev/null +++ b/docs/findings/replica_identity_missing.md @@ -0,0 +1,134 @@ +--- +id: replica_identity_missing +severity: critical +critical_when: "" +dimension: risk +object: relation +scope: schema +requires: [a publication replicating UPDATE or DELETE] +thresholds: [] +related: [subscription_worker_down, replication_slot_inactive] +--- + +# replica_identity_missing + +**Severity:** critical · **Dimension:** risk · **Object identity:** `schema.table` (see [configuration](../configuration.md)) · **Requires:** a publication that replicates `UPDATE` or `DELETE` + +## What pgbot observed + +A table belongs to a publication that replicates `UPDATE` or `DELETE`, but its +replica identity cannot identify a row: + +- `DEFAULT` with no primary key — the default resolves to the primary key, and + there isn't one. +- `NOTHING` — set explicitly. +- `USING INDEX` whose nominated index is missing or invalid, which behaves like + `NOTHING`. + +Publications that replicate only `INSERT` need no identity and are not reported. +Everything here comes from `pg_publication` and `pg_class`, so it is valid on a +freshly migrated, never-queried database. + +## Why it matters + +Postgres accepts the table, the publication and the schema, then refuses the +write at runtime: + +``` +ERROR: cannot update table "events" because it does not have a replica identity + and publishes updates +HINT: To enable updating the table, set REPLICA IDENTITY using ALTER TABLE. +``` + +`SELECT` and `INSERT` keep working, so nothing fails at deploy time. The failure +arrives with the first `UPDATE` or `DELETE` after the migration, in whatever code +path happens to run it, and it is a hard error for that statement rather than a +replication lag or a warning. + +## How to verify it yourself + +```sql +SELECT n.nspname AS schema, + c.relname AS "table", + c.relreplident AS identity, + string_agg(DISTINCT p.pubname, ', ') AS publications +FROM pg_publication p +JOIN pg_publication_tables pt ON pt.pubname = p.pubname +JOIN pg_namespace n ON n.nspname = pt.schemaname +JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = pt.tablename +WHERE (p.pubupdate OR p.pubdelete) + AND c.relkind IN ('r', 'p') + AND ( + (c.relreplident = 'd' AND NOT EXISTS ( + SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary AND i.indisvalid)) + OR c.relreplident = 'n' + OR (c.relreplident = 'i' AND NOT EXISTS ( + SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisreplident AND i.indisvalid)) + ) +GROUP BY 1, 2, 3 +ORDER BY 1, 2; +``` + +## How to fix it + +Pick the cheapest identity the table can support. + +1. **A primary key**, if the table can have one. This is the normal answer and + needs no further configuration: + + ```sql + ALTER TABLE public.events ADD PRIMARY KEY (id); + ``` + +2. **An existing unique index** on `NOT NULL` columns, when a primary key is not + an option: + + ```sql + ALTER TABLE public.events REPLICA IDENTITY USING INDEX events_uniq_idx; + ``` + +3. **`FULL`**, when neither fits. Correct, but it writes every column into the WAL + record and makes the subscriber match rows without an index: + + ```sql + ALTER TABLE public.events REPLICA IDENTITY FULL; + ``` + +4. **Remove the table from the publication**, if it was never meant to replicate: + + ```sql + ALTER PUBLICATION app_pub DROP TABLE public.events; + ``` + +## When to ignore it + +When the table is genuinely insert-only and you are certain no `UPDATE` or +`DELETE` will ever run against it. That is a claim about application behaviour +that the catalog cannot confirm, and one stray `UPDATE` turns into an error, so +scope the suppression to the table and give it an expiry: + +```toml +[[ignore]] +finding = "replica_identity_missing" +object = "public.events" +reason = "append-only event log; no UPDATE/DELETE in the writer (OPS-2291)" +expires = "2027-01-01" +``` + +## What pgbot cannot see + +- Whether anything actually issues an `UPDATE` or `DELETE` against the table. The + finding reports that the write *would* fail, not that it has. +- The subscriber side. A subscription may also need its own matching index for + `FULL` identity to perform acceptably. +- Row filters and column lists on the publication, which narrow what replicates + but do not remove the identity requirement. +- A partitioned table published with `publish_via_partition_root` replicates as + the root; pgbot names the relation the catalog lists, which may be a partition. + +## Related + +- [subscription_worker_down](subscription_worker_down.md) — the subscriber-side + symptom when replication stops. +- [replication_slot_inactive](replication_slot_inactive.md) — a stalled logical + slot retains WAL while the publisher cannot make progress. diff --git a/internal/collect/collector.go b/internal/collect/collector.go index 843243f..76a7be7 100644 --- a/internal/collect/collector.go +++ b/internal/collect/collector.go @@ -40,6 +40,7 @@ var schemaCollectors = map[string]bool{ "indexes": true, // index_invalid, redundant_indexes, fk_unindexed "sequences": true, // int4_identity_column "tables": true, // autovacuum_disabled_on_table (reloptions) + "replident": true, // replica_identity_missing (pg_publication + pg_class) } func (o Options) interval() time.Duration { @@ -106,6 +107,7 @@ var registry = []Collector{ checksumsCollector{}, collationCollector{}, standbyCollector{}, + replidentCollector{}, } func nowUTC() time.Time { return time.Now().UTC() } diff --git a/internal/collect/replident.go b/internal/collect/replident.go new file mode 100644 index 0000000..f8b234a --- /dev/null +++ b/internal/collect/replident.go @@ -0,0 +1,47 @@ +package collect + +import ( + "context" + _ "embed" + "time" + + "github.com/pgrundev/pgbot/internal/conn" + "github.com/pgrundev/pgbot/internal/model" +) + +//go:embed sql/replident.sql +var sqlReplident string + +// replident = published tables whose replica identity can't identify a row, so +// UPDATE/DELETE on them errors. Catalog-only, so it works on an empty database. +type replidentCollector struct{} + +type replidentRow struct { + Schema string `db:"schema"` + Table string `db:"table"` + Identity string `db:"identity"` + Publications string `db:"publications"` +} + +func (replidentCollector) Name() string { return "replident" } +func (replidentCollector) Kind() Kind { return KindGauge } +func (replidentCollector) Available(conn.Capabilities) bool { return true } + +func (replidentCollector) Sample(ctx context.Context, t *conn.Target, _ conn.Capabilities) (any, error) { + return queryMany[replidentRow](ctx, t, sqlReplident) +} + +func (replidentCollector) Assemble(c *model.Context, _ conn.Capabilities, s sampled, _ time.Duration, _ Options) { + rows, ok := s.A.([]replidentRow) + if s.Err != nil || !ok { + c.ReplicaIdentity = &model.ReplicaIdentity{Section: unavail(s.Err, "publication catalog unreadable")} + return + } + ri := &model.ReplicaIdentity{Section: model.Section{Exactness: model.ExactnessScraped}} + for _, r := range rows { + ri.Unidentifiable = append(ri.Unidentifiable, model.PublishedTable{ + Schema: r.Schema, Name: r.Table, Identity: r.Identity, Publications: r.Publications, + }) + } + c.ReplicaIdentity = ri +} diff --git a/internal/collect/replident_integration_test.go b/internal/collect/replident_integration_test.go new file mode 100644 index 0000000..2a42a2d --- /dev/null +++ b/internal/collect/replident_integration_test.go @@ -0,0 +1,103 @@ +package collect_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/pgrundev/pgbot/internal/collect" + "github.com/pgrundev/pgbot/internal/conn" + "github.com/pgrundev/pgbot/internal/findings" + "github.com/pgrundev/pgbot/internal/model" +) + +// A publication over a table with no primary key is accepted by Postgres and only +// rejects the first UPDATE. Build exactly that, prove the write really fails, then +// run the real collector as the read-only role and check the finding. Adding a +// primary key must clear it. +func TestIntegration_replicaIdentityMissing(t *testing.T) { + su := os.Getenv("PGBOT_TEST_SUPERUSER_DSN") + if su == "" { + t.Skip("set PGBOT_TEST_SUPERUSER_DSN (a superuser DSN) to run the publication fixture") + } + ro := dsn(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + admin, err := pgx.Connect(ctx, su) + if err != nil { + t.Fatalf("admin connect: %v", err) + } + t.Cleanup(func() { admin.Close(context.Background()) }) + + cleanup := func() { + _, _ = admin.Exec(context.Background(), `DROP PUBLICATION IF EXISTS pgbot_it_pub`) + _, _ = admin.Exec(context.Background(), `DROP TABLE IF EXISTS public.pgbot_it_nopk`) + } + cleanup() + t.Cleanup(cleanup) + if _, err := admin.Exec(ctx, ` + CREATE TABLE public.pgbot_it_nopk (id bigint, note text); + INSERT INTO public.pgbot_it_nopk VALUES (1, 'a'); + CREATE PUBLICATION pgbot_it_pub FOR TABLE public.pgbot_it_nopk`); err != nil { + t.Fatalf("fixture: %v", err) + } + // The breakage this finding predicts, confirmed against the server. + if _, err := admin.Exec(ctx, `UPDATE public.pgbot_it_nopk SET note = 'b'`); err == nil { + t.Fatal("expected the UPDATE to fail without a replica identity") + } + + target, err := conn.Connect(ctx, ro) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer target.Close() + run := func() *model.Context { + c, err := collect.Run(ctx, target, collect.Options{Interval: 200 * time.Millisecond, ASHHz: 0}) + if err != nil { + t.Fatalf("run: %v", err) + } + if c.ReplicaIdentity == nil || c.ReplicaIdentity.Exactness != model.ExactnessScraped { + t.Fatalf("the section must be collected by the read-only role, got %+v", c.ReplicaIdentity) + } + return c + } + + c := run() + var row *model.PublishedTable + for i := range c.ReplicaIdentity.Unidentifiable { + if c.ReplicaIdentity.Unidentifiable[i].Name == "pgbot_it_nopk" { + row = &c.ReplicaIdentity.Unidentifiable[i] + } + } + if row == nil { + t.Fatalf("the published table must be collected, got %+v", c.ReplicaIdentity.Unidentifiable) + } + if row.Identity != "d" || row.Publications != "pgbot_it_pub" { + t.Errorf("collected row must mirror the catalog: %+v", *row) + } + var f *model.Finding + for _, x := range findings.Compute(c) { + if x.ID == "replica_identity_missing" { + f = &x + break + } + } + if f == nil || f.Severity != model.SeverityCritical { + t.Fatalf("expected critical replica_identity_missing, got %+v", f) + } + + if _, err := admin.Exec(ctx, `ALTER TABLE public.pgbot_it_nopk ADD PRIMARY KEY (id)`); err != nil { + t.Fatalf("add pk: %v", err) + } + for _, r := range run().ReplicaIdentity.Unidentifiable { + if r.Name == "pgbot_it_nopk" { + t.Fatalf("a primary key must clear the finding, still reported: %+v", r) + } + } + // And the write the finding predicted now succeeds. + if _, err := admin.Exec(ctx, `UPDATE public.pgbot_it_nopk SET note = 'c'`); err != nil { + t.Errorf("UPDATE should succeed once a replica identity exists: %v", err) + } +} diff --git a/internal/collect/sql/replident.sql b/internal/collect/sql/replident.sql new file mode 100644 index 0000000..4e4c7e0 --- /dev/null +++ b/internal/collect/sql/replident.sql @@ -0,0 +1,32 @@ +-- Published tables whose replica identity cannot identify a row, so an UPDATE or +-- DELETE on them fails outright ("cannot update table … because it does not have +-- a replica identity and publishes updates"). Only publications that replicate +-- UPDATE or DELETE matter; an insert-only publication needs no identity. +-- pg_publication_tables expands FOR ALL TABLES and FOR TABLES IN SCHEMA, so both +-- reach this list. Only the catalog is read — valid on an empty database. +WITH published AS ( + SELECT DISTINCT pt.schemaname, pt.tablename, p.pubname + FROM pg_publication p + JOIN pg_publication_tables pt ON pt.pubname = p.pubname + WHERE p.pubupdate OR p.pubdelete +) +SELECT n.nspname AS schema, + c.relname AS "table", + c.relreplident::text AS identity, + string_agg(DISTINCT pb.pubname, ', ') AS publications +FROM published pb +JOIN pg_namespace n ON n.nspname = pb.schemaname +JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = pb.tablename +WHERE c.relkind IN ('r', 'p') + AND ( + -- 'd' (default) resolves to the primary key; without one there is nothing to use. + (c.relreplident = 'd' AND NOT EXISTS ( + SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary AND i.indisvalid)) + -- 'n' (nothing) was set explicitly. + OR c.relreplident = 'n' + -- 'i' (index) whose nominated index is gone or invalid behaves like nothing. + OR (c.relreplident = 'i' AND NOT EXISTS ( + SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisreplident AND i.indisvalid)) + ) +GROUP BY 1, 2, 3 +ORDER BY 1, 2; diff --git a/internal/findings/catalog.go b/internal/findings/catalog.go index c5c66ed..ee58fc8 100644 --- a/internal/findings/catalog.go +++ b/internal/findings/catalog.go @@ -311,6 +311,13 @@ var catalog = map[string]Meta{ Scope: "infra", Requires: []string{"PG15+"}, }, + "replica_identity_missing": { + Severity: "critical", CriticalWhen: "", + Dimension: "risk", ObjectClass: "relation", + Scope: "schema", + Requires: []string{"a publication replicating UPDATE or DELETE"}, + Related: []string{"subscription_worker_down", "replication_slot_inactive"}, + }, "pgaudit_silent": { Severity: "warn", CriticalWhen: "", Dimension: "risk", ObjectClass: "setting", @@ -559,6 +566,7 @@ var summaries = map[string]string{ "ignore_checksum_failure_on": "ignore_checksum_failure is on — corrupt pages are returned, not caught", "checksums_disabled": "data checksums are off, so this class of corruption is silent", "collation_version_mismatch": "the collation library changed version under the data — text indexes may be silently out of order", + "replica_identity_missing": "a published table has no replica identity, so UPDATE and DELETE on it fail", "pgaudit_silent": "pgaudit is installed but pgaudit.log selects no classes — the audit trail does not exist", "pgaudit_logs_parameters": "pgaudit.log_parameter=on writes bind parameters (passwords, PII) into the server log", "pgaudit_double_logging": "pgaudit and log_statement=all record every statement twice — duplicate log volume", diff --git a/internal/findings/catalog_test.go b/internal/findings/catalog_test.go index 5f49021..c6c3f0a 100644 --- a/internal/findings/catalog_test.go +++ b/internal/findings/catalog_test.go @@ -34,6 +34,11 @@ func TestCatalog_matchesEmitted(t *testing.T) { {Kind: "database", Name: "app", Provider: "libc", Recorded: "2.31", Actual: "2.36"}, }}, }, + "replica_identity_missing": { + ReplicaIdentity: &model.ReplicaIdentity{Unidentifiable: []model.PublishedTable{ + {Schema: "public", Name: "events", Identity: "d", Publications: "app_pub"}, + }}, + }, "pgaudit_silent": { Server: model.ServerInfo{Extensions: []string{"pgaudit"}}, Settings: &model.Settings{Params: map[string]string{"pgaudit.log": "none"}}, diff --git a/internal/findings/findings.go b/internal/findings/findings.go index 535cf0a..5ba477f 100644 --- a/internal/findings/findings.go +++ b/internal/findings/findings.go @@ -126,8 +126,8 @@ var knownIDs = map[string]bool{ "sync_rep_degraded": true, "replica_lag_time": true, "recovery_conflicts": true, "replica_disconnected": true, "checksum_failures": true, "ignore_checksum_failure_on": true, "checksums_disabled": true, - "collation_version_mismatch": true, - "archiving_failing": true, "archiving_stalled": true, "archiving_disabled": true, + "collation_version_mismatch": true, "replica_identity_missing": true, + "archiving_failing": true, "archiving_stalled": true, "archiving_disabled": true, "replication_slot_inactive": true, "subscription_worker_down": true, "query_slowdown": true, "pgss_entries_evicted": true, "work_mem_low": true, "checkpoints_forced": true, "connections_overprovisioned": true, "fsync_off": true, @@ -196,6 +196,7 @@ func ComputeWithTunables(c *model.Context, tun Tunables) []model.Finding { walArchiving(c, add) checksumFindings(c, add) collationVersionMismatch(c, add) + replicaIdentityMissing(c, add) failoverReadiness(c, add, tun) replicationSlotRisk(c, add) subscriptionDown(c, add) @@ -1640,6 +1641,44 @@ func collationVersionMismatch(c *model.Context, add func(model.Finding)) { }) } +// replicaIdentityMissing flags tables published for UPDATE/DELETE with no usable +// replica identity. Postgres accepts the publication and the schema, then rejects +// the write at runtime, so this is a live breakage a migration can introduce. +func replicaIdentityMissing(c *model.Context, add func(model.Finding)) { + if c.ReplicaIdentity == nil || len(c.ReplicaIdentity.Unidentifiable) == 0 { + return + } + why := map[string]string{ + "d": "replica identity default and no primary key", + "n": "replica identity nothing", + "i": "replica identity index, but the nominated index is missing or invalid", + } + var ev, objs []string + for _, t := range c.ReplicaIdentity.Unidentifiable { + rel := t.Schema + "." + t.Name + reason := why[t.Identity] + if reason == "" { + reason = "replica identity " + t.Identity + } + objs = append(objs, rel) + ev = append(ev, fmt.Sprintf("%s — %s (published by %s)", rel, reason, t.Publications)) + } + n := len(objs) + add(model.Finding{ + ID: "replica_identity_missing", Severity: model.SeverityCritical, Objects: objs, + Title: fmt.Sprintf("%d published table(s) reject UPDATE and DELETE — no replica identity", n), + Detail: "A table published for UPDATE or DELETE needs a replica identity so the subscriber can find the row to change. Without one Postgres rejects the write itself: \"cannot update table … because it does not have a replica identity and publishes updates\". Reads and INSERTs keep working, so this usually surfaces as the first UPDATE after a migration, not at deploy time.", + Evidence: ev, + Remediation: "Give each table a primary key, or point the identity at an existing UNIQUE index on NOT NULL columns with ALTER TABLE … REPLICA IDENTITY USING INDEX . Where neither fits, REPLICA IDENTITY FULL works, or drop the table from the publication.", + Caveats: []string{ + "REPLICA IDENTITY FULL puts every column in the WAL record and makes the subscriber match rows without an index — correct, but costly on a large or busy table.", + "A partitioned table published with publish_via_partition_root replicates as the root; pgbot reports the relation the catalog names, which may be a partition.", + }, + Impact: impact(model.DimRisk, 92, fmt.Sprintf("%d published table(s) cannot be updated", n), "relreplident with no usable identity on a table in an UPDATE/DELETE publication"), + Confidence: 1.0, + }) +} + // walArchiving flags the WAL-archiving / PITR failure modes. On a detected // managed provider the backup mechanism is usually outside archive_command, so // every archiving finding is downgraded to info with wording that says pgbot diff --git a/internal/findings/replident_test.go b/internal/findings/replident_test.go new file mode 100644 index 0000000..11ce846 --- /dev/null +++ b/internal/findings/replident_test.go @@ -0,0 +1,48 @@ +package findings + +import ( + "strings" + "testing" + + "github.com/pgrundev/pgbot/internal/model" +) + +func TestReplicaIdentityMissing(t *testing.T) { + ctx := &model.Context{ReplicaIdentity: &model.ReplicaIdentity{Unidentifiable: []model.PublishedTable{ + {Schema: "public", Name: "events", Identity: "d", Publications: "app_pub"}, + {Schema: "public", Name: "audit", Identity: "n", Publications: "app_pub, other_pub"}, + }}} + f := has(Compute(ctx), "replica_identity_missing") + if f == nil || f.Severity != model.SeverityCritical { + t.Fatalf("expected critical replica_identity_missing, got %+v", f) + } + if got := []string{"public.events", "public.audit"}; len(f.Objects) != 2 || f.Objects[0] != got[0] || f.Objects[1] != got[1] { + t.Errorf("objects must be relations, aligned with evidence: %v", f.Objects) + } + if !strings.Contains(f.Evidence[0], "no primary key") || !strings.Contains(f.Evidence[0], "app_pub") { + t.Errorf("evidence must name the reason and the publication: %q", f.Evidence[0]) + } + if !strings.Contains(f.Evidence[1], "nothing") { + t.Errorf("an explicit REPLICA IDENTITY NOTHING must say so: %q", f.Evidence[1]) + } + if len(f.Caveats) == 0 || !strings.Contains(f.Caveats[0], "FULL") { + t.Errorf("must carry the REPLICA IDENTITY FULL cost caveat: %v", f.Caveats) + } + + idx := &model.Context{ReplicaIdentity: &model.ReplicaIdentity{Unidentifiable: []model.PublishedTable{ + {Schema: "app", Name: "t", Identity: "i", Publications: "p"}, + }}} + if f := has(Compute(idx), "replica_identity_missing"); f == nil || !strings.Contains(f.Evidence[0], "invalid") { + t.Errorf("a dangling REPLICA IDENTITY USING INDEX must be reported: %+v", f) + } + + for _, healthy := range []*model.Context{ + {ReplicaIdentity: &model.ReplicaIdentity{}}, + {ReplicaIdentity: &model.ReplicaIdentity{Section: model.Section{Exactness: model.ExactnessUnavailable}}}, + {}, + } { + if has(Compute(healthy), "replica_identity_missing") != nil { + t.Error("no unidentifiable published tables must not fire") + } + } +} diff --git a/internal/model/context.go b/internal/model/context.go index df2e1ce..c9e2b79 100644 --- a/internal/model/context.go +++ b/internal/model/context.go @@ -57,7 +57,9 @@ type Context struct { Checksums *Checksums `json:"checksums,omitempty"` // data-checksum failures cluster-wide (A16) Standby *StandbyStatus `json:"standby,omitempty"` // standby-side recovery conflicts (A17) Collation *Collation `json:"collation,omitempty"` // collation version drift (PG15+) - Deltas *Deltas `json:"deltas,omitempty"` // vs baseline; nil on first run + // ReplicaIdentity is published tables that can't be updated (no usable identity). + ReplicaIdentity *ReplicaIdentity `json:"replica_identity,omitempty"` + Deltas *Deltas `json:"deltas,omitempty"` // vs baseline; nil on first run // Set (with Deltas nil) when a stats reset / restart between runs makes any // comparison fiction — e.g. serverless scale-to-zero. See T2. DeltaSuppressedReason string `json:"delta_suppressed_reason,omitempty"` @@ -480,6 +482,20 @@ type CollationMismatch struct { Actual string `json:"actual_version"` // "" when the library reports none } +// ReplicaIdentity lists tables published for UPDATE/DELETE whose replica identity +// can't identify a row, so those writes error. Empty = healthy. +type ReplicaIdentity struct { + Section + Unidentifiable []PublishedTable `json:"unidentifiable,omitempty"` +} + +type PublishedTable struct { + Schema string `json:"schema"` + Name string `json:"table"` + Identity string `json:"identity"` // relreplident: d | n | i | f + Publications string `json:"publications"` // comma-separated names +} + // Archiver is WAL archiving health from pg_stat_archiver. HasArchiveCommand is // only whether archive_command/library is set — never the value (credentials). type Archiver struct { diff --git a/internal/model/schema_version.go b/internal/model/schema_version.go index f451faa..e1440ea 100644 --- a/internal/model/schema_version.go +++ b/internal/model/schema_version.go @@ -17,4 +17,7 @@ package model // 1.4.0: additive only — ServerInfo gains instance/instance_role, naming the // cluster member a report came from under --all-instances. Both are omitted on a // single-instance run, so a 1.3.0 consumer still parses 1.4.0 output. -const SchemaVersion = "1.4.0" +// +// 1.5.0: additive only — Context gains the `replica_identity` section (published +// tables with no usable replica identity). A 1.4.0 consumer still parses it. +const SchemaVersion = "1.5.0" diff --git a/schema/pgbot-context-1.5.0.json b/schema/pgbot-context-1.5.0.json new file mode 100644 index 0000000..e7e198d --- /dev/null +++ b/schema/pgbot-context-1.5.0.json @@ -0,0 +1,1796 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pgbot.dev/schema/pgbot-context-1.5.0.json", + "$ref": "#/$defs/Context", + "$defs": { + "Activity": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "idle": { + "type": "integer" + }, + "idle_in_transaction": { + "type": "integer" + }, + "waiting": { + "type": "integer" + }, + "by_state": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "wait_events": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "longest_xact_sec": { + "type": "number" + }, + "longest_active_sec": { + "type": "number" + }, + "connections": { + "items": { + "$ref": "#/$defs/ConnGroup" + }, + "type": "array" + }, + "autovacuum_workers": { + "type": "integer" + }, + "autovacuum_max_age_sec": { + "type": "number" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "total", + "active", + "idle", + "idle_in_transaction", + "waiting", + "by_state", + "longest_xact_sec", + "longest_active_sec" + ] + }, + "Archiver": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "archived_count": { + "type": "integer" + }, + "last_archived_wal": { + "type": "string" + }, + "last_archived_time": { + "type": "string", + "format": "date-time" + }, + "failed_count": { + "type": "integer" + }, + "last_failed_wal": { + "type": "string" + }, + "last_failed_time": { + "type": "string", + "format": "date-time" + }, + "stats_reset": { + "type": "string", + "format": "date-time" + }, + "has_archive_command": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "archived_count", + "failed_count", + "has_archive_command" + ] + }, + "BlockingRow": { + "properties": { + "blocked_pid": { + "type": "integer" + }, + "blocking_pids": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "wait_event": { + "type": "string" + }, + "wait_seconds": { + "type": "number" + }, + "blocked_query": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "blocked_pid", + "blocking_pids", + "wait_seconds", + "blocked_query" + ] + }, + "ChecksumFailure": { + "properties": { + "database": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "last_failure": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "database", + "count" + ] + }, + "Checksums": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "failures": { + "items": { + "$ref": "#/$defs/ChecksumFailure" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness" + ] + }, + "Collation": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "mismatches": { + "items": { + "$ref": "#/$defs/CollationMismatch" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness" + ] + }, + "CollationMismatch": { + "properties": { + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "recorded_version": { + "type": "string" + }, + "actual_version": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "kind", + "name", + "provider", + "recorded_version", + "actual_version" + ] + }, + "ConnGroup": { + "properties": { + "app_name": { + "type": "string" + }, + "user": { + "type": "string" + }, + "state": { + "type": "string" + }, + "count": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "app_name", + "user", + "state", + "count" + ] + }, + "Context": { + "properties": { + "schema_version": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "collected_at": { + "type": "string", + "format": "date-time" + }, + "fingerprint": { + "type": "string" + }, + "server": { + "$ref": "#/$defs/ServerInfo" + }, + "window": { + "$ref": "#/$defs/Window" + }, + "health": { + "$ref": "#/$defs/Health" + }, + "activity": { + "$ref": "#/$defs/Activity" + }, + "locks": { + "$ref": "#/$defs/Locks" + }, + "queries": { + "$ref": "#/$defs/Queries" + }, + "tables": { + "$ref": "#/$defs/Tables" + }, + "indexes": { + "$ref": "#/$defs/Indexes" + }, + "wal": { + "$ref": "#/$defs/WAL" + }, + "io": { + "$ref": "#/$defs/IO" + }, + "replication": { + "$ref": "#/$defs/Replication" + }, + "settings": { + "$ref": "#/$defs/Settings" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "horizon": { + "$ref": "#/$defs/VacuumHorizon" + }, + "sequences": { + "$ref": "#/$defs/Sequences" + }, + "progress": { + "$ref": "#/$defs/Progress" + }, + "archiver": { + "$ref": "#/$defs/Archiver" + }, + "checksums": { + "$ref": "#/$defs/Checksums" + }, + "standby": { + "$ref": "#/$defs/StandbyStatus" + }, + "collation": { + "$ref": "#/$defs/Collation" + }, + "replica_identity": { + "$ref": "#/$defs/ReplicaIdentity" + }, + "deltas": { + "$ref": "#/$defs/Deltas" + }, + "delta_suppressed_reason": { + "type": "string" + }, + "events": { + "items": { + "$ref": "#/$defs/Event" + }, + "type": "array" + }, + "wait_profile": { + "$ref": "#/$defs/WaitProfile" + }, + "findings": { + "items": { + "$ref": "#/$defs/Finding" + }, + "type": "array" + }, + "config_warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "collected_at", + "fingerprint", + "server", + "window", + "findings" + ] + }, + "Delta": { + "properties": { + "id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "before": { + "type": "number" + }, + "after": { + "type": "number" + }, + "pct_change": { + "type": "number" + }, + "first_observed": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "subject", + "severity", + "before", + "after" + ] + }, + "Deltas": { + "properties": { + "against": { + "type": "string", + "format": "date-time" + }, + "yesterday_hour": { + "type": "string", + "format": "date-time" + }, + "changes": { + "items": { + "$ref": "#/$defs/Delta" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "against", + "changes" + ] + }, + "Event": { + "properties": { + "kind": { + "type": "string" + }, + "object": { + "type": "string" + }, + "before": { + "type": "string" + }, + "after": { + "type": "string" + }, + "occurred_after": { + "type": "string", + "format": "date-time" + }, + "occurred_before": { + "type": "string", + "format": "date-time" + }, + "confidence": { + "type": "number" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "kind", + "confidence" + ] + }, + "Finding": { + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "title": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "evidence": { + "items": { + "type": "string" + }, + "type": "array" + }, + "objects": { + "items": { + "type": "string" + }, + "type": "array" + }, + "remediation": { + "type": "string" + }, + "impact": { + "$ref": "#/$defs/Impact" + }, + "confidence": { + "type": "number" + }, + "caveats": { + "items": { + "type": "string" + }, + "type": "array" + }, + "related": { + "items": { + "type": "string" + }, + "type": "array" + }, + "safety": { + "$ref": "#/$defs/Safety" + }, + "suppressed": { + "type": "boolean" + }, + "suppression_reason": { + "type": "string" + }, + "suppression_rule": { + "type": "string" + }, + "severity_remapped": { + "type": "string" + }, + "cluster_scoped": { + "type": "boolean" + }, + "preexisting": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "severity", + "title", + "detail", + "impact", + "confidence" + ] + }, + "Health": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "connections": { + "type": "integer" + }, + "tps": { + "type": "number" + }, + "commits_per_sec": { + "type": "number" + }, + "rollbacks_per_sec": { + "type": "number" + }, + "rollback_ratio": { + "type": "number" + }, + "cache_hit_ratio": { + "type": "number" + }, + "cache_blocks_sampled": { + "type": "integer" + }, + "deadlocks_per_min": { + "type": "number" + }, + "temp_bytes_per_sec": { + "type": "number" + }, + "tuples_returned_per_sec": { + "type": "number" + }, + "tuples_written_per_sec": { + "type": "number" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "connections" + ] + }, + "HorizonHolder": { + "properties": { + "source": { + "type": "string" + }, + "holder": { + "type": "string" + }, + "xmin_age": { + "type": "integer" + }, + "age_s": { + "type": "number" + }, + "detail": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "source", + "holder", + "xmin_age" + ] + }, + "IO": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "checkpoints_timed": { + "type": "integer" + }, + "checkpoints_requested": { + "type": "integer" + }, + "buffers_written_per_sec": { + "type": "number" + }, + "backend_fsyncs": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "checkpoints_timed", + "checkpoints_requested", + "backend_fsyncs" + ] + }, + "Impact": { + "properties": { + "score": { + "type": "number" + }, + "dimension": { + "type": "string" + }, + "estimate": { + "type": "string" + }, + "basis": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "score", + "dimension", + "estimate", + "basis" + ] + }, + "IndexStat": { + "properties": { + "schema": { + "type": "string" + }, + "table": { + "type": "string" + }, + "index": { + "type": "string" + }, + "scans": { + "type": "integer" + }, + "bytes": { + "type": "integer" + }, + "definition": { + "type": "string" + }, + "columns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "method": { + "type": "string" + }, + "unique": { + "type": "boolean" + }, + "primary": { + "type": "boolean" + }, + "partial": { + "type": "boolean" + }, + "expression": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "table", + "index", + "scans", + "bytes" + ] + }, + "Indexes": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "scanned": { + "type": "integer" + }, + "unused": { + "items": { + "$ref": "#/$defs/IndexStat" + }, + "type": "array" + }, + "largest": { + "items": { + "$ref": "#/$defs/IndexStat" + }, + "type": "array" + }, + "redundant": { + "items": { + "$ref": "#/$defs/RedundantIndex" + }, + "type": "array" + }, + "unindexed_fks": { + "items": { + "$ref": "#/$defs/UnindexedFK" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "total", + "scanned" + ] + }, + "Limits": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "connections_used": { + "type": "integer" + }, + "connections_max": { + "type": "integer" + }, + "max_xid_age": { + "type": "integer" + }, + "max_mxid_age": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "connections_used", + "connections_max", + "max_xid_age" + ] + }, + "Locks": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "blocked_count": { + "type": "integer" + }, + "chains": { + "items": { + "$ref": "#/$defs/BlockingRow" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "blocked_count" + ] + }, + "NarrowIdentityColumn": { + "properties": { + "schema": { + "type": "string" + }, + "table": { + "type": "string" + }, + "column": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "table", + "column", + "type" + ] + }, + "PartitionRollup": { + "properties": { + "schema": { + "type": "string" + }, + "table": { + "type": "string" + }, + "partitions": { + "type": "integer" + }, + "total_bytes": { + "type": "integer" + }, + "live_tuples": { + "type": "integer" + }, + "seq_scans": { + "type": "integer" + }, + "index_scans": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "table", + "partitions", + "total_bytes", + "live_tuples", + "seq_scans", + "index_scans" + ] + }, + "Progress": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "operations": { + "items": { + "$ref": "#/$defs/ProgressOp" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness" + ] + }, + "ProgressOp": { + "properties": { + "pid": { + "type": "integer" + }, + "operation": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "pct": { + "type": "number" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "pid", + "operation" + ] + }, + "PublishedTable": { + "properties": { + "schema": { + "type": "string" + }, + "table": { + "type": "string" + }, + "identity": { + "type": "string" + }, + "publications": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "table", + "identity", + "publications" + ] + }, + "Queries": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "total_exec_ms": { + "type": "number" + }, + "pgss_dealloc": { + "type": "integer" + }, + "pgss_count": { + "type": "integer" + }, + "pgss_max": { + "type": "integer" + }, + "top": { + "items": { + "$ref": "#/$defs/QueryStat" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "enabled" + ] + }, + "QueryStat": { + "properties": { + "queryid": { + "type": "integer" + }, + "query": { + "type": "string" + }, + "calls": { + "type": "integer" + }, + "total_ms": { + "type": "number" + }, + "mean_ms": { + "type": "number" + }, + "max_ms": { + "type": "number" + }, + "rows": { + "type": "integer" + }, + "cache_hit": { + "type": "number" + }, + "wal_bytes": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "queryid", + "query", + "calls", + "total_ms", + "mean_ms", + "max_ms", + "rows", + "wal_bytes" + ] + }, + "QueryWaits": { + "properties": { + "query_id": { + "type": "integer" + }, + "sample_text": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "share": { + "type": "number" + }, + "lock_share": { + "type": "number" + }, + "io_share": { + "type": "number" + }, + "top_type": { + "type": "string" + }, + "top_event": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "query_id", + "count", + "share", + "lock_share", + "io_share" + ] + }, + "RedundantIndex": { + "properties": { + "schema": { + "type": "string" + }, + "table": { + "type": "string" + }, + "index": { + "type": "string" + }, + "covered_by": { + "type": "string" + }, + "bytes": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "table", + "index", + "covered_by", + "bytes" + ] + }, + "ReplicaIdentity": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "unidentifiable": { + "items": { + "$ref": "#/$defs/PublishedTable" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness" + ] + }, + "ReplicaRow": { + "properties": { + "client_addr": { + "type": "string" + }, + "application_name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "sync_state": { + "type": "string" + }, + "sync_priority": { + "type": "integer" + }, + "replay_lag_sec": { + "type": "number" + }, + "write_lag_bytes": { + "type": "integer" + }, + "flush_lag_bytes": { + "type": "integer" + }, + "replay_lag_bytes": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "client_addr", + "state", + "sync_state", + "write_lag_bytes", + "flush_lag_bytes", + "replay_lag_bytes" + ] + }, + "Replication": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "is_replica": { + "type": "boolean" + }, + "replicas": { + "items": { + "$ref": "#/$defs/ReplicaRow" + }, + "type": "array" + }, + "receiver_lag_sec": { + "type": "number" + }, + "slots": { + "items": { + "$ref": "#/$defs/ReplicationSlot" + }, + "type": "array" + }, + "subscriptions": { + "items": { + "$ref": "#/$defs/Subscription" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "is_replica" + ] + }, + "ReplicationSlot": { + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "database": { + "type": "string" + }, + "retained_bytes": { + "type": "integer" + }, + "wal_status": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "type", + "active", + "retained_bytes" + ] + }, + "Safety": { + "properties": { + "blocking_caveats": { + "items": { + "$ref": "#/$defs/SafetyGuard" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "blocking_caveats" + ] + }, + "SafetyGuard": { + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "action": { + "type": "string" + }, + "text": { + "type": "string" + }, + "verify": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "kind", + "action", + "text", + "verify" + ] + }, + "SequenceUsage": { + "properties": { + "schema": { + "type": "string" + }, + "sequence": { + "type": "string" + }, + "last_value": { + "type": "integer" + }, + "ceiling": { + "type": "integer" + }, + "pct_used": { + "type": "number" + }, + "owned_by": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "sequence", + "last_value", + "ceiling", + "pct_used" + ] + }, + "Sequences": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "items": { + "items": { + "$ref": "#/$defs/SequenceUsage" + }, + "type": "array" + }, + "narrow_identity": { + "items": { + "$ref": "#/$defs/NarrowIdentityColumn" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness" + ] + }, + "ServerInfo": { + "properties": { + "version_num": { + "type": "integer" + }, + "version_text": { + "type": "string" + }, + "database": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "in_recovery": { + "type": "boolean" + }, + "via_pooler": { + "type": "boolean" + }, + "instance": { + "type": "string" + }, + "instance_role": { + "type": "string" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "uptime_seconds": { + "type": "integer" + }, + "extensions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "has_pg_monitor": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "version_num", + "version_text", + "database", + "uptime_seconds", + "extensions", + "capabilities", + "has_pg_monitor" + ] + }, + "Settings": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "overrides": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "params": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "overrides" + ] + }, + "StandbyStatus": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "confl_tablespace": { + "type": "integer" + }, + "confl_lock": { + "type": "integer" + }, + "confl_snapshot": { + "type": "integer" + }, + "confl_bufferpin": { + "type": "integer" + }, + "confl_deadlock": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "confl_tablespace", + "confl_lock", + "confl_snapshot", + "confl_bufferpin", + "confl_deadlock" + ] + }, + "Subscription": { + "properties": { + "name": { + "type": "string" + }, + "worker_running": { + "type": "boolean" + }, + "last_msg_age_sec": { + "type": "number" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "worker_running" + ] + }, + "TableStat": { + "properties": { + "schema": { + "type": "string" + }, + "table": { + "type": "string" + }, + "total_bytes": { + "type": "integer" + }, + "live_tuples": { + "type": "integer" + }, + "dead_tuples": { + "type": "integer" + }, + "dead_ratio": { + "type": "number" + }, + "seq_scans": { + "type": "integer" + }, + "index_scans": { + "type": "integer" + }, + "mods_since_analyze": { + "type": "integer" + }, + "updates": { + "type": "integer" + }, + "hot_updates": { + "type": "integer" + }, + "last_analyze": { + "type": "string", + "format": "date-time" + }, + "last_autoanalyze": { + "type": "string", + "format": "date-time" + }, + "analyze_scale_override": { + "type": "number" + }, + "analyze_threshold_override": { + "type": "number" + }, + "autovacuum_count": { + "type": "integer" + }, + "autovacuum_disabled": { + "type": "boolean" + }, + "vacuum_scale_override": { + "type": "number" + }, + "vacuum_threshold_override": { + "type": "number" + }, + "last_vacuum": { + "type": "string", + "format": "date-time" + }, + "last_autovacuum": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "table", + "total_bytes", + "live_tuples", + "dead_tuples", + "dead_ratio", + "seq_scans", + "index_scans", + "mods_since_analyze" + ] + }, + "Tables": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "db_size_bytes": { + "type": "integer" + }, + "top": { + "items": { + "$ref": "#/$defs/TableStat" + }, + "type": "array" + }, + "partitioned": { + "items": { + "$ref": "#/$defs/PartitionRollup" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "db_size_bytes" + ] + }, + "UnindexedFK": { + "properties": { + "schema": { + "type": "string" + }, + "table": { + "type": "string" + }, + "constraint": { + "type": "string" + }, + "columns": { + "type": "string" + }, + "child_bytes": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema", + "table", + "constraint", + "columns", + "child_bytes" + ] + }, + "VacuumHorizon": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "holders": { + "items": { + "$ref": "#/$defs/HorizonHolder" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness" + ] + }, + "WAL": { + "properties": { + "exactness": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "bytes_per_sec": { + "type": "number" + }, + "records_per_sec": { + "type": "number" + }, + "buffers_full": { + "type": "integer" + }, + "dir_bytes": { + "type": "integer" + }, + "dir_files": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "exactness", + "buffers_full" + ] + }, + "WaitBucket": { + "properties": { + "type": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "share": { + "type": "number" + }, + "events": { + "items": { + "$ref": "#/$defs/WaitEvent" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type", + "count", + "share" + ] + }, + "WaitEvent": { + "properties": { + "event": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "share": { + "type": "number" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "event", + "count", + "share" + ] + }, + "WaitProfile": { + "properties": { + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "samples": { + "type": "integer" + }, + "window_seconds": { + "type": "number" + }, + "buckets": { + "items": { + "$ref": "#/$defs/WaitBucket" + }, + "type": "array" + }, + "by_query": { + "items": { + "$ref": "#/$defs/QueryWaits" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "available", + "samples", + "window_seconds" + ] + }, + "Window": { + "properties": { + "sample_seconds": { + "type": "number" + }, + "stats_reset_at": { + "type": "string", + "format": "date-time" + }, + "postmaster_start_at": { + "type": "string", + "format": "date-time" + }, + "window_age_seconds": { + "type": "integer" + }, + "stats_window_days": { + "type": "number" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "sample_seconds" + ] + } + }, + "description": "pgbot inspect --json — the versioned Context contract for agents and scripts." +}