Skip to content

Commit bcbcde2

Browse files
committed
Merge branch 'md_temp' into 'master'
buffers, pages, diagnostics: temperature columns, CLUSTER candidates, overview buffer stats See merge request sysadmins/pgdu!5
2 parents 88f9d05 + 6247c99 commit bcbcde2

33 files changed

Lines changed: 707 additions & 82 deletions

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ require (
1010
github.com/jackc/pgx/v5 v5.10.0
1111
github.com/muesli/termenv v0.16.0
1212
github.com/spf13/pflag v1.0.10
13-
golang.org/x/sync v0.22.0
1413
)
1514

1615
require (
@@ -32,6 +31,7 @@ require (
3231
github.com/muesli/cancelreader v0.2.2 // indirect
3332
github.com/rivo/uniseg v0.4.7 // indirect
3433
github.com/xo/terminfo v1.0.0 // indirect
34+
golang.org/x/sync v0.22.0 // indirect
3535
golang.org/x/sys v0.47.0 // indirect
3636
golang.org/x/text v0.41.0 // indirect
3737
)

‎internal/pg/buffers.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,38 @@ func (c *Client) TableBufferUsageCounts(ctx context.Context, db string, oid uint
175175
return counts, blockSize, err
176176
}
177177

178+
// PageBuffers maps the blocks of one main-fork window of a relation to their
179+
// shared-buffers state (usagecount, dirty). Blocks not currently cached are
180+
// absent from the map. Powers the page inspector's per-page temperature
181+
// column; callers treat failures as "no temperature data" rather than fatal.
182+
func (c *Client) PageBuffers(ctx context.Context, db string, oid uint32, start, count int32) (map[int64]PageBuffer, error) {
183+
if err := c.EnsureBufferCache(ctx, db); err != nil {
184+
return nil, err
185+
}
186+
pool, err := c.PoolFor(ctx, db)
187+
if err != nil {
188+
return nil, err
189+
}
190+
rows, err := pool.Query(ctx, sqlPageBuffers, oid, start, count)
191+
if err != nil {
192+
return nil, fmt.Errorf("page buffers for oid %d in %q: %w", oid, db, err)
193+
}
194+
defer rows.Close()
195+
bufs := make(map[int64]PageBuffer)
196+
for rows.Next() {
197+
var blkno int64
198+
var b PageBuffer
199+
if err := rows.Scan(&blkno, &b.UsageCount, &b.Dirty); err != nil {
200+
return nil, fmt.Errorf("page buffers for oid %d in %q: %w", oid, db, err)
201+
}
202+
bufs[blkno] = b
203+
}
204+
if err := rows.Err(); err != nil {
205+
return nil, fmt.Errorf("page buffers for oid %d in %q: %w", oid, db, err)
206+
}
207+
return bufs, nil
208+
}
209+
178210
// BufferCacheSummary returns the cluster-wide shared_buffers occupancy split
179211
// between the current database, anything else, and free pages.
180212
func (c *Client) BufferCacheSummary(ctx context.Context, db string) (BufferCacheSummary, error) {

‎internal/pg/diagnostic_defs.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,42 @@ var Diagnostics = []Diagnostic{
9898
range scans, not single-row lookups — check the workload first, create
9999
the BRIN, verify the plans still prune, then drop the btree.`,
100100
},
101+
{
102+
Key: "index_cluster_candidates",
103+
PerDB: true,
104+
Title: "CLUSTER candidates (fragmented)",
105+
Category: "index",
106+
Description: "hot btree indexes on low-correlation columns (|corr| ≤ 0.5) whose scans return many scattered rows — candidates for CLUSTER / pg_repack",
107+
SQL: sqlDiagIndexClusterCandidates,
108+
Bar: "scatter_pct",
109+
Sort: "disk_pain",
110+
Kinds: map[string]DiagColumnKind{
111+
"scatter_pct": DiagPercentBad,
112+
"heap_miss_pct": DiagPercentBad,
113+
"disk_pain": DiagCount,
114+
"scans": DiagCount,
115+
"tuples_read": DiagCount,
116+
},
117+
Help: `Btree indexes whose scans return several rows each (tup_per_scan ≥ 5)
118+
while the heap is ordered unlike the index (scatter_pct = (1−|corr|)×100;
119+
100 means heap and index order are unrelated), so every scan fetches
120+
rows scattered across many heap pages. The classic shape is a per-key
121+
detail table — a player inventory, per-user events — always queried by
122+
that key; rows_per_key confirms it. disk_pain, the default order, is
123+
tuples_read × scatter × heap-miss ratio — an estimate of fetches that
124+
were both scattered and served from disk; a fragmented table the
125+
buffer cache fully absorbs (heap_miss_pct ≈ 0) scores near zero no
126+
matter how hot its counters are. Ignore rows
127+
with rows_per_key ≈ 1 unless the scans are range scans (cleanup by
128+
timestamp, id ranges) — clustering only helps when scanned rows share
129+
pages. Fix by rewriting the heap in index order: CLUSTER <table>
130+
USING <index> (takes an ACCESS EXCLUSIVE lock) or pg_repack for an
131+
online rewrite. Churny tables re-fragment over time, so re-run
132+
periodically; clustered = t means the table was already CLUSTERed on
133+
that index and has degraded since. scans/tuples_read/heap_miss_pct are
134+
cumulative since the last stats reset, so freshly reset stats
135+
understate hotness.`,
136+
},
101137
// "All indexes" was merged into "Index sizes" below (which now also carries
102138
// scan counts and the unique flag, across all schemas), so it is no longer a
103139
// separate entry. Kept commented for reference rather than deleted.

‎internal/pg/diagnostics.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
DiagDuration // elapsed time in ms (Num): right-aligned, coloured by absolute magnitude band (ms→green, s→yellow, min→red)
2929
DiagBackendState // pg_stat_activity state: coloured per value (active→green, idle-in-xact→yellow, aborted→red, idle→muted)
3030
DiagPercentBad // 0–100 % where higher is worse: cell text graded green→red on an absolute scale (e.g. dead-tuple %, seq-scan %)
31+
DiagCount // large cumulative counter: rendered humanized (1.2k/3.4M/5.1G) from Num; sorts and sums on the raw value
3132
)
3233

3334
// DiagColumn describes one column of a diagnostic result set.

‎internal/pg/queries_buffers.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ LEFT JOIN b ON b.usagecount = g.usagecount
7878
ORDER BY g.usagecount
7979
`
8080

81+
// sqlPageBuffers lists the shared buffers holding one window of a relation's
82+
// main fork: block number → usagecount/isdirty, feeding the page inspector's
83+
// per-page temperature column. pg_relation_filenode resolves rewritten and
84+
// mapped relations; reldatabase 0 covers shared catalogs. One full buffer-pool
85+
// scan per window — the same cost class as the aggregate queries above.
86+
const sqlPageBuffers = `
87+
SELECT relblocknumber::bigint, usagecount::int, isdirty
88+
FROM pg_buffercache
89+
WHERE relfilenode = pg_relation_filenode($1::oid::regclass)
90+
AND reldatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database()))
91+
AND relforknumber = 0
92+
AND relblocknumber >= $2::int AND relblocknumber < $2::int + $3::int
93+
`
94+
8195
// sqlBufferStats reports per-table shared-buffer footprint and cumulative I/O
8296
// counters for one schema. Buffer footprint sums the heap, toast and every
8397
// index for the table, so the "biggest cache hog" answer matches the user's

‎internal/pg/queries_diag.go‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,80 @@ WHERE am.amname = 'btree' -- only B-tree indexes
238238
ORDER BY abs(s.correlation) DESC, pg_relation_size(t.oid) DESC
239239
`
240240

241+
// sqlDiagIndexClusterCandidates is the inverse of the BRIN query: btree indexes
242+
// whose leading column has *low* physical correlation (|correlation| ≤ 0.5) yet
243+
// whose scans return many rows each — the fragmented-detail-table pattern (e.g.
244+
// a player inventory always fetched by user id) where every index scan touches
245+
// scattered heap pages and a CLUSTER/pg_repack rewrite collapses them onto few.
246+
// Only the leading column matters (k.ord = 1): its correlation decides the heap
247+
// locality of a prefix scan. Unique/primary indexes stay in deliberately — the
248+
// tuples-per-scan floor already drops point lookups (~1 tuple/scan), and a PK
249+
// like (user_id, item_id) scanned by prefix is exactly the target. Expression
250+
// indexes drop out naturally (indkey attnum 0 has no pg_attribute/pg_stats row).
251+
// scatter_pct ((1−|corr|)×100, higher = worse) is the bar. heap_miss_pct
252+
// (per-table, from pg_statio_user_tables) separates fragmented tables that
253+
// actually hit disk from ones the buffer cache absorbs anyway, and the default
254+
// sort is disk_pain — tuples_read × scatter × heap-miss ratio — so the rows
255+
// whose scattered fetches actually cost disk reads rank first, not merely the
256+
// hottest counters on fully-cached tables.
257+
const sqlDiagIndexClusterCandidates = `
258+
SELECT
259+
t.relname AS table_name,
260+
i.relname AS index_name,
261+
a.attname AS column_name,
262+
round(((1 - abs(s.correlation)) * 100)::numeric, 1) AS scatter_pct,
263+
-- Fraction of the table's heap block reads that missed shared_buffers: a
264+
-- fragmented table that lives entirely in cache costs little; one that
265+
-- hits disk on scattered pages is the real CLUSTER payoff.
266+
round(100 * sio.heap_blks_read::numeric
267+
/ nullif(sio.heap_blks_hit + sio.heap_blks_read, 0), 1) AS heap_miss_pct,
268+
-- Composite ranking: tuple fetches × scattered fraction × disk-miss
269+
-- fraction ≈ fetches that were both on a random page AND read from disk.
270+
-- Table size needs no extra factor — a table the cache absorbs already
271+
-- scores ~0 through the miss ratio. NULL (no heap I/O counted) sorts last.
272+
round(st.idx_tup_read * (1 - abs(s.correlation))
273+
* sio.heap_blks_read::numeric
274+
/ nullif(sio.heap_blks_hit + sio.heap_blks_read, 0)) AS disk_pain,
275+
st.idx_scan AS scans,
276+
st.idx_tup_read AS tuples_read,
277+
round(st.idx_tup_read::numeric / st.idx_scan, 1) AS tup_per_scan,
278+
CASE
279+
WHEN s.n_distinct > 0
280+
THEN round((t.reltuples / s.n_distinct)::numeric, 1)
281+
WHEN s.n_distinct < 0
282+
THEN round((-1 / s.n_distinct)::numeric, 1)
283+
END AS rows_per_key,
284+
idx.indisclustered AS clustered,
285+
pg_size_pretty(pg_relation_size(t.oid)) AS table_size,
286+
pg_size_pretty(pg_relation_size(i.oid)) AS index_size
287+
FROM pg_index idx
288+
JOIN pg_class i ON i.oid = idx.indexrelid -- the index
289+
JOIN pg_class t ON t.oid = idx.indrelid -- the table
290+
JOIN pg_namespace n ON n.oid = t.relnamespace
291+
JOIN pg_am am ON am.oid = i.relam -- access method
292+
-- pg_stat_user_indexes also scopes the result to user schemas, so no explicit
293+
-- pg_catalog/information_schema exclusion is needed here.
294+
JOIN pg_stat_user_indexes st ON st.indexrelid = idx.indexrelid
295+
JOIN pg_statio_user_tables sio ON sio.relid = t.oid
296+
JOIN LATERAL unnest(idx.indkey) WITH ORDINALITY AS k(attnum, ord) ON k.ord = 1
297+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
298+
LEFT JOIN pg_stats s ON s.schemaname = n.nspname
299+
AND s.tablename = t.relname
300+
AND s.attname = a.attname
301+
WHERE am.amname = 'btree' -- only B-tree (CLUSTER's home turf)
302+
AND idx.indisvalid
303+
AND a.attnum > 0 -- skip expression-index entries
304+
AND t.reltuples > 100000 -- only tables big enough to matter
305+
AND s.correlation IS NOT NULL
306+
AND abs(s.correlation) <= 0.5 -- heap order does not follow the index
307+
AND st.idx_scan > 0
308+
-- Multi-row scans only, written multiplication-side to avoid division by
309+
-- zero before the idx_scan > 0 predicate is applied: point lookups
310+
-- (~1 tuple/scan) don't suffer from fragmentation and would be noise.
311+
AND st.idx_tup_read >= 5 * st.idx_scan
312+
ORDER BY disk_pain DESC NULLS LAST
313+
`
314+
241315
// sqlDiagIndexShowAll was the old per-index listing (public schema only). Its
242316
// useful columns (scan count, tuples read, unique flag) were folded into
243317
// sqlDiagIndexShowSize, so it is no longer registered. Kept commented for

‎internal/pg/queries_tableoverview.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,44 @@ WHERE n.nspname = $1
5454
AND c.relkind IN ('r','m','p')
5555
ORDER BY total_bytes DESC
5656
`
57+
58+
// sqlTableStatsBuffers sums the current shared-buffer footprint (total and
59+
// dirty bytes) per table for one schema from pg_buffercache, covering the heap,
60+
// TOAST and every index — the same filenode aggregation as sqlBufferStats but
61+
// without the sizing/statio columns the overview already has. It is a separate
62+
// best-effort query rather than a join in sqlTableStats because pg_buffercache
63+
// is an extension needing pg_monitor: its absence must not take down the whole
64+
// overview, only blank the two buffer columns.
65+
const sqlTableStatsBuffers = `
66+
WITH bc AS (
67+
SELECT relfilenode,
68+
COUNT(*) AS bufs,
69+
COUNT(*) FILTER (WHERE isdirty) AS dirty_bufs
70+
FROM pg_buffercache
71+
WHERE reldatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database()))
72+
GROUP BY relfilenode
73+
),
74+
filenodes AS (
75+
SELECT c.oid AS tab_oid, pg_relation_filenode(c.oid) AS fn
76+
FROM pg_class c
77+
JOIN pg_namespace n ON n.oid = c.relnamespace
78+
WHERE n.nspname = $1 AND c.relkind IN ('r','m','p')
79+
UNION ALL
80+
SELECT c.oid, pg_relation_filenode(c.reltoastrelid)
81+
FROM pg_class c
82+
JOIN pg_namespace n ON n.oid = c.relnamespace
83+
WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0
84+
UNION ALL
85+
SELECT c.oid, pg_relation_filenode(i.indexrelid)
86+
FROM pg_class c
87+
JOIN pg_namespace n ON n.oid = c.relnamespace
88+
JOIN pg_index i ON i.indrelid = c.oid
89+
WHERE n.nspname = $1 AND c.relkind IN ('r','m','p')
90+
)
91+
SELECT f.tab_oid,
92+
(COALESCE(SUM(bc.bufs), 0) * current_setting('block_size')::int)::bigint AS buffered_bytes,
93+
(COALESCE(SUM(bc.dirty_bufs), 0) * current_setting('block_size')::int)::bigint AS dirty_bytes
94+
FROM filenodes f
95+
LEFT JOIN bc ON bc.relfilenode = f.fn
96+
GROUP BY f.tab_oid
97+
`

‎internal/pg/tableoverview.go‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package pg
33
import (
44
"context"
55
"fmt"
6+
"time"
67

78
"github.com/jackc/pgx/v5"
89
)
@@ -11,12 +12,17 @@ import (
1112
// table in db.schema, with size, write/scan activity, cache-hit counters,
1213
// maintenance counters and storage options gathered in a single query. Result
1314
// is ordered by total size; the TUI re-sorts by the active column.
14-
func (c *Client) ListTableStats(ctx context.Context, db, schema string) ([]TableStat, error) {
15+
//
16+
// Two best-effort extras never fail the load: the current shared-buffer
17+
// footprint per table (pg_buffercache — extension, needs pg_monitor; absent →
18+
// BufsKnown stays false) and the database's stats_reset timestamp dating the
19+
// cumulative counters (zero when unavailable or never reset).
20+
func (c *Client) ListTableStats(ctx context.Context, db, schema string) ([]TableStat, time.Time, error) {
1521
pool, err := c.PoolFor(ctx, db)
1622
if err != nil {
17-
return nil, err
23+
return nil, time.Time{}, err
1824
}
19-
return collect(ctx, pool, fmt.Sprintf("list table stats in %q.%q", db, schema), sqlTableStats, []any{schema},
25+
rows, err := collect(ctx, pool, fmt.Sprintf("list table stats in %q.%q", db, schema), sqlTableStats, []any{schema},
2026
func(row pgx.CollectableRow) (TableStat, error) {
2127
t := TableStat{DB: db, Schema: schema}
2228
err := row.Scan(
@@ -34,4 +40,39 @@ func (c *Client) ListTableStats(ctx context.Context, db, schema string) ([]Table
3440
)
3541
return t, err
3642
})
43+
if err != nil {
44+
return nil, time.Time{}, err
45+
}
46+
47+
type bufRow struct {
48+
oid uint32
49+
buffered, dirty int64
50+
}
51+
bufs := collectBestEffort(ctx, pool, sqlTableStatsBuffers, []any{schema},
52+
func(r pgx.Rows) (bufRow, bool) {
53+
var b bufRow
54+
return b, r.Scan(&b.oid, &b.buffered, &b.dirty) == nil
55+
})
56+
if bufs != nil {
57+
byOID := make(map[uint32]bufRow, len(bufs))
58+
for _, b := range bufs {
59+
byOID[b.oid] = b
60+
}
61+
// Every table in the schema gets a row from the LEFT JOIN, so a merged
62+
// zero is a real "nothing cached", not missing data.
63+
for i := range rows {
64+
if b, ok := byOID[rows[i].OID]; ok {
65+
rows[i].BufferedBytes = b.buffered
66+
rows[i].DirtyBytes = b.dirty
67+
rows[i].BufsKnown = true
68+
}
69+
}
70+
}
71+
72+
var reset time.Time
73+
var resetPtr *time.Time
74+
if pool.QueryRow(ctx, sqlMaintTableStatsReset).Scan(&resetPtr) == nil && resetPtr != nil {
75+
reset = *resetPtr
76+
}
77+
return rows, reset, nil
3778
}

‎internal/pg/triage.go‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import (
55
"errors"
66
"fmt"
77
"sort"
8+
"sync"
89
"time"
910

10-
"golang.org/x/sync/errgroup"
11-
1211
"pgdu/internal/humanize"
1312
)
1413

@@ -157,8 +156,8 @@ func (c *Client) Triage(ctx context.Context) []TriageResult {
157156
dbStats *DiagResult
158157
dbErr error
159158
)
160-
var pre errgroup.Group
161-
pre.Go(func() error {
159+
var pre sync.WaitGroup
160+
pre.Go(func() {
162161
cctx, cancel := context.WithTimeout(ctx, triageCheckTimeout)
163162
defer cancel()
164163
info, infoErr = c.Maintenance(cctx, "")
@@ -170,15 +169,13 @@ func (c *Client) Triage(ctx context.Context) []TriageResult {
170169
if infoErr == nil && (info == nil || info.Version == "") {
171170
infoErr = errors.New("server unreachable")
172171
}
173-
return nil
174172
})
175-
pre.Go(func() error {
173+
pre.Go(func() {
176174
cctx, cancel := context.WithTimeout(ctx, triageCheckTimeout)
177175
defer cancel()
178176
dbStats, dbErr = c.runTriageDiag(cctx, "", "database_stats")
179-
return nil
180177
})
181-
_ = pre.Wait()
178+
pre.Wait()
182179

183180
// mgrade/dgrade adapt a pure grader over a shared input into a check.run,
184181
// short-circuiting to "could not evaluate" when that input failed to load.
@@ -231,10 +228,12 @@ func (c *Client) Triage(ctx context.Context) []TriageResult {
231228
}
232229

233230
results := make([]TriageResult, len(checks))
234-
g := new(errgroup.Group)
235-
g.SetLimit(triageFanout)
231+
var wg sync.WaitGroup
232+
sem := make(chan struct{}, triageFanout)
236233
for i, chk := range checks {
237-
g.Go(func() error {
234+
wg.Go(func() {
235+
sem <- struct{}{}
236+
defer func() { <-sem }()
238237
cctx, cancel := context.WithTimeout(ctx, triageCheckTimeout)
239238
defer cancel()
240239
sev, detail, err := chk.run(cctx)
@@ -248,10 +247,9 @@ func (c *Client) Triage(ctx context.Context) []TriageResult {
248247
DiagKey: chk.diagKey,
249248
Target: chk.target,
250249
}
251-
return nil
252250
})
253251
}
254-
_ = g.Wait()
252+
wg.Wait()
255253

256254
sort.SliceStable(results, func(a, b int) bool {
257255
return results[a].Severity > results[b].Severity

0 commit comments

Comments
 (0)