Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/jackc/pgx/v5 v5.10.0
github.com/muesli/termenv v0.16.0
github.com/spf13/pflag v1.0.10
golang.org/x/sync v0.21.0
)

require (
Expand All @@ -31,7 +32,6 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
2 changes: 1 addition & 1 deletion internal/pg/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (c *Client) ListActivity(ctx context.Context, db string, mode ActivityFilte
&r.BackendType, &r.State, &r.WaitEventType, &r.WaitEvent,
&r.BackendXid, &r.BackendXmin,
&r.QueryAgeMs, &r.XactAgeMs, &r.StateAgeMs,
&r.QueryID, &r.Query,
&r.QueryID, &r.BlockedBy, &r.Query,
)
return r, err
})
Expand Down
4 changes: 2 additions & 2 deletions internal/pg/bloat.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error {
if err != nil {
return err
}
qualified := fmt.Sprintf("%q.%q", t.Schema, t.Name)
qualified := qualifiedIdent(t.Schema, t.Name)

for i := range parts {
p := &parts[i]
Expand Down Expand Up @@ -83,7 +83,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error {
}
}
case PartIndex:
indexRef := fmt.Sprintf("%q.%q", t.Schema, p.Name)
indexRef := qualifiedIdent(t.Schema, p.Name)
if mode == BloatExact && p.AccessMethod == "btree" {
if err := pool.QueryRow(ctx, sqlBloatIndex, indexRef).Scan(&p.WastedBytes); err == nil {
p.HasBloat = true
Expand Down
19 changes: 19 additions & 0 deletions internal/pg/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ func (c *Client) ResolveTable(ctx context.Context, db, name string) (Table, erro
return t, nil
}

// ResolveIndex resolves an index name (optionally schema-qualified) to its OID
// and qualified display name, so `d` on rows that only carry an index name
// (diagnostic results) can reach DescribeIndex.
func (c *Client) ResolveIndex(ctx context.Context, db, name string) (oid uint32, qualified string, err error) {
pool, err := c.PoolFor(ctx, db)
if err != nil {
return 0, "", err
}
var schema, rel string
err = pool.QueryRow(ctx, sqlResolveIndex, name).Scan(&oid, &schema, &rel)
if errors.Is(err, pgx.ErrNoRows) {
return 0, "", &MissingRelationError{Name: name}
}
if err != nil {
return 0, "", fmt.Errorf("resolve index %q in %q: %w", name, db, err)
}
return oid, schema + "." + rel, nil
}

// MissingRelationError reports that a name couldn't be resolved to a describable
// relation (e.g. it's a CTE alias, a view, or simply doesn't exist).
type MissingRelationError struct{ Name string }
Expand Down
120 changes: 120 additions & 0 deletions internal/pg/diagnostic_defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ type Diagnostic struct {
Bar string // headline column name rendered as a bar, or ""
Sort string // default sort column name (descending); "" falls back to Bar, then column 0 ascending
PerDB bool // true = query reads only the connected database; the TUI prompts for which database to run against (or all)

// Kinds overrides the name-heuristic column kind (colKindFromName) per
// column, so a diagnostic can opt into graded rendering the suffix rules
// can't infer — e.g. hit ratios as DiagPercentGraded (higher is better) or
// dead-tuple % as DiagPercentBad (higher is worse). Keys are column names.
Kinds map[string]DiagColumnKind
}

// DiagnosticByKey looks a diagnostic up in the registry by its stable key.
func DiagnosticByKey(key string) (Diagnostic, bool) {
for _, d := range Diagnostics {
if d.Key == key {
return d, true
}
}
return Diagnostic{}, false
}

// Diagnostics is the ordered registry of all built-in diagnostic queries.
Expand All @@ -29,6 +45,15 @@ var Diagnostics = []Diagnostic{
SQL: sqlDiagBloatIndex,
Bar: "bloat_pct",
},
{
Key: "fk_missing_index",
PerDB: true,
Title: "FKs without index",
Category: "index",
Description: "foreign keys whose referencing columns have no supporting index — parent deletes/updates seq-scan the child table",
SQL: sqlDiagFKMissingIndex,
Bar: "table_size_bytes",
},
{
Key: "index_brin_candidates",
PerDB: true,
Expand All @@ -49,6 +74,34 @@ var Diagnostics = []Diagnostic{
// SQL: sqlDiagIndexShowAll,
// Bar: "number_of_scans",
// },
{
Key: "index_invalid",
PerDB: true,
Title: "Invalid indexes",
Category: "index",
Description: "indexes left INVALID by a failed CREATE/REINDEX CONCURRENTLY — unusable by plans but still maintained on writes",
SQL: sqlDiagIndexInvalid,
Bar: "index_size_bytes",
},
{
Key: "index_io",
PerDB: true,
Title: "Index I/O",
Category: "index",
Description: "per-index buffer cache hits vs disk reads — hot indexes with poor hit ratios are shared_buffers pressure",
SQL: sqlDiagIndexIO,
Bar: "blks_read",
Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded},
},
{
Key: "index_redundant_prefix",
PerDB: true,
Title: "Redundant indexes (prefix)",
Category: "index",
Description: "btree indexes whose key columns are a leading prefix of a wider index — usually droppable write amplification",
SQL: sqlDiagIndexRedundantPrefix,
Bar: "redundant_size_bytes",
},
{
Key: "index_show_definitions",
PerDB: true,
Expand Down Expand Up @@ -107,6 +160,16 @@ var Diagnostics = []Diagnostic{
SQL: sqlDiagBloatTable,
Bar: "pct_bloat",
},
{
Key: "stale_statistics",
PerDB: true,
Title: "Stale planner statistics",
Category: "table",
Description: "tables whose row modifications since the last ANALYZE outgrow their live rows — bad-plan risk",
SQL: sqlDiagStaleStatistics,
Bar: "stale_pct",
Kinds: map[string]DiagColumnKind{"stale_pct": DiagPercentBad},
},
{
Key: "table_scan_types",
PerDB: true,
Expand All @@ -115,6 +178,7 @@ var Diagnostics = []Diagnostic{
Description: "tables with >20% sequential reads and >800 kB — potential missing-index candidates",
SQL: sqlDiagTableScanTypes,
Bar: "index_read_pct",
Kinds: map[string]DiagColumnKind{"index_read_pct": DiagPercentGraded},
},
{
Key: "table_show_hitratio",
Expand All @@ -124,6 +188,7 @@ var Diagnostics = []Diagnostic{
Description: "tables with heap cache hit ratio below 80%, ordered by blocks read from disk",
SQL: sqlDiagTableShowHitratio,
Bar: "hit_pct",
Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded},
},
{
Key: "table_show_hot_ratio",
Expand All @@ -134,6 +199,7 @@ var Diagnostics = []Diagnostic{
SQL: sqlDiagTableShowHotRatio,
Bar: "hot_pct",
Sort: "non_hot_updates",
Kinds: map[string]DiagColumnKind{"hot_pct": DiagPercentGraded},
},
{
Key: "table_show_modify_ratio",
Expand Down Expand Up @@ -170,6 +236,15 @@ var Diagnostics = []Diagnostic{
Description: "currently running autovacuum workers with scan and vacuum progress",
SQL: sqlDiagAutovacuumProgress,
Bar: "scanned_pct",
Kinds: map[string]DiagColumnKind{"dead_pct": DiagPercentBad},
},
{
Key: "progress_all",
Title: "Running operations (progress)",
Category: "vacuum",
Description: "everything with a pg_stat_progress_* view — VACUUM, CREATE INDEX, ANALYZE, CLUSTER, COPY, base backups — with % done",
SQL: sqlDiagProgressAll,
Bar: "done_pct",
},
{
Key: "vacuum_running",
Expand Down Expand Up @@ -199,6 +274,27 @@ var Diagnostics = []Diagnostic{
SQL: sqlDiagConnections,
Bar: "connections",
},
{
Key: "idle_in_xact_holders",
Title: "Idle-in-transaction lock holders",
Category: "activity",
Description: "open transactions sitting idle, with the locks they still hold — the usual 'why is this stuck / why is bloat growing' answer",
SQL: sqlDiagIdleInXactHolders,
Bar: "xact_age_secs",
Kinds: map[string]DiagColumnKind{
"xact_age_secs": DiagCostGraded,
"state": DiagBackendState,
},
},
{
Key: "lock_summary",
Title: "Lock summary",
Category: "activity",
Description: "pg_locks grouped by lock type and mode with waiter counts — the one-glance contention read",
SQL: sqlDiagLockSummary,
Bar: "locks",
Kinds: map[string]DiagColumnKind{"waiting": DiagCostGraded},
},
// ── wal ───────────────────────────────────────────────────────────────
{
Key: "wal_files",
Expand Down Expand Up @@ -232,6 +328,7 @@ var Diagnostics = []Diagnostic{
Description: "per-database commits, rollbacks, cache hit ratio, deadlocks and temp-file usage",
SQL: sqlDiagDatabaseStats,
Bar: "hit_pct",
Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded},
},
{
Key: "foreignkeys_show_all",
Expand Down Expand Up @@ -267,6 +364,7 @@ var Diagnostics = []Diagnostic{
Description: "how much of each sequence's range is consumed (last_value needs SELECT/USAGE)",
SQL: sqlDiagSequences,
Bar: "consumed_pct",
Kinds: map[string]DiagColumnKind{"consumed_pct": DiagPercentBad},
},
{
Key: "settings_show_pending",
Expand All @@ -276,4 +374,26 @@ var Diagnostics = []Diagnostic{
SQL: sqlDiagSettingsShowPending,
Bar: "",
},
{
Key: "slru_stats",
Title: "SLRU caches",
Category: "server",
Description: "transaction-status / multixact / subtransaction cache traffic — invisible pressure from long transactions and savepoints",
SQL: sqlDiagSLRU,
Bar: "blks_read",
Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded},
},
{
Key: "subscription_stats",
PerDB: true,
Title: "Logical subscriptions",
Category: "server",
Description: "logical-replication subscriptions with worker state, message staleness and apply/sync error counts",
SQL: sqlDiagSubscriptionStats,
Bar: "",
Kinds: map[string]DiagColumnKind{
"apply_errors": DiagCostGraded,
"sync_errors": DiagCostGraded,
},
},
}
17 changes: 17 additions & 0 deletions internal/pg/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func (c *Client) RunDiagnostic(ctx context.Context, db string, d Diagnostic) (*D
return nil, fmt.Errorf("run diagnostic %q: %w", d.Key, err)
}

applyKindOverrides(cols, d.Kinds)
barCol, sortCol := resolveBarSort(cols, d)
return &DiagResult{Columns: cols, Rows: resultRows, BarCol: barCol, SortCol: sortCol}, nil
}
Expand Down Expand Up @@ -183,10 +184,26 @@ func (c *Client) RunDiagnosticAllDBs(ctx context.Context, d Diagnostic) (*DiagRe
return &DiagResult{Columns: []DiagColumn{{Name: dbColName, Kind: DiagText}}, BarCol: -1, SortCol: -1}, nil
}

applyKindOverrides(cols, d.Kinds)
barCol, sortCol := resolveBarSort(cols, d)
return &DiagResult{Columns: cols, Rows: rows, BarCol: barCol, SortCol: sortCol}, nil
}

// applyKindOverrides replaces inferred column kinds with the diagnostic's
// declared ones (Diagnostic.Kinds). Applied after scanning: Kind only drives
// rendering, so a post-scan overwrite is safe and also wins over the
// text→numeric promotion done while scanning.
func applyKindOverrides(cols []DiagColumn, kinds map[string]DiagColumnKind) {
if len(kinds) == 0 {
return
}
for i, c := range cols {
if k, ok := kinds[c.Name]; ok {
cols[i].Kind = k
}
}
}

// scanDiagRows drains rows into generic column/cell form: column metadata comes
// from the server's field descriptions (kind inferred by name, promoted to
// numeric on the first numeric value seen), each value goes through
Expand Down
Loading
Loading