diff --git a/apps/cli/docs/supabase/inspect/db-toast-sizes.md b/apps/cli/docs/supabase/inspect/db-toast-sizes.md new file mode 100644 index 0000000000..f0da0deb58 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-toast-sizes.md @@ -0,0 +1,13 @@ +# db-toast-sizes + +This command displays TOAST table sizes and dead chunk counts for every user table that has a TOAST relation. When a column value exceeds ~2 kB (TEXT, JSONB, bytea), Postgres stores it out-of-line in a companion TOAST table. Autovacuum runs on the TOAST table independently from the main heap, so it can accumulate dead chunks even when the parent table looks healthy by its own dead-tuple count. + +A table that appears fine by `vacuum-stats` or `bloat` alone can still have significant TOAST bloat that wastes disk space and slows reads. High `TOAST Dead %` values indicate that autovacuum is not keeping up with the TOAST table and a manual `VACUUM` may be needed. + +``` + TABLE │ TOTAL SIZE │ HEAP SIZE │ TOAST SIZE │ TOAST LIVE CHUNKS │ TOAST DEAD CHUNKS │ TOAST DEAD % │ LAST AUTOVACUUM │ LAST VACUUM +────────────────────────┼────────────┼───────────┼────────────┼───────────────────┼───────────────────┼──────────────┼────────────────────┼───────────── + public.documents │ 4200 MB │ 800 MB │ 3400 MB │ 250000 │ 18000 │ 6.7 │ 2024-03-01 04:12 │ + public.media_assets │ 890 MB │ 120 MB │ 770 MB │ 80000 │ 200 │ 0.2 │ 2024-03-02 01:45 │ + public.messages │ 340 MB │ 280 MB │ 60 MB │ 95000 │ 0 │ 0.0 │ 2024-03-02 03:10 │ +``` diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index f6d78560b9..92176f55bd 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -1,6 +1,6 @@ # `supabase inspect db ` -Single shared side-effect document for all 13 active `inspect db` subcommands and +Single shared side-effect document for all 14 active `inspect db` subcommands and their 12 deprecated aliases. Every subcommand has the same surface — it resolves a Postgres connection from `--db-url` / `--linked` / `--local`, runs one read-only `SELECT`, and renders the result as a Glamour ASCII table. They differ only in the @@ -46,7 +46,7 @@ no new config reads. ## Database Queries Each subcommand runs one read-only `SELECT` (the embedded Go `.sql`). The -5 schema-filtered queries take `$1` = the LIKE-escaped internal-schema list; +6 schema-filtered queries take `$1` = the LIKE-escaped internal-schema list; `db-stats` additionally takes `$2` = the database name. | Subcommand | SQL file | InternalSchemas param? | @@ -64,6 +64,7 @@ Each subcommand runs one read-only `SELECT` (the embedded Go `.sql`). The | long-running-queries | long_running_queries.sql | no | | role-stats | role_stats.sql | no | | traffic-profile | traffic_profile.sql | no | +| toast-sizes | toast-sizes.query.ts | yes (`$1`) | Deprecated aliases run an active subcommand's query: `cache-hit`→db-stats; `index-usage`/`total-index-size`/`index-sizes`/`unused-indexes`/`seq-scans`/`table-record-counts`→index-stats; diff --git a/apps/cli/src/commands/inspect/db/db.command.ts b/apps/cli/src/commands/inspect/db/db.command.ts index d68d765621..904ac2da86 100644 --- a/apps/cli/src/commands/inspect/db/db.command.ts +++ b/apps/cli/src/commands/inspect/db/db.command.ts @@ -22,6 +22,7 @@ import { inspectDbTableStatsCommand } from "./table-stats/table-stats.command.ts import { inspectDbTotalIndexSizeCommand } from "./total-index-size/total-index-size.command.ts"; import { inspectDbTotalTableSizesCommand } from "./total-table-sizes/total-table-sizes.command.ts"; import { inspectDbTrafficProfileCommand } from "./traffic-profile/traffic-profile.command.ts"; +import { inspectDbToastSizesCommand } from "./toast-sizes/toast-sizes.command.ts"; import { inspectDbUnusedIndexesCommand } from "./unused-indexes/unused-indexes.command.ts"; import { inspectDbVacuumStatsCommand } from "./vacuum-stats/vacuum-stats.command.ts"; @@ -42,6 +43,7 @@ export const inspectDbCommand = Command.make("db").pipe( inspectDbVacuumStatsCommand, inspectDbTableStatsCommand, inspectDbTrafficProfileCommand, + inspectDbToastSizesCommand, inspectDbCacheHitCommand, inspectDbIndexUsageCommand, inspectDbTotalIndexSizeCommand, diff --git a/apps/cli/src/commands/inspect/db/db.layers.ts b/apps/cli/src/commands/inspect/db/db.layers.ts index b5052ba660..833de344ab 100644 --- a/apps/cli/src/commands/inspect/db/db.layers.ts +++ b/apps/cli/src/commands/inspect/db/db.layers.ts @@ -10,7 +10,7 @@ import { inspectBaseLayer } from "../inspect.layers.ts"; * deprecated alias like `"cache-hit"`) and is appended to `["inspect", "db"]`. This * path is what `withCommandTelemetry` records as the PostHog * `cli_command_executed` `command` property: the inspect tree is a real 3-level - * hierarchy, so each of the 25 leaves emits a distinct command name. A shared + * hierarchy, so each of the 26 leaves emits a distinct command name. A shared * `["inspect", "db"]` path would collapse them all into one event, so each leaf must * pass its own name — and a deprecated alias records the alias the user typed, not * the backend command it delegates to. diff --git a/apps/cli/src/commands/inspect/db/inspect-db-command.ts b/apps/cli/src/commands/inspect/db/inspect-db-command.ts index 19c2c85100..a35002fd5e 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -7,7 +7,7 @@ import type { InspectConnectionFlags } from "./inspect-query.ts"; /** * The `inspect` persistent flag set, inherited by every `inspect db` subcommand. - * Shared verbatim across all 25 commands + * Shared verbatim across all 26 commands * so the flag names and descriptions live in one place. `Command.make` reads this * immutable descriptor without mutating it, so a single instance is safe to reuse. */ @@ -37,7 +37,7 @@ export const INSPECT_DB_FLAGS = { * Wraps an `inspect db` handler with the standard command-level pipeline: legacy * telemetry instrumentation (the Go-shape `cli_command_executed` event, with the * three connection flags) and the machine-format JSON error envelope. Shared by - * all 25 command files so the wiring is defined once. + * all 26 command files so the wiring is defined once. */ export function inspectDbCommandHandler( handler: (flags: InspectConnectionFlags) => Effect.Effect, diff --git a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts index f2808f17f6..1959fc6616 100644 --- a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts @@ -19,6 +19,7 @@ import { outliersSpec } from "./outliers/outliers.query.ts"; import { replicationSlotsSpec } from "./replication-slots/replication-slots.query.ts"; import { roleStatsSpec } from "./role-stats/role-stats.query.ts"; import { tableStatsSpec } from "./table-stats/table-stats.query.ts"; +import { toastSizesSpec } from "./toast-sizes/toast-sizes.query.ts"; import { trafficProfileSpec } from "./traffic-profile/traffic-profile.query.ts"; import { vacuumStatsSpec } from "./vacuum-stats/vacuum-stats.query.ts"; @@ -248,11 +249,27 @@ const cases: ReadonlyArray = [ }, expect: ["public", "100", "50", "12.0", "1:1 (Balanced)"], }, + { + spec: toastSizesSpec, + params: "schemas1", + row: { + name: "public.events", + total_size: "120 kB", + heap_size: "80 kB", + toast_size: "40 kB", + toast_live_chunks: 1200, + toast_dead_chunks: 80, + toast_dead_pct: "6.3", + last_autovacuum: "2025-01-15 03:00", + last_vacuum: "", + }, + expect: ["public.events", "120 kB", "40 kB", "1200", "80", "6.3", "2025-01-15 03:00"], + }, ]; describe("inspect db specs (per-subcommand correctness)", () => { - it("covers all 13 active subcommands", () => { - expect(cases).toHaveLength(13); + it("covers all 14 active subcommands", () => { + expect(cases).toHaveLength(14); }); for (const testCase of cases) { diff --git a/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.command.ts b/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.command.ts new file mode 100644 index 0000000000..6e0755b34a --- /dev/null +++ b/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.command.ts @@ -0,0 +1,15 @@ +import { Command } from "effect/unstable/cli"; +import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; +import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { inspectDbToastSizes } from "./toast-sizes.handler.ts"; + +export const inspectDbToastSizesCommand = Command.make("toast-sizes", INSPECT_DB_FLAGS).pipe( + Command.withDescription( + "Displays TOAST table sizes and dead chunk counts for every user table that has overflow storage. " + + "Autovacuum runs on TOAST relations independently, so dead chunks can accumulate even when the " + + "main heap looks healthy.", + ), + Command.withShortDescription("Show TOAST table sizes and dead chunk counts"), + Command.withHandler(inspectDbCommandHandler(inspectDbToastSizes)), + Command.provide(inspectDbRuntimeLayer("toast-sizes")), +); diff --git a/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.handler.ts b/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.handler.ts new file mode 100644 index 0000000000..68f14bfa2b --- /dev/null +++ b/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.handler.ts @@ -0,0 +1,4 @@ +import { makeInspectDbHandler } from "../inspect-query.ts"; +import { toastSizesSpec } from "./toast-sizes.query.ts"; + +export const inspectDbToastSizes = makeInspectDbHandler(toastSizesSpec, "inspect.db.toast-sizes"); diff --git a/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.query.ts b/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.query.ts new file mode 100644 index 0000000000..59ebd23bc8 --- /dev/null +++ b/apps/cli/src/commands/inspect/db/toast-sizes/toast-sizes.query.ts @@ -0,0 +1,59 @@ +import { + inspectFloat1, + inspectInt, + inspectPlainText, + inspectText, + type InspectQuerySpec, +} from "../inspect-query.ts"; +import { INTERNAL_SCHEMAS, likeEscapeSchema } from "../inspect-schemas.ts"; + +const SQL = ` +SELECT + FORMAT('%I.%I', n.nspname, main.relname) AS name, + pg_size_pretty(pg_total_relation_size(main.oid)) AS total_size, + pg_size_pretty(pg_relation_size(main.oid)) AS heap_size, + pg_size_pretty(pg_total_relation_size(main.reltoastrelid)) AS toast_size, + COALESCE(ts.n_live_tup, 0) AS toast_live_chunks, + COALESCE(ts.n_dead_tup, 0) AS toast_dead_chunks, + COALESCE( + round(100.0 * ts.n_dead_tup / nullif(ts.n_live_tup + ts.n_dead_tup, 0), 1), + 0.0 + ) AS toast_dead_pct, + COALESCE(to_char(ts.last_autovacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_autovacuum, + COALESCE(to_char(ts.last_vacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_vacuum +FROM pg_class main +JOIN pg_namespace n ON n.oid = main.relnamespace +LEFT JOIN pg_stat_all_tables ts ON ts.relid = main.reltoastrelid +WHERE main.relkind = 'r' + AND main.reltoastrelid <> 0 + AND pg_total_relation_size(main.reltoastrelid) > 0 + AND NOT n.nspname LIKE ANY($1) +ORDER BY pg_total_relation_size(main.reltoastrelid) DESC`; + +export const toastSizesSpec: InspectQuerySpec = { + name: "toast-sizes", + sql: SQL, + params: () => [likeEscapeSchema(INTERNAL_SCHEMAS)], + headers: [ + "Table", + "Total Size", + "Heap Size", + "TOAST Size", + "TOAST Live Chunks", + "TOAST Dead Chunks", + "TOAST Dead %", + "Last Autovacuum", + "Last Vacuum", + ], + project: (row) => [ + inspectText(row["name"]), + inspectText(row["total_size"]), + inspectText(row["heap_size"]), + inspectText(row["toast_size"]), + inspectInt(row["toast_live_chunks"]), + inspectInt(row["toast_dead_chunks"]), + inspectFloat1(row["toast_dead_pct"]), + inspectPlainText(row["last_autovacuum"]), + inspectPlainText(row["last_vacuum"]), + ], +}; diff --git a/apps/cli/src/commands/inspect/report/report.integration.test.ts b/apps/cli/src/commands/inspect/report/report.integration.test.ts index f6af68d1d2..edef5f9566 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -190,12 +190,12 @@ describe("inspect report", () => { return Effect.gen(function* () { yield* inspectReport(flags({ outputDir: base })); const { dir, files } = dateFolderContents(base); - expect(files.length).toBe(14); + expect(files.length).toBe(15); expect(files).toContain("db_stats.csv"); expect(files).toContain("unused_indexes.csv"); expect(files).not.toContain("db-stats.csv"); // Every query was copied with both placeholders substituted. - expect(connection.copiedSql.length).toBe(14); + expect(connection.copiedSql.length).toBe(15); expect( connection.copiedSql.every( (s) => s.startsWith("COPY (") && s.endsWith("TO STDOUT WITH CSV HEADER"), @@ -466,10 +466,10 @@ describe("inspect report", () => { const data = ( success as { data?: { files?: Array; outputDir?: string; rules?: Array } } ).data; - expect(data?.files?.length).toBe(14); + expect(data?.files?.length).toBe(15); expect(typeof data?.outputDir).toBe("string"); expect(data?.rules?.length).toBe(13); - expect(dateFolderContents(base).files.length).toBe(14); + expect(dateFolderContents(base).files.length).toBe(15); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -556,7 +556,7 @@ describe("inspect report", () => { return Effect.gen(function* () { yield* inspectReport(flags({ outputDir: "reports" })); const { files } = dateFolderContents(join(cwd, "reports")); - expect(files.length).toBe(14); + expect(files.length).toBe(15); }).pipe(Effect.provide(layer)); }); @@ -567,7 +567,7 @@ describe("inspect report", () => { return Effect.gen(function* () { yield* inspectReport(flags({ outputDir: base })); // Written under the absolute base, not under the CWD. - expect(dateFolderContents(base).files.length).toBe(14); + expect(dateFolderContents(base).files.length).toBe(15); expect(readdirSync(cwd).length).toBe(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/inspect/report/report.queries.ts b/apps/cli/src/commands/inspect/report/report.queries.ts index f108351635..938f3a4130 100644 --- a/apps/cli/src/commands/inspect/report/report.queries.ts +++ b/apps/cli/src/commands/inspect/report/report.queries.ts @@ -10,6 +10,7 @@ import { outliersSpec } from "../db/outliers/outliers.query.ts"; import { replicationSlotsSpec } from "../db/replication-slots/replication-slots.query.ts"; import { roleStatsSpec } from "../db/role-stats/role-stats.query.ts"; import { tableStatsSpec } from "../db/table-stats/table-stats.query.ts"; +import { toastSizesSpec } from "../db/toast-sizes/toast-sizes.query.ts"; import { trafficProfileSpec } from "../db/traffic-profile/traffic-profile.query.ts"; import { vacuumStatsSpec } from "../db/vacuum-stats/vacuum-stats.query.ts"; @@ -50,7 +51,7 @@ export interface ReportQuery { } /** - * The 14 report queries. Reuses the 13 `inspect db` specs' `.sql` verbatim + * The 15 report queries. Reuses the 14 `inspect db` specs' `.sql` verbatim * (byte-identical COPY input → byte-identical CSVs) plus the standalone * `unused_indexes` query. */ @@ -68,6 +69,7 @@ export const REPORT_QUERIES: ReadonlyArray = [ { fileName: "table_stats", sql: tableStatsSpec.sql }, { fileName: "traffic_profile", sql: trafficProfileSpec.sql }, { fileName: "unused_indexes", sql: UNUSED_INDEXES_REPORT_SQL }, + { fileName: "toast_sizes", sql: toastSizesSpec.sql }, { fileName: "vacuum_stats", sql: vacuumStatsSpec.sql }, ]; diff --git a/apps/cli/src/commands/inspect/report/report.queries.unit.test.ts b/apps/cli/src/commands/inspect/report/report.queries.unit.test.ts index 8b4cf6e723..765b0c0b7c 100644 --- a/apps/cli/src/commands/inspect/report/report.queries.unit.test.ts +++ b/apps/cli/src/commands/inspect/report/report.queries.unit.test.ts @@ -38,7 +38,7 @@ describe("reportIgnoreSchemas", () => { }); describe("REPORT_QUERIES", () => { - it("has the 14 underscore CSV basenames Go embeds", () => { + it("has the 15 underscore CSV basenames Go embeds", () => { expect(REPORT_QUERIES.map((q) => q.fileName)).toEqual([ "bloat", "blocking", @@ -53,6 +53,7 @@ describe("REPORT_QUERIES", () => { "table_stats", "traffic_profile", "unused_indexes", + "toast_sizes", "vacuum_stats", ]); });