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."
+}