From 316759d24c589c4d4bad8e52783ac8fb347ec841 Mon Sep 17 00:00:00 2001
From: elkaix
Date: Fri, 11 Sep 2026 23:36:59 -0400
Subject: [PATCH 1/2] feat(findings): partition skew, per-table autovacuum
tuning, pg_stat_io verdict in why (schema 1.4.0)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- partition_skew: the hottest leaf takes ≥ 4× the per-partition average scans
(or holds ≥ 4× the rows). What pgbot can see of the hot-shard problem — a
partition key with a hot value. Rollup SQL now returns the hot/big leaf.
- autovacuum_table_tuning: a ≥ 1M-row write-active table still on the global
scale factor; reports the current trigger and the per-table override
(0.02 / 1000) that would replace it. Surfaced by pgbot tune.
- wait_io_bound and why --duration now bracket the window with pg_stat_io:
ms-per-read says the device served the misses, µs says the page cache did,
and the next step differs (fewer blocks vs cache/IO knobs). WaitStudy gains
io; PartitionRollup gains hot/big partition fields. Contract 1.3.0 → 1.4.0.
---
README.md | 6 +-
docs/findings/README.md | 2 +
docs/findings/autovacuum_table_tuning.md | 111 ++
docs/findings/partition_skew.md | 93 ++
internal/collect/sql/partitions.sql | 43 +-
internal/collect/tables.go | 5 +
internal/collect/waitstudy.go | 21 +
internal/findings/catalog.go | 14 +
internal/findings/findings.go | 142 +-
internal/findings/findings_test.go | 93 ++
internal/model/context.go | 6 +
internal/model/schema_version.go | 4 +-
internal/model/waits.go | 4 +
internal/render/gauges.go | 5 +-
internal/why/live.go | 32 +
internal/why/live_test.go | 37 +
schema/pgbot-context-1.4.0.json | 1790 ++++++++++++++++++++++
17 files changed, 2388 insertions(+), 20 deletions(-)
create mode 100644 docs/findings/autovacuum_table_tuning.md
create mode 100644 docs/findings/partition_skew.md
create mode 100644 schema/pgbot-context-1.4.0.json
diff --git a/README.md b/README.md
index 46367a3..fcb6adf 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@
Provider notes
-> **Status: beta.** The `--json` contract is versioned (currently `1.3.0`, JSON
+> **Status: beta.** The `--json` contract is versioned (currently `1.4.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.
@@ -806,13 +806,13 @@ 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.4.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.3.0` consumer parses `1.4.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 20d844e..253a8fb 100644
--- a/docs/findings/README.md
+++ b/docs/findings/README.md
@@ -52,6 +52,7 @@ Disk wasted by bloat, dead tuples, and indexes that earn nothing.
- **[table_bloat](table_bloat.md)** · Warn — dead tuples make a table far larger on disk than its live rows
- **[unused_indexes](unused_indexes.md)** · Warn — indexes with zero scans — storage and write cost, no reads served
- **[vacuum_horizon_blocked](vacuum_horizon_blocked.md)** · Warn — something pins the xmin horizon so vacuum can't reclaim
+- **[autovacuum_table_tuning](autovacuum_table_tuning.md)** · Info — a large write-active table on the global 20% scale factor — millions of dead rows before autovacuum starts
- **[redundant_indexes](redundant_indexes.md)** · Info — an index whose columns are a leading prefix of another
## Latency
@@ -85,6 +86,7 @@ Whole-database capacity lost to waits, cache misses, and write amplification.
- **[work_mem_low](work_mem_low.md)** · Warn — queries spill sorts/hashes to temp files — work_mem too small
- **[autovacuum_long_running](autovacuum_long_running.md)** · Info — an autovacuum worker has been running over an hour
- **[io_timing_off](io_timing_off.md)** · Info — track_io_timing off, so per-query IO time is unavailable
+- **[partition_skew](partition_skew.md)** · Info — one partition takes most of the scans or rows — the partition key has a hot value
- **[pgaudit_double_logging](pgaudit_double_logging.md)** · Info — pgaudit and log_statement=all record every statement twice — duplicate log volume
## Cost & visibility
diff --git a/docs/findings/autovacuum_table_tuning.md b/docs/findings/autovacuum_table_tuning.md
new file mode 100644
index 0000000..d3202c4
--- /dev/null
+++ b/docs/findings/autovacuum_table_tuning.md
@@ -0,0 +1,111 @@
+---
+id: autovacuum_table_tuning
+severity: info
+critical_when: ""
+dimension: storage
+object: relation
+scope: workload
+requires: []
+thresholds: []
+related: [autovacuum_starved, table_bloat, txid_wraparound]
+---
+
+# autovacuum_table_tuning
+
+**Severity:** info · **Dimension:** storage · **Object identity:** `schema.table` (see [configuration](../configuration.md)) · **Requires:** —
+
+## What pgbot observed
+
+A table with **≥ 1,000,000** live rows (`avTuneMinRows`), write activity (dead
+tuples or updates on record), autovacuum enabled, and **no per-table
+`autovacuum_vacuum_scale_factor`** override, while the global scale factor is
+**≥ 0.1** (`avTuneMinScale`; the default is 0.2). pgbot reports the trigger the
+table currently waits for — `autovacuum_vacuum_threshold + scale × n_live_tup` —
+and the trigger a per-table override of scale 0.02 / threshold 1000
+(`avTuneSuggestedScale`, `avTuneSuggestedThres`) would give. Up to ten tables
+are listed, largest first.
+
+## Why it matters
+
+`autovacuum_vacuum_scale_factor` is a fraction of the table, so the same 20%
+that is fine for a 10k-row lookup table means a 50M-row `orders` table
+accumulates 10M dead rows before autovacuum even starts. Until then those rows
+bloat the heap and every index, slow every scan, and let the table's
+transaction-id age climb. When the vacuum finally runs it is a big one that
+holds a worker for a long time. The documented remedy is a per-table override
+that fires on a small fraction plus a fixed threshold — set on the relation, so
+every other table keeps the default and the global cost budget is not spent on
+tiny ones.
+
+## How to verify it yourself
+
+```sql
+-- Current effective trigger per large table (global settings + reloptions).
+SELECT s.schemaname, s.relname, s.n_live_tup, s.n_dead_tup,
+ coalesce((SELECT option_value::float
+ FROM pg_options_to_table(c.reloptions)
+ WHERE option_name = 'autovacuum_vacuum_scale_factor'),
+ current_setting('autovacuum_vacuum_scale_factor')::float) AS scale,
+ coalesce((SELECT option_value::int
+ FROM pg_options_to_table(c.reloptions)
+ WHERE option_name = 'autovacuum_vacuum_threshold'),
+ current_setting('autovacuum_vacuum_threshold')::int) AS threshold
+FROM pg_stat_user_tables s
+JOIN pg_class c ON c.oid = s.relid
+WHERE s.n_live_tup >= 1000000
+ORDER BY s.n_live_tup DESC;
+```
+
+The trigger is `threshold + scale × n_live_tup`.
+
+## How to fix it
+
+```sql
+ALTER TABLE public.orders SET (
+ autovacuum_vacuum_scale_factor = 0.02,
+ autovacuum_vacuum_threshold = 1000,
+ autovacuum_analyze_scale_factor = 0.01,
+ autovacuum_analyze_threshold = 500
+);
+```
+
+Takes effect at the next autovacuum cycle; no restart, and no rewrite (a brief
+`SHARE UPDATE EXCLUSIVE` lock). Derive the numbers from the table: a queue-like
+table with a few thousand live rows and constant churn wants a threshold-driven
+trigger; a billion-row fact table wants an even smaller scale factor. More
+frequent vacuums on big tables need cost budget — watch
+[autovacuum_saturated](autovacuum_saturated.md) and raise
+`autovacuum_vacuum_cost_limit` if workers fall behind.
+
+Rollback: `ALTER TABLE public.orders RESET (autovacuum_vacuum_scale_factor,
+autovacuum_vacuum_threshold, autovacuum_analyze_scale_factor,
+autovacuum_analyze_threshold);`
+
+## When to ignore it
+
+- Append-only tables with occasional deletes — pgbot already skips tables with no
+ dead tuples and no updates, but a rare bulk delete can trip it.
+- You lowered the global scale factor deliberately (below 0.1 the finding stays quiet).
+
+```toml
+[[ignore]]
+finding = "autovacuum_table_tuning"
+object = "public.audit_log"
+reason = "append-only; monthly partition drop handles retention"
+expires = "2027-01-01"
+```
+
+## What pgbot cannot see
+
+- The write *rate*: it sees dead tuples and cumulative updates, not how fast they
+ arrive, so it cannot say how long the table waits between vacuums.
+- Whether a manual `VACUUM` schedule already covers the table.
+- `autovacuum_vacuum_insert_*` (PG13+) for insert-only tables — a separate trigger
+ this finding does not model.
+
+## Related
+
+- [autovacuum_starved](autovacuum_starved.md) — the trigger was reached and
+ autovacuum still didn't run; this finding is the trigger being too far away.
+- [table_bloat](table_bloat.md) — what accumulates while waiting for the trigger.
+- [txid_wraparound](txid_wraparound.md) — the eventual cost of vacuums that come too late.
diff --git a/docs/findings/partition_skew.md b/docs/findings/partition_skew.md
new file mode 100644
index 0000000..9e3db32
--- /dev/null
+++ b/docs/findings/partition_skew.md
@@ -0,0 +1,93 @@
+---
+id: partition_skew
+severity: info
+critical_when: ""
+dimension: throughput
+object: relation
+scope: workload
+requires: []
+thresholds: []
+related: [partition_seq_scan_heavy, autovacuum_table_tuning]
+---
+
+# partition_skew
+
+**Severity:** info · **Dimension:** throughput · **Object identity:** `schema.table` (the partitioned parent; see [configuration](../configuration.md)) · **Requires:** —
+
+## What pgbot observed
+
+For a partitioned table with at least **4** leaf partitions
+(`partitionSkewMinParts`), the hottest leaf takes **≥ 4×** the per-partition
+average scan count (`partitionSkewFactor`, over at least 1,000 total scans), or
+the largest leaf holds ≥ 4× the average row count (over at least 100,000 rows).
+Scan counts are `seq_scan + idx_scan` from `pg_stat_user_tables`, rolled up by
+climbing `pg_inherits` to the root. The finding is suppressed on a cold stats
+window.
+
+## Why it matters
+
+Partitioning spreads maintenance and scans only as far as the key spreads the
+data. One leaf carrying most of the rows or reads is, for every purpose that
+matters — vacuum duration, index build time, scan cost, lock scope — an
+unpartitioned table with extra planning overhead. It is also the earliest
+visible form of the hot-shard problem: the same key would put the same tenant or
+value on one shard if the table were ever distributed, and no number of routers
+or shards fixes a key that doesn't spread.
+
+## How to verify it yourself
+
+```sql
+-- Per-leaf scans and rows for one partitioned parent, hottest first.
+SELECT c.relname AS partition,
+ s.seq_scan + coalesce(s.idx_scan, 0) AS scans,
+ s.n_live_tup AS rows
+FROM pg_inherits i
+JOIN pg_class c ON c.oid = i.inhrelid
+JOIN pg_stat_user_tables s ON s.relid = c.oid
+WHERE i.inhparent = ''::regclass
+ORDER BY scans DESC;
+```
+
+## How to fix it
+
+- **Time-range key, hottest = newest partition:** expected. Ignore it (below).
+- **List key with one dominant value** (a big tenant, a common status): sub-partition
+ that value (`PARTITION OF … FOR VALUES IN ('big_tenant') PARTITION BY HASH (id)`),
+ or move it to its own table with the same schema.
+- **Hash key with low cardinality:** re-key on something with more distinct
+ values, e.g. `(tenant_id, id)` hashed together, at the next rebuild.
+- **Accept it:** give the hot leaf its own autovacuum settings
+ ([autovacuum_table_tuning](autovacuum_table_tuning.md)) and confirm its indexes
+ are the ones the hot queries need.
+
+Re-partitioning is a table rewrite — plan it as a migration with
+`CREATE … CONCURRENTLY` indexes and a cut-over, not an `ALTER`.
+
+## When to ignore it
+
+Time-based partitioning where the newest partition is hot by design, or an
+archive layout where old partitions are deliberately cold.
+
+```toml
+[[ignore]]
+finding = "partition_skew"
+object = "public.events"
+reason = "monthly range partitions; current month is hot by design"
+expires = "2027-01-01"
+```
+
+## What pgbot cannot see
+
+- Counters are cumulative since the stats reset: a partition attached last week
+ looks cold next to one attached last year.
+- Which *value* is hot — only which leaf. Map the leaf to its bound with
+ `pg_get_expr(relpartbound, oid)`.
+- Query-level routing: whether hot queries prune to one leaf or scan all of them
+ ([partition_seq_scan_heavy](partition_seq_scan_heavy.md) covers the latter).
+
+## Related
+
+- [partition_seq_scan_heavy](partition_seq_scan_heavy.md) — the parent scanned
+ end-to-end; the other way partitioning fails to pay off.
+- [autovacuum_table_tuning](autovacuum_table_tuning.md) — the hot leaf is exactly
+ the relation that needs its own vacuum trigger.
diff --git a/internal/collect/sql/partitions.sql b/internal/collect/sql/partitions.sql
index 5dc6b52..c9a8c33 100644
--- a/internal/collect/sql/partitions.sql
+++ b/internal/collect/sql/partitions.sql
@@ -17,18 +17,41 @@ WITH RECURSIVE climb AS (
),
roots AS (
SELECT leaf, node AS root FROM climb WHERE NOT relispartition
+),
+leaves AS (
+ SELECT r.root, s.relid, s.relname, s.n_live_tup,
+ s.seq_scan + coalesce(s.idx_scan, 0) AS scans,
+ pg_total_relation_size(s.relid) AS bytes
+ FROM roots r
+ JOIN pg_stat_user_tables s ON s.relid = r.leaf
+),
+-- The hottest leaf by scans and the largest leaf by rows: the skew evidence.
+-- Cumulative counters, so a freshly attached partition looks cold (A-skew).
+hot AS (
+ SELECT DISTINCT ON (root) root, relname AS hot_partition, scans AS hot_scans
+ FROM leaves ORDER BY root, scans DESC, relname
+),
+big AS (
+ SELECT DISTINCT ON (root) root, relname AS big_partition, n_live_tup AS big_rows
+ FROM leaves ORDER BY root, n_live_tup DESC, relname
)
-SELECT n.nspname AS schema,
- rc.relname AS "table",
- count(*) AS partitions,
- sum(pg_total_relation_size(s.relid)) AS total_bytes,
- sum(s.n_live_tup) AS live_tuples,
- sum(s.seq_scan) AS seq_scans,
- sum(coalesce(s.idx_scan, 0)) AS index_scans
-FROM roots r
-JOIN pg_stat_user_tables s ON s.relid = r.leaf
-JOIN pg_class rc ON rc.oid = r.root
+SELECT n.nspname AS schema,
+ rc.relname AS "table",
+ count(*) AS partitions,
+ sum(l.bytes) AS total_bytes,
+ sum(l.n_live_tup) AS live_tuples,
+ sum(l.scans) - sum(coalesce(s.idx_scan, 0)) AS seq_scans,
+ sum(coalesce(s.idx_scan, 0)) AS index_scans,
+ max(hot.hot_partition) AS hot_partition,
+ max(hot.hot_scans) AS hot_scans,
+ max(big.big_partition) AS big_partition,
+ max(big.big_rows) AS big_rows
+FROM leaves l
+JOIN pg_stat_user_tables s ON s.relid = l.relid
+JOIN pg_class rc ON rc.oid = l.root
JOIN pg_namespace n ON n.oid = rc.relnamespace
+JOIN hot ON hot.root = l.root
+JOIN big ON big.root = l.root
GROUP BY 1, 2
ORDER BY total_bytes DESC
LIMIT 20;
diff --git a/internal/collect/tables.go b/internal/collect/tables.go
index 6f4262b..9d68ca9 100644
--- a/internal/collect/tables.go
+++ b/internal/collect/tables.go
@@ -62,6 +62,10 @@ type partitionRow struct {
LiveTuples int64 `db:"live_tuples"`
SeqScans int64 `db:"seq_scans"`
IndexScans int64 `db:"index_scans"`
+ HotPart string `db:"hot_partition"`
+ HotScans int64 `db:"hot_scans"`
+ BigPart string `db:"big_partition"`
+ BigRows int64 `db:"big_rows"`
}
type tablesSample struct {
@@ -117,6 +121,7 @@ func (tablesCollector) Assemble(c *model.Context, _ conn.Capabilities, s sampled
tbl.Partitioned = append(tbl.Partitioned, model.PartitionRollup{
Schema: p.Schema, Name: p.Table, Partitions: p.Partitions, TotalBytes: p.TotalBytes,
LiveTuples: p.LiveTuples, SeqScans: p.SeqScans, IndexScans: p.IndexScans,
+ HotPartition: p.HotPart, HotScans: p.HotScans, BigPartition: p.BigPart, BigRows: p.BigRows,
})
}
c.Tables = tbl
diff --git a/internal/collect/waitstudy.go b/internal/collect/waitstudy.go
index 2cd3967..56a2e30 100644
--- a/internal/collect/waitstudy.go
+++ b/internal/collect/waitstudy.go
@@ -295,6 +295,27 @@ type WaitStudyOptions struct {
// budget and a failed snapshot is dropped, never queued behind a lock storm.
// Ctrl+C mid-window reports what was gathered (coverage says how much).
func RunWaitStudy(ctx context.Context, t *conn.Target, caps conn.Capabilities, o WaitStudyOptions) *model.WaitStudy {
+ // Bracket the window with pg_stat_io so the diagnosis can say whether IO
+ // waits were the device (ms per read) or cache misses served by the kernel.
+ var ioA any
+ if caps.HasStatIO() {
+ ioA, _ = iostatsCollector{}.Sample(ctx, t, caps)
+ }
+ ioStart := time.Now()
+ study := runWaitStudy(ctx, t, caps, o)
+ if ioA != nil {
+ if ioB, err := (iostatsCollector{}).Sample(ctx, t, caps); err == nil {
+ var scratch model.Context
+ iostatsCollector{}.Assemble(&scratch, caps, sampled{A: ioA, B: ioB}, time.Since(ioStart), Options{})
+ if scratch.IOStats != nil && scratch.IOStats.Exactness == model.ExactnessSampled {
+ study.IO = scratch.IOStats
+ }
+ }
+ }
+ return study
+}
+
+func runWaitStudy(ctx context.Context, t *conn.Target, caps conn.Capabilities, o WaitStudyOptions) *model.WaitStudy {
var snaps []LockSnapshot
snapFails := 0
done := make(chan struct{})
diff --git a/internal/findings/catalog.go b/internal/findings/catalog.go
index 5bf2ba7..e0f5df7 100644
--- a/internal/findings/catalog.go
+++ b/internal/findings/catalog.go
@@ -423,6 +423,18 @@ var catalog = map[string]Meta{
Scope: "infra",
Related: []string{"work_mem_low", "connections_overprovisioned"},
},
+ "partition_skew": {
+ Severity: "info", CriticalWhen: "",
+ Dimension: "throughput", ObjectClass: "relation",
+ Scope: "workload",
+ Related: []string{"partition_seq_scan_heavy", "autovacuum_table_tuning"},
+ },
+ "autovacuum_table_tuning": {
+ Severity: "info", CriticalWhen: "",
+ Dimension: "storage", ObjectClass: "relation",
+ Scope: "workload",
+ Related: []string{"autovacuum_starved", "table_bloat", "txid_wraparound"},
+ },
"io_read_latency_high": {
Severity: "warn", CriticalWhen: "mean physical read latency ≥ 20 ms",
Dimension: "latency", ObjectClass: "cluster",
@@ -542,6 +554,8 @@ func ObjectClass(object string) string {
// README.md), grouped by dimension. Kept terse — the page has the depth.
var summaries = map[string]string{
"blocking_chains": "one session is blocked waiting on locks held by another",
+ "partition_skew": "one partition takes most of the scans or rows — the partition key has a hot value",
+ "autovacuum_table_tuning": "a large write-active table on the global 20% scale factor — millions of dead rows before autovacuum starts",
"io_read_latency_high": "physical reads take milliseconds each — the working set is on the device, not in memory",
"io_concurrency_low": "effective_io_concurrency ≤ 1 on SSD-backed storage — scans read one block at a time",
"plan_cache_mode_forced": "plan_cache_mode pinned cluster-wide — prepared statements can't switch generic/custom plans",
diff --git a/internal/findings/findings.go b/internal/findings/findings.go
index a0e5f14..5565e8f 100644
--- a/internal/findings/findings.go
+++ b/internal/findings/findings.go
@@ -101,6 +101,21 @@ const (
// poolSizing: the cumulative window must be at least this old before the
// average-concurrency estimate is trustworthy.
poolSizingMinWindowS = 3600
+
+ // partition_skew: the hottest leaf takes ≥ this multiple of the per-partition
+ // average scan count (or holds that multiple of the average row count).
+ partitionSkewFactor = 4.0
+ partitionSkewMinParts = 4
+ partitionSkewMinScans = 1000
+ partitionSkewMinRows = 100_000
+
+ // autovacuum_table_tuning: a table this large on the global 20% scale factor
+ // waits for this many dead rows before autovacuum starts — the per-table
+ // override the docs recommend for large, write-active relations.
+ avTuneMinRows = 1_000_000
+ avTuneMinScale = 0.1 // only flag when the effective scale factor is still coarse
+ avTuneSuggestedScale = 0.02 // the suggested per-table autovacuum_vacuum_scale_factor
+ avTuneSuggestedThres = 1000 // and threshold
)
// TuningIDs identifies the config-recommendation findings, surfaced together by
@@ -119,6 +134,7 @@ var TuningIDs = map[string]bool{
"io_concurrency_low": true,
"plan_cache_mode_forced": true,
"slot_wal_keep_unbounded": true,
+ "autovacuum_table_tuning": true,
}
// knownIDs is every finding ID Compute can emit. It is the whitelist the config
@@ -150,8 +166,8 @@ var knownIDs = map[string]bool{
"high_rollback_ratio": true, "pg_stat_statements_missing": true,
"stale_stats_window": true,
"io_read_latency_high": true, "io_concurrency_low": true, "plan_cache_mode_forced": true,
- "slot_wal_keep_unbounded": true,
- "pgaudit_silent": true, "pgaudit_logs_parameters": true, "pgaudit_double_logging": true,
+ "slot_wal_keep_unbounded": true, "partition_skew": true, "autovacuum_table_tuning": true,
+ "pgaudit_silent": true, "pgaudit_logs_parameters": true, "pgaudit_double_logging": true,
// B2 meta-findings (the suppression system reporting on itself).
"suppression_expired": true, "suppression_unused": true,
}
@@ -197,6 +213,8 @@ func ComputeWithTunables(c *model.Context, tun Tunables) []model.Finding {
unindexedForeignKeys(c, add)
seqScanHeavy(c, add)
partitionSeqScanHeavy(c, add)
+ partitionSkew(c, add)
+ autovacuumTableTuning(c, add)
bloatedTables(c, add, tun)
staleStatistics(c, add)
autovacuumHealth(c, add)
@@ -726,6 +744,111 @@ func partitionSeqScanHeavy(c *model.Context, add func(model.Finding)) {
})
}
+// partitionSkew — one leaf of a partitioned table takes far more scans (or holds
+// far more rows) than the per-partition average. This is what pgbot can see of
+// the shard/skew problem: a hash partitioning key with a hot value, or a list
+// key where one tenant dwarfs the rest. Time-range partitioning skews toward the
+// newest leaf by design — the docs page says when that is fine.
+func partitionSkew(c *model.Context, add func(model.Finding)) {
+ if c.Tables == nil || c.Window.ColdWindow() {
+ return
+ }
+ var ev, objs []string
+ worst := 0.0
+ for _, p := range c.Tables.Partitioned {
+ if p.Partitions < partitionSkewMinParts {
+ continue
+ }
+ total := p.SeqScans + p.IndexScans
+ avgScans := float64(total) / float64(p.Partitions)
+ avgRows := float64(p.LiveTuples) / float64(p.Partitions)
+ var parts []string
+ if total >= partitionSkewMinScans && avgScans > 0 && float64(p.HotScans) >= partitionSkewFactor*avgScans {
+ f := float64(p.HotScans) / avgScans
+ worst = math.Max(worst, f)
+ parts = append(parts, fmt.Sprintf("%s takes %.0f× the average scans (%s of %s)", p.HotPartition, f, human(p.HotScans), human(total)))
+ }
+ if p.LiveTuples >= partitionSkewMinRows && avgRows > 0 && float64(p.BigRows) >= partitionSkewFactor*avgRows {
+ f := float64(p.BigRows) / avgRows
+ worst = math.Max(worst, f)
+ parts = append(parts, fmt.Sprintf("%s holds %.0f× the average rows (%s of %s)", p.BigPartition, f, human(p.BigRows), human(p.LiveTuples)))
+ }
+ if len(parts) == 0 {
+ continue
+ }
+ ev = append(ev, fmt.Sprintf("%s.%s (%d partitions): %s", p.Schema, p.Name, p.Partitions, strings.Join(parts, "; ")))
+ objs = append(objs, p.Schema+"."+p.Name)
+ }
+ if len(ev) == 0 {
+ return
+ }
+ add(model.Finding{
+ ID: "partition_skew", Severity: model.SeverityInfo,
+ Title: fmt.Sprintf("%d partitioned table(s) with a hot partition (%.0f× the average)", len(ev), worst),
+ Detail: "Partitioning only spreads work when the key spreads it. One leaf taking most of the scans or rows means the partition key has a hot value — a tenant that dwarfs the rest, a status column with one common state, or a hash key with low cardinality. Every operation on that leaf (vacuum, index build, scan) is as slow as an unpartitioned table, and the same key would produce one hot shard if the table were ever distributed.",
+ Evidence: ev,
+ Objects: objs,
+ Remediation: "For a time-range key this is the newest partition and expected. Otherwise re-examine the key: sub-partition the hot value, choose a higher-cardinality key (tenant_id + a hash), or accept it and give the hot leaf its own autovacuum settings and indexes.",
+ Caveats: []string{"scan counts are cumulative since the stats reset — a recently attached partition looks cold, a detached-and-reattached one looks new"},
+ Impact: impact(model.DimThroughput, math.Min(50, 10+worst*3), fmt.Sprintf("%.0f× hot partition", worst), "hottest leaf vs per-partition average in pg_stat_user_tables"),
+ Confidence: 0.55,
+ })
+}
+
+// autovacuumTableTuning — a large, write-active table still on the global
+// autovacuum_vacuum_scale_factor (default 20%). On a 50M-row table that means
+// 10M dead rows before autovacuum starts; the documented fix is a per-table
+// override, derived from the table's size rather than lowering every table's
+// trigger globally.
+func autovacuumTableTuning(c *model.Context, add func(model.Finding)) {
+ if c.Tables == nil {
+ return
+ }
+ gThresh := settingFloat(c, "autovacuum_vacuum_threshold", 50)
+ gScale := settingFloat(c, "autovacuum_vacuum_scale_factor", 0.2)
+ var ev, objs []string
+ var worstTrigger int64
+ for _, t := range c.Tables.Top {
+ if t.LiveTuples < avTuneMinRows || t.AutovacuumDisabled || t.VacuumScaleOverride != nil {
+ continue
+ }
+ if t.DeadTuples == 0 && t.Updates == 0 {
+ continue // append-only or idle: the vacuum trigger is not the constraint
+ }
+ if gScale < avTuneMinScale {
+ continue
+ }
+ th := gThresh
+ if t.VacuumThresholdOverride != nil {
+ th = *t.VacuumThresholdOverride
+ }
+ trigger := int64(th + gScale*float64(t.LiveTuples))
+ suggested := int64(avTuneSuggestedThres + avTuneSuggestedScale*float64(t.LiveTuples))
+ if trigger > worstTrigger {
+ worstTrigger = trigger
+ }
+ ev = append(ev, fmt.Sprintf("%s.%s: %s rows, %s dead now; autovacuum waits for %s dead rows (scale %g) → suggested %s (scale %g, threshold %d)",
+ t.Schema, t.Name, human(t.LiveTuples), human(t.DeadTuples), human(trigger), gScale, human(suggested), avTuneSuggestedScale, avTuneSuggestedThres))
+ objs = append(objs, t.Schema+"."+t.Name)
+ if len(ev) == 10 {
+ break
+ }
+ }
+ if len(ev) == 0 {
+ return
+ }
+ add(model.Finding{
+ ID: "autovacuum_table_tuning", Severity: model.SeverityInfo,
+ Title: fmt.Sprintf("%d large table(s) on the global autovacuum scale factor — up to %s dead rows before a vacuum", len(ev), human(worstTrigger)),
+ Detail: "autovacuum_vacuum_scale_factor is a fraction of the table: the default 20% is fine for a 10k-row table and lets a 50M-row table accumulate 10M dead rows (and the bloat, index growth and xid age that come with them) before autovacuum even starts. The right trigger for a large, write-active table is a small fraction plus a fixed threshold, set on the table so every other relation keeps the default.",
+ Evidence: ev,
+ Objects: objs,
+ Remediation: fmt.Sprintf("Per table, not globally: ALTER TABLE SET (autovacuum_vacuum_scale_factor = %g, autovacuum_vacuum_threshold = %d, autovacuum_analyze_scale_factor = 0.01, autovacuum_analyze_threshold = 500); takes effect at the next autovacuum cycle, no restart. Watch autovacuum worker saturation afterward — more frequent vacuums on big tables need cost budget (autovacuum_vacuum_cost_limit).", avTuneSuggestedScale, avTuneSuggestedThres),
+ Impact: impact(model.DimStorage, math.Min(45, 15+math.Log10(float64(worstTrigger))*3), human(worstTrigger)+" dead rows before vacuum", "autovacuum_vacuum_threshold + scale × n_live_tup on the global settings"),
+ Confidence: 0.7,
+ })
+}
+
func seqScanHeavy(c *model.Context, add func(model.Finding)) {
if c.Tables == nil || c.Window.ColdWindow() { // scan counts are cold-window-sensitive
return
@@ -1125,12 +1248,23 @@ func waitFindings(c *model.Context, add func(model.Finding)) {
// IO-bound: the whole window dominated by storage reads/writes.
if io := share("IO"); io > waitIOBoundShare {
+ ev := []string{ioEvidence(w)}
+ rem := "Add RAM/shared_buffers or better indexes; check for large scans returning few rows."
+ if st := c.IOStats; st != nil && st.Exactness == model.ExactnessSampled && st.TrackIOTiming && st.ReadLatencyMS != nil && st.ReadsInWindow >= ioReadLatencyMinOps {
+ if *st.ReadLatencyMS >= 1 {
+ ev = append(ev, fmt.Sprintf("pg_stat_io: %d physical reads at %.2f ms each — the device served them, not the page cache", st.ReadsInWindow, *st.ReadLatencyMS))
+ rem = "Reads wait on the device: cut blocks read first (top queries by shared_blks_read, indexes, bloat), then fit the working set in shared_buffers, then effective_io_concurrency / io_method / volume class — re-measure read latency after each."
+ } else {
+ ev = append(ev, fmt.Sprintf("pg_stat_io: %d physical reads at %.2f ms each — served from the kernel page cache; the cost is volume, not device latency", st.ReadsInWindow, *st.ReadLatencyMS))
+ rem = "Reads are cheap but many: find the query reading the most blocks (pgbot queries) and give it an index (pgbot advise) — more cache or faster storage would not change this."
+ }
+ }
add(model.Finding{
ID: "wait_io_bound", Severity: model.SeverityWarn,
Title: fmt.Sprintf("%.0f%% of active time was spent waiting on IO", io*100),
Detail: "Most active samples were waiting on the storage layer, not on CPU or locks. The working set may not fit in cache, or a few queries are scanning far more than they return.",
- Evidence: []string{ioEvidence(w)},
- Remediation: "Add RAM/shared_buffers or better indexes; check for large scans returning few rows.",
+ Evidence: ev,
+ Remediation: rem,
Impact: impact(model.DimThroughput, math.Min(90, io*100),
fmt.Sprintf("%.0f%% of active time on IO", io*100),
"ASH: share of samples with wait_event_type = IO"),
diff --git a/internal/findings/findings_test.go b/internal/findings/findings_test.go
index 8ffc046..2b185d3 100644
--- a/internal/findings/findings_test.go
+++ b/internal/findings/findings_test.go
@@ -599,3 +599,96 @@ func TestPoolSizing(t *testing.T) {
t.Error("no pgss must not estimate")
}
}
+
+func TestPartitionSkew(t *testing.T) {
+ warm := int64(86_400)
+ mk := func(p model.PartitionRollup) *model.Context {
+ return &model.Context{Window: model.Window{WindowAgeSeconds: &warm}, Tables: &model.Tables{Partitioned: []model.PartitionRollup{p}}}
+ }
+ // 8 partitions, 8000 scans → avg 1000; hot leaf 6000 = 6×.
+ hot := model.PartitionRollup{Schema: "public", Name: "events", Partitions: 8, SeqScans: 4000, IndexScans: 4000, LiveTuples: 80_000, HotPartition: "events_p3", HotScans: 6000, BigPartition: "events_p1", BigRows: 12_000}
+ f := has(Compute(mk(hot)), "partition_skew")
+ if f == nil || !contains(strings.Join(f.Evidence, "\n"), "events_p3 takes 6×") {
+ t.Fatalf("6× hot partition should fire with the leaf named, got %+v", f)
+ }
+ if f.Objects[0] != "public.events" {
+ t.Errorf("object should be the parent, got %v", f.Objects)
+ }
+ // Rows-only skew: 8 partitions, 800k rows → avg 100k; big leaf 500k = 5×.
+ rows := model.PartitionRollup{Schema: "public", Name: "tenants", Partitions: 8, SeqScans: 10, IndexScans: 10, LiveTuples: 800_000, HotPartition: "t_p0", HotScans: 5, BigPartition: "t_p7", BigRows: 500_000}
+ if f := has(Compute(mk(rows)), "partition_skew"); f == nil || !contains(f.Evidence[0], "t_p7 holds 5×") {
+ t.Errorf("row skew should fire, got %+v", f)
+ }
+ // Even spread → silent; too few partitions → silent; cold window → silent.
+ even := hot
+ even.HotScans, even.BigRows = 1500, 15_000
+ if has(Compute(mk(even)), "partition_skew") != nil {
+ t.Error("1.5× must not fire")
+ }
+ few := hot
+ few.Partitions = 2
+ if has(Compute(mk(few)), "partition_skew") != nil {
+ t.Error("2 partitions must not fire")
+ }
+ cold := int64(60)
+ c := mk(hot)
+ c.Window.WindowAgeSeconds = &cold
+ if has(Compute(c), "partition_skew") != nil {
+ t.Error("cold window must not fire")
+ }
+}
+
+func TestAutovacuumTableTuning(t *testing.T) {
+ big := model.TableStat{Schema: "public", Name: "orders", LiveTuples: 50_000_000, DeadTuples: 2_000_000, Updates: 1}
+ c := &model.Context{Tables: &model.Tables{Top: []model.TableStat{big}}}
+ f := has(Compute(c), "autovacuum_table_tuning")
+ if f == nil || f.Severity != model.SeverityInfo {
+ t.Fatalf("50M-row table on the default scale factor should fire info, got %+v", f)
+ }
+ // Default 50 + 0.2 × 50M = 10,000,050 → "10.0M"; suggested 1000 + 0.02 × 50M = 1,001,000.
+ if !contains(f.Evidence[0], "waits for 10.0M dead rows") || !contains(f.Evidence[0], "suggested 1.0M") {
+ t.Errorf("evidence should show current and suggested trigger, got %q", f.Evidence[0])
+ }
+ if !contains(f.Remediation, "autovacuum_vacuum_scale_factor = 0.02") {
+ t.Errorf("remediation should carry the ALTER TABLE, got %q", f.Remediation)
+ }
+ if !TuningIDs["autovacuum_table_tuning"] {
+ t.Error("must be surfaced by pgbot tune")
+ }
+ cases := map[string]model.TableStat{
+ "already overridden": func() model.TableStat { x := big; s := 0.02; x.VacuumScaleOverride = &s; return x }(),
+ "small table": func() model.TableStat { x := big; x.LiveTuples = 500_000; return x }(),
+ "append-only": func() model.TableStat { x := big; x.DeadTuples, x.Updates = 0, 0; return x }(),
+ "autovacuum off": func() model.TableStat { x := big; x.AutovacuumDisabled = true; return x }(),
+ }
+ for name, tbl := range cases {
+ if has(Compute(&model.Context{Tables: &model.Tables{Top: []model.TableStat{tbl}}}), "autovacuum_table_tuning") != nil {
+ t.Errorf("%s must not fire", name)
+ }
+ }
+ // Global scale already lowered → silent.
+ low := &model.Context{Tables: c.Tables, Settings: &model.Settings{Params: map[string]string{"autovacuum_vacuum_scale_factor": "0.05"}}}
+ if has(Compute(low), "autovacuum_table_tuning") != nil {
+ t.Error("global scale 0.05 must not fire")
+ }
+}
+
+func TestWaitIOBound_ioStatsVerdict(t *testing.T) {
+ prof := &model.WaitProfile{Available: true, Samples: 200, Buckets: []model.WaitBucket{{Type: "IO", Share: 0.7, Events: []model.WaitEvent{{Event: "DataFileRead", Share: 0.6}}}}}
+ mk := func(lat float64) *model.Context {
+ return &model.Context{WaitProfile: prof, IOStats: &model.IOStats{Section: model.Section{Exactness: model.ExactnessSampled}, TrackIOTiming: true, ReadLatencyMS: ptr(lat), ReadsInWindow: 5000}}
+ }
+ slow := has(Compute(mk(4.2)), "wait_io_bound")
+ if slow == nil || !contains(strings.Join(slow.Evidence, "\n"), "the device served them") || !contains(slow.Remediation, "cut blocks read first") {
+ t.Fatalf("device-latency verdict expected, got %+v", slow)
+ }
+ fast := has(Compute(mk(0.05)), "wait_io_bound")
+ if fast == nil || !contains(strings.Join(fast.Evidence, "\n"), "kernel page cache") || !contains(fast.Remediation, "more cache or faster storage would not change this") {
+ t.Fatalf("page-cache verdict expected, got %+v", fast)
+ }
+ // Without io_stats the original evidence and remediation stand.
+ plain := has(Compute(&model.Context{WaitProfile: prof}), "wait_io_bound")
+ if plain == nil || len(plain.Evidence) != 1 {
+ t.Fatalf("no io_stats → single evidence line, got %+v", plain)
+ }
+}
diff --git a/internal/model/context.go b/internal/model/context.go
index 0d3e839..19a908b 100644
--- a/internal/model/context.go
+++ b/internal/model/context.go
@@ -330,6 +330,12 @@ type PartitionRollup struct {
LiveTuples int64 `json:"live_tuples"`
SeqScans int64 `json:"seq_scans"`
IndexScans int64 `json:"index_scans"`
+ // Skew evidence (1.4.0): the leaf taking the most scans and the leaf holding
+ // the most rows. Cumulative counters — a newly attached partition looks cold.
+ HotPartition string `json:"hot_partition,omitempty"`
+ HotScans int64 `json:"hot_scans,omitempty"`
+ BigPartition string `json:"big_partition,omitempty"`
+ BigRows int64 `json:"big_rows,omitempty"`
}
type TableStat struct {
diff --git a/internal/model/schema_version.go b/internal/model/schema_version.go
index b361b2f..07258eb 100644
--- a/internal/model/schema_version.go
+++ b/internal/model/schema_version.go
@@ -12,4 +12,6 @@ package model
// index/code correlation). A 1.1.0 consumer still parses 1.2.0 output.
// 1.3.0: additive only — Context gains io_stats (pg_stat_io rates and
// latencies, PG16+). A 1.2.0 consumer still parses 1.3.0 output.
-const SchemaVersion = "1.3.0"
+// 1.4.0: additive only — PartitionRollup gains hot/big partition skew fields;
+// WaitStudy gains io (pg_stat_io over the sampling window).
+const SchemaVersion = "1.4.0"
diff --git a/internal/model/waits.go b/internal/model/waits.go
index c549540..00a97ee 100644
--- a/internal/model/waits.go
+++ b/internal/model/waits.go
@@ -29,6 +29,10 @@ type WaitStudy struct {
Sessions []SessionWaits `json:"sessions,omitempty"` // per-PID rollup
Blockers []Blocker `json:"blockers,omitempty"` // sustained evidence only
Transient []Blocker `json:"transient_lock_waits,omitempty"` // seen, but not evidence of a root cause
+ // IO is pg_stat_io over the same window (PG16+): the physical-read latency
+ // that says whether an IO wait is the device or just cache misses served by
+ // the kernel. Nil below PG16 or when the read failed.
+ IO *IOStats `json:"io,omitempty"`
}
// SessionWaits is one backend's share of the sampled window.
diff --git a/internal/render/gauges.go b/internal/render/gauges.go
index cf38fc4..b0e7af6 100644
--- a/internal/render/gauges.go
+++ b/internal/render/gauges.go
@@ -215,12 +215,13 @@ func buildChecked(c *model.Context) []string {
deadlocksClean := c.Health != nil && c.Health.DeadlocksPerMin != nil && *c.Health.DeadlocksPerMin == 0
subsystems := []checkedSubsystem{
{"queries", c.Queries != nil && c.Queries.Enabled,
- []string{"query_slowdown", "seq_scan_heavy", "partition_seq_scan_heavy", "pgss_entries_evicted", "pg_stat_statements_missing"}},
+ []string{"query_slowdown", "seq_scan_heavy", "partition_seq_scan_heavy", "partition_skew", "pgss_entries_evicted", "pg_stat_statements_missing"}},
{"indexes", c.Indexes != nil,
[]string{"unused_indexes", "index_invalid", "redundant_indexes", "fk_unindexed"}},
{"vacuum", c.Tables != nil,
[]string{"table_bloat", "autovacuum_disabled_on_table", "table_never_vacuumed", "autovacuum_starved", "autovacuum_saturated",
- "autovacuum_long_running", "stale_statistics", "never_analyzed", "low_hot_update_ratio", "vacuum_horizon_blocked", "autovacuum_off"}},
+ "autovacuum_long_running", "stale_statistics", "never_analyzed", "low_hot_update_ratio", "vacuum_horizon_blocked", "autovacuum_off",
+ "autovacuum_table_tuning"}},
{"replication", c.Replication != nil,
[]string{"sync_rep_degraded", "replica_lag_time", "recovery_conflicts", "replica_disconnected", "replication_slot_inactive", "subscription_worker_down"}},
{"checkpoints", c.WAL != nil, []string{"checkpoints_forced"}},
diff --git a/internal/why/live.go b/internal/why/live.go
index 5bd4517..6e0b86b 100644
--- a/internal/why/live.go
+++ b/internal/why/live.go
@@ -22,6 +22,26 @@ type LiveReport struct {
Study *model.WaitStudy `json:"study,omitempty"`
}
+// ioVerdict turns the window's pg_stat_io reading into one evidence line and a
+// storage-bound flag. Needs track_io_timing and enough reads for the mean to
+// hold (ioVerdictMinReads); otherwise it says what is missing.
+func ioVerdict(io *model.IOStats) (line string, storage bool) {
+ if io == nil || io.Exactness != model.ExactnessSampled {
+ return "", false
+ }
+ if !io.TrackIOTiming {
+ return "pg_stat_io: track_io_timing is off, so per-read latency is unknown — turn it on (a reload) to tell device waits from cache misses.", false
+ }
+ if io.ReadLatencyMS == nil || io.ReadsInWindow < ioVerdictMinReads {
+ return fmt.Sprintf("pg_stat_io: only %d physical reads in the window — too few to judge storage latency.", io.ReadsInWindow), false
+ }
+ lat := *io.ReadLatencyMS
+ if lat >= ioVerdictStorageMS {
+ return fmt.Sprintf("pg_stat_io: %d physical reads averaged %.2f ms each — the device (or volume latency floor), not the page cache, served them.", io.ReadsInWindow, lat), true
+ }
+ return fmt.Sprintf("pg_stat_io: %d physical reads averaged %.2f ms each — served from the kernel page cache; the cost is block volume, not device latency.", io.ReadsInWindow, lat), false
+}
+
// HistShares is a baseline wait-class distribution from the local store's
// rollups — a separate window, compared by ratio, never blended into the live
// percentages.
@@ -37,6 +57,8 @@ const (
liveAASFloor = 0.5
liveLockShareBar = 0.40
liveIOShareBar = 0.40
+ ioVerdictMinReads = 500 // reads in the window before a mean latency means anything
+ ioVerdictStorageMS = 1.0 // ≥ 1 ms per read is a device, not the page cache
liveClientShareBar = 0.50
liveCPUShareBar = 0.60
histMinSamples = 100
@@ -114,6 +136,16 @@ func ClassifyLive(s *model.WaitStudy, hist *HistShares) *LiveReport {
r.Headline = "storage/WAL wait"
r.Evidence = append(r.Evidence,
fmt.Sprintf("%.0f%% of sampled time reading or writing data — IO wait alone does not identify a cause like an absent index.", share["IO"]*100))
+ if line, storage := ioVerdict(s.IO); line != "" {
+ r.Evidence = append(r.Evidence, line)
+ if storage {
+ r.Confidence = 0.7
+ r.Headline = "storage latency — physical reads wait on the device"
+ r.NextCheck = "reads are slow per block, not just many: cut blocks read first (top queries by shared_blks_read, indexes, bloat), then working set vs shared_buffers, then effective_io_concurrency / io_method / volume class"
+ } else {
+ r.NextCheck = "reads were served in microseconds (kernel page cache), so the IO time is volume, not device latency: find the query reading the most blocks — `pgbot queries`, then `pgbot advise`"
+ }
+ }
for _, q := range s.Profile.ByQuery {
if q.Share >= ioQueryShareBar && q.IOShare >= ioQueryIOBar {
r.NextCheck = "one query dominates the IO samples — `pgbot advise` can check indexes with planner validation"
diff --git a/internal/why/live_test.go b/internal/why/live_test.go
index 1228427..3ce8622 100644
--- a/internal/why/live_test.go
+++ b/internal/why/live_test.go
@@ -144,3 +144,40 @@ func TestClassifyLiveSource(t *testing.T) {
t.Errorf("source label missing: %+v", r)
}
}
+
+func TestIOVerdict(t *testing.T) {
+ sampled := model.Section{Exactness: model.ExactnessSampled}
+ f := func(v float64) *float64 { return &v }
+ cases := []struct {
+ name string
+ io *model.IOStats
+ want string
+ storage bool
+ }{
+ {"nil", nil, "", false},
+ {"timing off", &model.IOStats{Section: sampled, TrackIOTiming: false}, "track_io_timing is off", false},
+ {"too few reads", &model.IOStats{Section: sampled, TrackIOTiming: true, ReadLatencyMS: f(9), ReadsInWindow: 12}, "too few", false},
+ {"device", &model.IOStats{Section: sampled, TrackIOTiming: true, ReadLatencyMS: f(3.5), ReadsInWindow: 2000}, "the device", true},
+ {"page cache", &model.IOStats{Section: sampled, TrackIOTiming: true, ReadLatencyMS: f(0.03), ReadsInWindow: 2000}, "kernel page cache", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ line, storage := ioVerdict(tc.io)
+ if storage != tc.storage || !strings.Contains(line, tc.want) {
+ t.Fatalf("want (%q, %v), got (%q, %v)", tc.want, tc.storage, line, storage)
+ }
+ })
+ }
+}
+
+func TestClassifyLive_storageVerdictRaisesConfidence(t *testing.T) {
+ s := study(3, bucket("IO", 70, 100), bucket("CPU", 30, 100))
+ s.IO = &model.IOStats{Section: model.Section{Exactness: model.ExactnessSampled}, TrackIOTiming: true, ReadLatencyMS: func() *float64 { v := 6.0; return &v }(), ReadsInWindow: 3000}
+ r := ClassifyLive(s, nil)
+ if r.Cause != "storage_wait" || r.Confidence != 0.7 || !strings.Contains(r.Headline, "storage latency") {
+ t.Fatalf("device latency should sharpen the storage verdict, got %+v", r)
+ }
+ if !strings.Contains(r.NextCheck, "cut blocks read first") {
+ t.Errorf("next check should order the fixes, got %q", r.NextCheck)
+ }
+}
diff --git a/schema/pgbot-context-1.4.0.json b/schema/pgbot-context-1.4.0.json
new file mode 100644
index 0000000..a35b59c
--- /dev/null
+++ b/schema/pgbot-context-1.4.0.json
@@ -0,0 +1,1790 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://pgbot.dev/schema/pgbot-context-1.4.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"
+ ]
+ },
+ "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"
+ },
+ "io_stats": {
+ "$ref": "#/$defs/IOStats"
+ },
+ "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"
+ },
+ "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"
+ ]
+ },
+ "IOStatRow": {
+ "properties": {
+ "backend_type": {
+ "type": "string"
+ },
+ "object": {
+ "type": "string"
+ },
+ "context": {
+ "type": "string"
+ },
+ "reads_per_sec": {
+ "type": "number"
+ },
+ "writes_per_sec": {
+ "type": "number"
+ },
+ "fsyncs_per_sec": {
+ "type": "number"
+ },
+ "read_latency_ms": {
+ "type": "number"
+ },
+ "write_latency_ms": {
+ "type": "number"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "backend_type",
+ "object",
+ "context",
+ "reads_per_sec",
+ "writes_per_sec",
+ "fsyncs_per_sec"
+ ]
+ },
+ "IOStats": {
+ "properties": {
+ "exactness": {
+ "type": "string"
+ },
+ "reason": {
+ "type": "string"
+ },
+ "track_io_timing": {
+ "type": "boolean"
+ },
+ "reads_per_sec": {
+ "type": "number"
+ },
+ "writes_per_sec": {
+ "type": "number"
+ },
+ "fsyncs_per_sec": {
+ "type": "number"
+ },
+ "read_latency_ms": {
+ "type": "number"
+ },
+ "write_latency_ms": {
+ "type": "number"
+ },
+ "fsync_latency_ms": {
+ "type": "number"
+ },
+ "reads_in_window": {
+ "type": "integer"
+ },
+ "rows": {
+ "items": {
+ "$ref": "#/$defs/IOStatRow"
+ },
+ "type": "array"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "exactness",
+ "track_io_timing",
+ "reads_in_window"
+ ]
+ },
+ "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"
+ },
+ "hot_partition": {
+ "type": "string"
+ },
+ "hot_scans": {
+ "type": "integer"
+ },
+ "big_partition": {
+ "type": "string"
+ },
+ "big_rows": {
+ "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"
+ ]
+ },
+ "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"
+ ]
+ },
+ "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"
+ },
+ "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."
+}
From 0d73ab287a4be2d4dcab36f1afb1de84586232fe Mon Sep 17 00:00:00 2001
From: elkaix
Date: Fri, 11 Sep 2026 23:37:51 -0400
Subject: [PATCH 2/2] ci(release): make the release workflow fork-safe
Image templates take the ghcr namespace from IMAGE_REPO (set to the releasing
repository), the GitHub release targets the repository the workflow runs in,
and brew-smoke only runs upstream where the tap credential exists. A fork's
tag now produces binaries, SBOMs and an image instead of failing on
pgrundev-owned targets.
---
.github/workflows/release.yml | 6 ++++++
.goreleaser.yaml | 21 ++++++++++-----------
2 files changed, 16 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2879816..20f622e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -41,6 +41,9 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Container image goes to the releasing repo's own ghcr namespace, so a
+ # fork's release doesn't try (and fail) to push to ghcr.io/pgrundev.
+ IMAGE_REPO: ghcr.io/${{ github.repository }}
# Write access to the separate pgrundev/homebrew-tap repo — EITHER the
# private half of a write deploy key on that repo (preferred) OR a
# fine-grained PAT scoped to it (Contents: write). Both are passed (empty
@@ -116,6 +119,9 @@ jobs:
brew-smoke:
needs: goreleaser
name: brew install pgrundev/tap/pgbot
+ # The tap is pgrundev's; a fork has no credential for it by design, so the
+ # loud "formula NOT published" failure below is only meaningful upstream.
+ if: github.repository == 'pgrundev/pgbot'
runs-on: macos-latest
env:
HAS_TAP_CRED: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY != '' || secrets.HOMEBREW_TAP_TOKEN != '' }}
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 23d6580..83a0164 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -77,14 +77,14 @@ nfpms:
dockers:
- image_templates:
- - "ghcr.io/pgrundev/pgbot:{{ .Version }}-amd64"
+ - "{{ .Env.IMAGE_REPO }}:{{ .Version }}-amd64"
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/amd64"
goarch: amd64
- image_templates:
- - "ghcr.io/pgrundev/pgbot:{{ .Version }}-arm64"
+ - "{{ .Env.IMAGE_REPO }}:{{ .Version }}-arm64"
dockerfile: Dockerfile
use: buildx
build_flag_templates:
@@ -95,14 +95,14 @@ dockers:
# :latest) works on Apple Silicon / Graviton, not just amd64. The README lists
# Docker as a first-class install method and uses the bare (:latest) tag.
docker_manifests:
- - name_template: "ghcr.io/pgrundev/pgbot:{{ .Version }}"
+ - name_template: "{{ .Env.IMAGE_REPO }}:{{ .Version }}"
image_templates:
- - "ghcr.io/pgrundev/pgbot:{{ .Version }}-amd64"
- - "ghcr.io/pgrundev/pgbot:{{ .Version }}-arm64"
- - name_template: "ghcr.io/pgrundev/pgbot:latest"
+ - "{{ .Env.IMAGE_REPO }}:{{ .Version }}-amd64"
+ - "{{ .Env.IMAGE_REPO }}:{{ .Version }}-arm64"
+ - name_template: "{{ .Env.IMAGE_REPO }}:latest"
image_templates:
- - "ghcr.io/pgrundev/pgbot:{{ .Version }}-amd64"
- - "ghcr.io/pgrundev/pgbot:{{ .Version }}-arm64"
+ - "{{ .Env.IMAGE_REPO }}:{{ .Version }}-amd64"
+ - "{{ .Env.IMAGE_REPO }}:{{ .Version }}-arm64"
# Homebrew formula, pushed to the pgrundev/homebrew-tap repo so
# `brew install pgrundev/tap/pgbot` works (issue #8). The workflow's GITHUB_TOKEN
@@ -145,9 +145,8 @@ brews:
system bin/"pgbot", "--version"
release:
- github:
- owner: pgrundev
- name: pgbot
+ # No explicit github.owner/name: goreleaser publishes to the repository the
+ # workflow runs in, so a fork's tag releases on the fork.
prerelease: auto
# Publish the JSON Schema for the --json contracts as release assets, so a
# consumer can pin against a version (B7-2). The files are generated from the