Skip to content

Commit b474e78

Browse files
committed
chore: raise the supported versions to PG14+
1 parent 2951754 commit b474e78

27 files changed

Lines changed: 314 additions & 218 deletions

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ No database connection needed. The assistant never sees credentials.
4949

5050
## Install
5151

52+
**Requires PostgreSQL 14 or newer.** Older servers are not refused — dryrun
53+
warns and captures what it can — but they are untested, and some streams
54+
(planner stats in particular) will be unavailable. The floor tracks the oldest
55+
release still supported upstream.
56+
5257
**Homebrew:**
5358

5459
Homebrew 6.0 requires third-party taps to be trusted before their formulae can load:

‎cmd/dryrun/capture_check.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/boringsql/dryrun/internal/config"
14+
"github.com/boringsql/dryrun/internal/dryrun"
1415
"github.com/boringsql/dryrun/internal/history"
1516
"github.com/boringsql/dryrun/internal/schema"
1617
)
@@ -161,6 +162,9 @@ func checkNode(ctx context.Context, store *history.Store, key history.SnapshotKe
161162
return r
162163
}
163164
r.PgVersion, r.ServerAddr, r.Started = node.PgVersion, node.ServerAddr, node.PostmasterStartTime
165+
if v, err := dryrun.ParsePgVersion(node.PgVersion); err == nil && schema.BelowSupportedFloor(v) {
166+
r.Warnings = append(r.Warnings, schema.FloorWarning(v))
167+
}
164168
r.Role = history.NodeRolePrimary
165169
if node.IsStandby {
166170
r.Role = history.NodeRoleStandby

‎cmd/dryrun/main.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,10 @@ func connectDBProdFor(override string) (context.Context, *schema.DryRun, error)
586586
conn.Close()
587587
return nil, nil, err
588588
}
589+
// Once per connect, so capture/take/mcp-serve warn too, not just probe.
590+
if w := conn.FloorWarningFor(ctx); w != "" {
591+
slog.Warn(w)
592+
}
589593
return ctx, conn, nil
590594
}
591595

‎docs/multi-node-stats.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ dryrun --profile replica3 snapshot activity \
5555

5656
Each capture also records which server answered — `pg_postmaster_start_time()` and `inet_server_addr()` — so a label that stops pointing at one machine can be noticed. A one-way change is reported as a `notice:` (a restart, or a node that was replaced). A fingerprint that *recurs* after a different one intervened is reported as a `warning:`: that is a rotating endpoint — a reader endpoint, a Kubernetes Service, a `kubectl port-forward` reconnecting to whichever pod answers — and it means two servers' cumulative counters are interleaving under one label, so deltas across those rows are wrong. Neither ever fails a capture. If the label deliberately names a pool, pass `--allow-rotation` to silence the warning; the one-way notice still prints. `system_identifier` cannot do this job: replicas inherit the primary's through base backup, so it is identical across a cluster.
5757

58-
Each activity row also carries four database-scoped, best-effort sections, independent of any table: `pg_stat_database` counters for the connected database (deadlocks, temp spill, commit/rollback volume, buffer hit ratio, conflicts, checksum failures), `pg_replication_slots` (per-slot activity and WAL retention risk, PG13+ fields nullable on 13-), `pg_stat_replication` peers (application_name, client_addr, state, sync_state, LSN positions, and lag), and checkpoint counters normalized from `pg_stat_checkpointer` (PG17+) or `pg_stat_bgwriter` (older). All four are CLUSTER facts riding a per-node, per-database document — a multi-database cluster reports identical checkpoint counters once per tracked database, a standby's own replication-slot list is normally empty (its slots, if any, live on the primary), and peers are normally empty on a standby unless it is itself feeding further standbys via cascading replication. A read failure on any of the four leaves it absent on the wire, never a substitute zero.
58+
Each activity row also carries four database-scoped, best-effort sections, independent of any table: `pg_stat_database` counters for the connected database (deadlocks, temp spill, commit/rollback volume, buffer hit ratio, conflicts, checksum failures), `pg_replication_slots` (per-slot activity and WAL retention risk; `safe_wal_size` is null under the default `max_slot_wal_keep_size`, and snapshots captured below the PG14 floor may lack `wal_status`), `pg_stat_replication` peers (application_name, client_addr, state, sync_state, LSN positions, and lag), and checkpoint counters normalized from `pg_stat_checkpointer` (PG17+) or `pg_stat_bgwriter` (older). All four are CLUSTER facts riding a per-node, per-database document — a multi-database cluster reports identical checkpoint counters once per tracked database, a standby's own replication-slot list is normally empty (its slots, if any, live on the primary), and peers are normally empty on a standby unless it is itself feeding further standbys via cascading replication. A read failure on any of the four leaves it absent on the wire, never a substitute zero.
5959

6060
The peer section is captured on both primaries and standbys; the lag reference LSN is chosen in SQL by `pg_is_in_recovery()` — `pg_current_wal_lsn()` on a primary, `pg_last_wal_receive_lsn()` on a standby — so cascading replicas are visible without aborting the capture. Lag fields are NULL for an idle standby that has not sent a recent replay message, and state/LSN fields are NULL for a `state='backup'` walsender (a running `pg_basebackup`), so a nil field always means "unknown", never "no lag". The `pg_read_all_stats` role is required for full peer visibility; without it `pg_stat_replication` still returns one row per walsender but with every column NULL except `pid`, which dryrun records as rows with an empty `application_name` and all other fields absent. So `replication_peers_read_ok=true` with a list of such blank rows signals missing privilege, while a genuinely empty list means no standbys. To join peer rows to `describe_table` / `detect` node breakdowns, set each replica's `--label` to its `application_name` (the `primary_conninfo` setting on the replica). Peer rows also carry `client_addr` (replica IP or NULL for Unix sockets); that is infrastructure metadata, not table data, and is not routed through the column-oriented data-masking policy.
6161

‎docs/query-stats.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ dryrun snapshot capture --from "$URL" --label replica-1 --streams query
1313

1414
`dryrun snapshot query-stats --from "$URL" --label replica-1` still works and writes the same row through the same code, without the lock or the interval check. It is superseded by `capture --from` and kept for existing scripts.
1515

16-
Each row stores the raw `pg_stat_statements` rows, the grouped shapes with their rollups, and one `pg_stat_statements_info` read (`stats_reset`, `dealloc`). On PG13 the info part is absent rather than zero. A zero would look like a reset epoch that never happened.
16+
Each row stores the raw `pg_stat_statements` rows, the grouped shapes with their rollups, and one `pg_stat_statements_info` read (`stats_reset`, `dealloc`). Where `pg_stat_statements` is older than 1.9 the info part is absent rather than zero. A zero would look like a reset epoch that never happened.
1717

1818
The fetch is capped at 500 rows by default; `--query-stats-limit` or `[query_stats].row_cap` changes it. A capped capture records `raw_rows` and `row_cap`. Without that, a diff couldn't tell a genuinely new shape from one that only made it under the cap this time.
1919

‎internal/dryrun/version.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import (
77
"unicode"
88
)
99

10+
// The oldest major dryrun supports; tracks the oldest community-supported
11+
// release. Older servers are warned about, not refused. Never use this to
12+
// interpret a stored snapshot: it moves between binaries.
13+
const MinSupportedMajor = 14
14+
1015
type PgVersion struct {
1116
Major int `json:"major"`
1217
Minor int `json:"minor"`

‎internal/mcp/handlers_query.go‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,7 @@ func (s *Server) handleCheckMigration(_ context.Context, req mcp.CallToolRequest
123123
return errResult(err.Error()), nil
124124
}
125125

126-
pgVersion, _ := dryrun.ParsePgVersion(snap.PgVersion)
127-
checks, err := query.CheckMigration(getArg(req, "ddl"), snap, &pgVersion)
126+
checks, err := query.CheckMigration(getArg(req, "ddl"), snap)
128127
if err != nil {
129128
return errResult(fmt.Sprintf("DDL parse error: %v", err)), nil
130129
}
@@ -184,14 +183,14 @@ type (
184183
)
185184

186185
// A nil plan still leaves snapshot-based index suggestions.
187-
func planInsightsFor(annotated *schema.AnnotatedSchema, sql string, plan *query.PlanNode, includeIdx bool, pgVersion *dryrun.PgVersion) planInsights {
186+
func planInsightsFor(annotated *schema.AnnotatedSchema, sql string, plan *query.PlanNode, includeIdx bool) planInsights {
188187
var out planInsights
189188
if plan != nil {
190189
out.warnings = query.DetectPlanWarnings(plan, annotated.Schema)
191-
out.advice = query.Advise(plan, annotated, pgVersion)
190+
out.advice = query.Advise(plan, annotated)
192191
}
193192
if includeIdx {
194-
if suggestions, err := query.SuggestIndex(sql, annotated.Schema, plan, pgVersion); err == nil {
193+
if suggestions, err := query.SuggestIndex(sql, annotated.Schema, plan); err == nil {
195194
out.indexes = suggestions
196195
}
197196
}
@@ -225,8 +224,6 @@ func (s *Server) handleAdvise(ctx context.Context, req mcp.CallToolRequest) (*mc
225224
sql := getArg(req, "sql")
226225
includeIdx := getBoolArgDefault(req, "include_index_suggestions", true)
227226
analyze := getBoolArg(req, "analyze")
228-
pgVersion, _ := dryrun.ParsePgVersion(snap.PgVersion)
229-
230227
plan, supplied, err := suppliedPlan(req)
231228
if err != nil {
232229
return errResult(fmt.Sprintf("plan_json parse error: %v", err)), nil
@@ -254,7 +251,7 @@ func (s *Server) handleAdvise(ctx context.Context, req mcp.CallToolRequest) (*mc
254251
plan = &result.Plan
255252
}
256253
}
257-
insights := planInsightsFor(annotated, sql, plan, includeIdx, &pgVersion)
254+
insights := planInsightsFor(annotated, sql, plan, includeIdx)
258255

259256
wrapper := map[string]any{}
260257
if vErr != nil {

‎internal/mcp/query_stats_caveats.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ func queryStatsCaveats(latest, previous []schema.QueryStatsSnapshot) []string {
7676
node, humanDuration(window), snap.InfoAfter.StatsReset.UTC().Format(time.RFC3339)))
7777
}
7878
} else if snap.InfoAfter == nil {
79-
// PG13 has no info view; fetchPgssInfo also returns nil on read failure.
79+
// pgss < 1.9 has no info view; fetchPgssInfo also returns nil on read failure.
8080
scope = append(scope, fmt.Sprintf(
81-
"reset epoch and eviction count are unknown for %s (pg_stat_statements_info is unreadable or the node predates PG14), so the counters' age cannot be established", node))
81+
"reset epoch and eviction count are unknown for %s (pg_stat_statements_info is unreadable or pg_stat_statements is older than 1.9), so the counters' age cannot be established", node))
8282
}
8383

8484
if c, ok := regresqlMetaCaveat(node, snap); ok {
@@ -153,7 +153,7 @@ func crossNodeWindowCaveat(latest []schema.QueryStatsSnapshot) string {
153153
}
154154

155155
// trackAllCaveat qualifies a track = 'all' capture: filtered captures no
156-
// longer double count; the split-era fields and unfiltered paths (PG13,
156+
// longer double count; the split-era fields and unfiltered paths (pgss < 1.9,
157157
// pre-filter captures) still need the old warnings.
158158
func trackAllCaveat(node string, snap schema.QueryStatsSnapshot) string {
159159
for _, q := range snap.Queries {
@@ -168,7 +168,7 @@ func trackAllCaveat(node string, snap schema.QueryStatsSnapshot) string {
168168
"track = 'all' on %s: nested statements were excluded at capture (toplevel filter), so no time is double counted — but work done inside functions and triggers is invisible here, exactly as under track = 'top'; 'all' now only adds pg_stat_statements entry pressure", node)
169169
}
170170
return fmt.Sprintf(
171-
"track = 'all' on %s but this capture carries no top-level split: either nothing ran nested, or pg_stat_statements predates 1.9 (PG14), or the capture predates the toplevel filter — in the latter two cases nested time is counted both in its own row and in the caller's, so shares are understated", node)
171+
"track = 'all' on %s but this capture carries no top-level split: either nothing ran nested, or pg_stat_statements predates 1.9, or the capture predates the toplevel filter — in the latter two cases nested time is counted both in its own row and in the caller's, so shares are understated", node)
172172
}
173173

174174
// changeCaveats reports settings that moved between a node's last two

‎internal/mcp/query_stats_caveats_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func TestCrossNodeWindowCaveatNeedsTwoKnownWindows(t *testing.T) {
114114
func TestNodeWindowRejectsUnusableTimestamps(t *testing.T) {
115115
now := time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)
116116
cases := map[string]schema.QueryStatsSnapshot{
117-
"no info view (PG13)": {Node: schema.NodeIdentity{Source: "n", Timestamp: now}},
117+
"no info view (pgss < 1.9)": {Node: schema.NodeIdentity{Source: "n", Timestamp: now}},
118118
"zero reset": {
119119
Node: schema.NodeIdentity{Source: "n", Timestamp: now},
120120
InfoAfter: &schema.QueryStatsInfo{},
@@ -313,7 +313,7 @@ func TestYoungCountersWarnButOldOnesDoNot(t *testing.T) {
313313
func TestUnknownEpochIsReportedNotSilent(t *testing.T) {
314314
now := time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)
315315
cases := map[string]schema.QueryStatsSnapshot{
316-
"no info view at all": {Node: schema.NodeIdentity{Source: "pg13", Timestamp: now}},
316+
"no info view at all": {Node: schema.NodeIdentity{Source: "old-pgss", Timestamp: now}},
317317
"second probe failed": {
318318
Node: schema.NodeIdentity{Source: "partial", Timestamp: now},
319319
InfoBefore: &schema.QueryStatsInfo{StatsReset: now.Add(-time.Hour)},

‎internal/query/advise.go‎

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"fmt"
55
"strings"
66

7-
"github.com/boringsql/dryrun/internal/dryrun"
87
"github.com/boringsql/dryrun/internal/schema"
98
"github.com/boringsql/dryrun/pkg/bloat"
109
"github.com/boringsql/dryrun/pkg/jit"
@@ -21,12 +20,12 @@ type Advice struct {
2120
}
2221

2322
// Walks plan tree, with per-node seq_scan breakdown when merged activity present
24-
func Advise(plan *PlanNode, a *schema.AnnotatedSchema, pgVersion *dryrun.PgVersion) []Advice {
23+
func Advise(plan *PlanNode, a *schema.AnnotatedSchema) []Advice {
2524
var advice []Advice
2625
snap := a.Schema
27-
walkForAdvice(plan, a, pgVersion, &advice)
26+
walkForAdvice(plan, a, &advice)
2827

29-
if suggestions, err := SuggestIndex("", snap, plan, pgVersion); err == nil && len(suggestions) > 0 {
28+
if suggestions, err := SuggestIndex("", snap, plan); err == nil && len(suggestions) > 0 {
3029
for i := range advice {
3130
if advice[i].Table == nil {
3231
continue
@@ -74,19 +73,19 @@ func perNodeBreakdown(a *schema.AnnotatedSchema, qualified string) string {
7473
return strings.Join(lines, "\n")
7574
}
7675

77-
func walkForAdvice(node *PlanNode, a *schema.AnnotatedSchema, pgVersion *dryrun.PgVersion, advice *[]Advice) {
78-
adviseSeqScan(node, a, pgVersion, advice)
79-
adviseNestedLoopSeqScan(node, pgVersion, advice)
80-
adviseSort(node, a.Schema, pgVersion, advice)
76+
func walkForAdvice(node *PlanNode, a *schema.AnnotatedSchema, advice *[]Advice) {
77+
adviseSeqScan(node, a, advice)
78+
adviseNestedLoopSeqScan(node, advice)
79+
adviseSort(node, a.Schema, advice)
8180
adviseIndexScanBloat(node, a, advice)
8281
adviseCTE(node, advice)
8382

8483
for i := range node.Children {
85-
walkForAdvice(&node.Children[i], a, pgVersion, advice)
84+
walkForAdvice(&node.Children[i], a, advice)
8685
}
8786
}
8887

89-
func adviseSeqScan(node *PlanNode, a *schema.AnnotatedSchema, pgVersion *dryrun.PgVersion, advice *[]Advice) {
88+
func adviseSeqScan(node *PlanNode, a *schema.AnnotatedSchema, advice *[]Advice) {
9089
if node.NodeType != "Seq Scan" || node.RelationName == nil || node.PlanRows < 10_000 {
9190
return
9291
}
@@ -197,11 +196,11 @@ func adviseSeqScan(node *PlanNode, a *schema.AnnotatedSchema, pgVersion *dryrun.
197196
Table: strp(qualified),
198197
Recommendation: recommendation,
199198
DDL: ddl,
200-
VersionNote: versionNoteForIndex(pgVersion),
199+
VersionNote: versionNoteForIndex(),
201200
})
202201
}
203202

204-
func adviseNestedLoopSeqScan(node *PlanNode, pgVersion *dryrun.PgVersion, advice *[]Advice) {
203+
func adviseNestedLoopSeqScan(node *PlanNode, advice *[]Advice) {
205204
if node.NodeType != "Nested Loop" || len(node.Children) < 2 {
206205
return
207206
}
@@ -233,11 +232,11 @@ func adviseNestedLoopSeqScan(node *PlanNode, pgVersion *dryrun.PgVersion, advice
233232
Table: strp(qualified),
234233
Recommendation: "Add an index on the join/filter column of the inner table to convert the seq scan to an index scan.",
235234
DDL: ddl,
236-
VersionNote: versionNoteForIndex(pgVersion),
235+
VersionNote: versionNoteForIndex(),
237236
})
238237
}
239238

240-
func adviseSort(node *PlanNode, _ *schema.SchemaSnapshot, pgVersion *dryrun.PgVersion, advice *[]Advice) {
239+
func adviseSort(node *PlanNode, _ *schema.SchemaSnapshot, advice *[]Advice) {
241240
if node.NodeType != "Sort" || node.PlanRows < 10_000 || len(node.SortKey) == 0 {
242241
return
243242
}
@@ -259,7 +258,7 @@ func adviseSort(node *PlanNode, _ *schema.SchemaSnapshot, pgVersion *dryrun.PgVe
259258
Table: strp(qualified),
260259
Recommendation: "Consider an index matching the sort order to avoid an explicit sort step.",
261260
DDL: strp(ddl),
262-
VersionNote: versionNoteForIndex(pgVersion),
261+
VersionNote: versionNoteForIndex(),
263262
})
264263
}
265264

@@ -309,17 +308,10 @@ func suggestIndexType(table, colType, colName string) (string, string) {
309308
}
310309
}
311310

312-
func versionNoteForIndex(pgVersion *dryrun.PgVersion) *string {
313-
if pgVersion == nil {
314-
return nil
315-
}
316-
if pgVersion.Major >= 13 {
317-
return strp("PG 13+: B-tree deduplication is enabled by default, reducing index size for low-cardinality columns.")
318-
}
319-
if pgVersion.Major >= 11 {
320-
return strp("PG 11+: Use INCLUDE for covering indexes to enable index-only scans.")
321-
}
322-
return nil
311+
// Both facts hold on every supported major, so no version branching.
312+
func versionNoteForIndex() *string {
313+
return strp("B-tree deduplication is on by default, which shrinks indexes on low-cardinality columns. " +
314+
"Use INCLUDE for covering indexes to enable index-only scans.")
323315
}
324316

325317
func adviseIndexScanBloat(node *PlanNode, a *schema.AnnotatedSchema, advice *[]Advice) {

0 commit comments

Comments
 (0)