@@ -238,6 +238,80 @@ WHERE am.amname = 'btree' -- only B-tree indexes
238238ORDER 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
0 commit comments