inspect

package
v0.5.0-beta.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 24, 2026 License: PostgreSQL Imports: 9 Imported by: 0

Documentation

Overview

Package inspect runs read-only diagnostic queries against a Postgres database and renders the rows. It is the one place the CLI connects to Postgres rather than to a pgEdge API (#431).

Every query is a literal with no interpolated input, reads only catalog and statistics views, and writes nothing. The SQL follows the PgHero-derived inspect queries other Postgres CLIs ship, adapted to plain Postgres; each analysis that follows one names it.

Index

Constants

View Source
const Timeout = 30 * time.Second

Timeout bounds one analysis, connection included. Catalog queries answer in milliseconds; the bound is for a database that is not answering at all.

Variables

View Source
var Analyses = []Analysis{
	{
		Name:    "table-sizes",
		Short:   "Tables by total size, largest first",
		Columns: []string{"schema", "table", "size", "table_size", "index_size"},

		SQL: `SELECT n.nspname AS schema, c.relname AS table,
  pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
  pg_size_pretty(pg_table_size(c.oid)) AS table_size,
  pg_size_pretty(pg_indexes_size(c.oid)) AS index_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm', 'p')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND n.nspname NOT LIKE 'pg_toast%'
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 50`,
	},
	{
		Name:    "index-sizes",
		Short:   "Indexes by size, largest first",
		Columns: []string{"schema", "table", "index", "size"},

		SQL: `SELECT n.nspname AS schema, t.relname AS table,
  i.relname AS index, pg_size_pretty(pg_relation_size(i.oid)) AS size
FROM pg_index x
JOIN pg_class i ON i.oid = x.indexrelid
JOIN pg_class t ON t.oid = x.indrelid
JOIN pg_namespace n ON n.oid = i.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND n.nspname NOT LIKE 'pg_toast%'
ORDER BY pg_relation_size(i.oid) DESC
LIMIT 50`,
	},
	{
		Name:    "unused-indexes",
		Short:   "Non-unique indexes scanned fewer than 50 times",
		Columns: []string{"schema", "table", "index", "size", "scans"},

		SQL: `SELECT s.schemaname AS schema, s.relname AS table,
  s.indexrelname AS index,
  pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
  s.idx_scan AS scans
FROM pg_stat_user_indexes s
JOIN pg_index x ON x.indexrelid = s.indexrelid
WHERE NOT x.indisunique AND s.idx_scan < 50
ORDER BY pg_relation_size(s.indexrelid) DESC
LIMIT 50`,
	},
	{
		Name:    "seq-scans",
		Short:   "Tables by sequential scan count",
		Columns: []string{"schema", "table", "seq_scans", "seq_rows_read", "index_scans"},

		SQL: `SELECT schemaname AS schema, relname AS table,
  seq_scan AS seq_scans, seq_tup_read AS seq_rows_read,
  COALESCE(idx_scan, 0) AS index_scans
FROM pg_stat_user_tables
ORDER BY seq_scan DESC
LIMIT 50`,
	},
	{
		Name:       "long-running-queries",
		Short:      "Active queries running longer than five minutes",
		NeedsStats: true,
		Columns:    []string{"pid", "duration", "user", "state", "query"},

		SQL: `SELECT pid, (now() - query_start)::text AS duration,
  usename AS user, state, query
FROM pg_stat_activity
WHERE state <> 'idle' AND pid <> pg_backend_pid()
  AND query_start < now() - interval '5 minutes'
ORDER BY query_start`,
	},
	{
		Name:       "locks",
		Short:      "Sessions waiting on a lock, and who holds it",
		NeedsStats: true,
		Columns:    []string{"blocked_pid", "blocked_by", "blocked_duration", "blocked_query"},

		SQL: `SELECT a.pid AS blocked_pid,
  array_to_string(pg_blocking_pids(a.pid), ',') AS blocked_by,
  (now() - a.query_start)::text AS blocked_duration,
  a.query AS blocked_query
FROM pg_stat_activity a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY a.query_start`,
	},
	{
		Name:    "vacuum-stats",
		Short:   "Dead rows and last vacuum per table",
		Columns: []string{"schema", "table", "live_rows", "dead_rows", "last_vacuum", "last_autovacuum"},

		SQL: `SELECT schemaname AS schema, relname AS table,
  n_live_tup AS live_rows, n_dead_tup AS dead_rows,
  COALESCE(last_vacuum::text, '') AS last_vacuum,
  COALESCE(last_autovacuum::text, '') AS last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 50`,
	},
	{
		Name:    "bloat",
		Short:   "Estimated table bloat from planner statistics",
		Columns: []string{"schema", "table", "bloat_estimate", "waste"},

		SQL: `WITH constants AS (
  SELECT current_setting('block_size')::numeric AS bs, 23 AS hdr, 4 AS ma
), bloat_info AS (
  SELECT ma, bs, schemaname, tablename,
    (datawidth + (hdr + ma - (CASE WHEN hdr % ma = 0 THEN ma ELSE hdr % ma END)))::numeric AS datahdr,
    (maxfracsum * (nullhdr + ma - (CASE WHEN nullhdr % ma = 0 THEN ma ELSE nullhdr % ma END))) AS nullhdr2
  FROM (
    SELECT schemaname, tablename, hdr, ma, bs,
      SUM((1 - null_frac) * avg_width) AS datawidth,
      MAX(null_frac) AS maxfracsum,
      hdr + (SELECT 1 + count(*) / 8 FROM pg_stats s2
             WHERE null_frac <> 0 AND s2.schemaname = s.schemaname
               AND s2.tablename = s.tablename) AS nullhdr
    FROM pg_stats s, constants
    GROUP BY 1, 2, 3, 4, 5
  ) AS foo
), table_bloat AS (
  SELECT schemaname, tablename, cc.relpages, bs,
    CEIL((cc.reltuples * ((datahdr + ma -
      (CASE WHEN datahdr % ma = 0 THEN ma ELSE datahdr % ma END)) + nullhdr2 + 4)) / (bs - 20::float)) AS otta
  FROM bloat_info
  JOIN pg_class cc ON cc.relname = bloat_info.tablename
  JOIN pg_namespace nn ON cc.relnamespace = nn.oid
    AND nn.nspname = bloat_info.schemaname
    AND nn.nspname NOT IN ('pg_catalog', 'information_schema')
)
SELECT schemaname AS schema, tablename AS table,
  ROUND(CASE WHEN otta = 0 THEN 0.0 ELSE relpages / otta::numeric END, 1) AS bloat_estimate,
  pg_size_pretty((CASE WHEN relpages < otta THEN 0 ELSE bs * (relpages - otta)::bigint END)::bigint) AS waste
FROM table_bloat
WHERE relpages > 10
ORDER BY (CASE WHEN relpages < otta THEN 0 ELSE bs * (relpages - otta)::bigint END) DESC
LIMIT 50`,
	},
	{
		Name:       "calls",
		Short:      "Statements by call count (needs pg_stat_statements)",
		Extension:  "pg_stat_statements",
		NeedsStats: true,
		Columns:    []string{"calls", "total_time", "mean_time", "query"},

		SQL: `SELECT calls, ROUND(total_exec_time::numeric, 1)::text AS total_time,
  ROUND(mean_exec_time::numeric, 1)::text AS mean_time, query
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20`,
	},
	{
		Name:       "outliers",
		Short:      "Statements by total execution time (needs pg_stat_statements)",
		Extension:  "pg_stat_statements",
		NeedsStats: true,
		Columns:    []string{"total_time", "calls", "mean_time", "query"},

		SQL: `SELECT ROUND(total_exec_time::numeric, 1)::text AS total_time, calls,
  ROUND(mean_exec_time::numeric, 1)::text AS mean_time, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20`,
	},
	{
		Name:    "replication-slots",
		Short:   "Replication slots, and the WAL each one retains",
		Columns: []string{"slot", "type", "plugin", "database", "active", "wal_status", "retained_wal"},

		SQL: `SELECT slot_name AS slot, slot_type AS type, plugin, database, active::text AS active,
  wal_status, pg_size_pretty(pg_wal_lsn_diff(
    CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()
      ELSE pg_current_wal_lsn() END, restart_lsn)) AS retained_wal
FROM pg_replication_slots
ORDER BY slot_name`,
	},
	{
		Name:       "replication-lag",
		Short:      "Connected replicas and subscribers, and their lag",
		NeedsStats: true,
		Columns: []string{"client", "application", "state", "sent_lsn",
			"replay_lsn", "write_lag", "flush_lag", "replay_lag", "sync_state"},

		SQL: `SELECT COALESCE(host(client_addr), '') AS client, application_name AS application,
  state, sent_lsn::text AS sent_lsn, replay_lsn::text AS replay_lsn,
  write_lag::text AS write_lag, flush_lag::text AS flush_lag,
  replay_lag::text AS replay_lag, sync_state
FROM pg_stat_replication
ORDER BY application_name`,
	},
	{
		Name:  "subscriptions",
		Short: "Logical replication subscriptions and their progress",
		Columns: []string{"subscription", "pid", "received_lsn", "last_msg_sent",
			"last_msg_received", "latest_end_lsn"},

		SQL: `SELECT subname AS subscription, pid::text AS pid,
  received_lsn::text AS received_lsn,
  last_msg_send_time::text AS last_msg_sent,
  last_msg_receipt_time::text AS last_msg_received,
  latest_end_lsn::text AS latest_end_lsn
FROM pg_stat_subscription
ORDER BY subname`,
	},
}

Analyses is the closed vocabulary, in the order the help lists it.

View Source
var ErrMissingExtension = errors.New("extension not installed")

ErrMissingExtension is returned when an analysis needs an extension the database does not have. errors.Is distinguishes it from a failed connection so the caller can name the extension.

Functions

func Headers

func Headers(a Analysis) []string

Headers upper-cases the analysis's columns for the text table, the way every other table in the CLI prints them.

func Names

func Names() []string

Names returns the vocabulary in help order.

func Open

func Open(ctx context.Context, dbURL string) (*sql.DB, error)

Open connects with the driver and verifies the connection within Timeout, so a wrong host fails here with the driver's message rather than on the first query.

Types

type Analysis

type Analysis struct {
	Name string
	// Short is the one-line description shown in the vocabulary list.
	Short string
	// Extension is a required extension, or "" when catalog views
	// suffice. Its absence is reported by name rather than as the
	// driver's "relation does not exist".
	Extension string
	// NeedsStats marks an analysis that reads other sessions' rows in
	// pg_stat_activity or pg_stat_statements. Postgres nulls those
	// columns for a role without pg_read_all_stats, so a filter on
	// them drops every row and the analysis answers empty. The
	// managed verb connects as admin for these unless told otherwise.
	NeedsStats bool
	Columns    []string
	SQL        string
}

Analysis is one diagnostic: the columns it returns, in order, and the statement that produces them.

func Lookup

func Lookup(name string) (Analysis, bool)

Lookup returns the analysis named, or false.

type Row

type Row struct {
	// contains filtered or unexported fields
}

Row is one result row: the cell values as strings, in column order.

func Run

func Run(ctx context.Context, db *sql.DB, a Analysis) ([]Row, error)

Run executes one analysis and returns its rows. Every cell is read as text through the driver's own conversion, so a NULL is "" and a numeric keeps the server's digits.

func (Row) Columns

func (r Row) Columns() []string

Columns implements output.Row.

func (Row) MarshalJSON

func (r Row) MarshalJSON() ([]byte, error)

MarshalJSON renders the row as an object keyed by column, in column order, so -o json reads as records rather than positional arrays. -o yaml goes through the same shape but sorts keys.

Directories

Path Synopsis
Package inspecttest is a database/sql driver that answers queries from a script, so a command that runs an inspect analysis can be tested without a Postgres.
Package inspecttest is a database/sql driver that answers queries from a script, so a command that runs an inspect analysis can be tested without a Postgres.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL