From 6a43af54df00523b22d312451168c7852af56b1c Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 8 Sep 2026 22:01:24 +0200 Subject: [PATCH 1/4] feat(cli): add inspect db xid-age command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `supabase inspect db xid-age`, which lists all user tables sorted by their transaction ID (XID) age from oldest to newest. PostgreSQL wraps around at ~2 billion transactions. As a table's XID age approaches that limit, an emergency autovacuum freeze is forced — an operation that can make the database temporarily unavailable. Tables older than 1.5 billion transactions (fewer than 500 million remaining) should be treated as urgent and vacuumed manually or via tuned autovacuum. The query reads pg_class.relfrozenxid and pg_namespace, filters out internal Supabase schemas via the shared INTERNAL_SCHEMAS parameter, and returns the table name, current XID age, and transactions remaining. Co-Authored-By: Claude Sonnet 4.6 --- apps/cli/docs/supabase/inspect/db-xid-age.md | 13 ++++++++++ .../src/commands/inspect/db/SIDE_EFFECTS.md | 5 ++-- .../cli/src/commands/inspect/db/db.command.ts | 2 ++ .../db/inspect-specs.integration.test.ts | 15 +++++++++-- .../inspect/db/xid-age/xid-age.command.ts | 16 ++++++++++++ .../inspect/db/xid-age/xid-age.handler.ts | 4 +++ .../inspect/db/xid-age/xid-age.query.ts | 25 +++++++++++++++++++ 7 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 apps/cli/docs/supabase/inspect/db-xid-age.md create mode 100644 apps/cli/src/commands/inspect/db/xid-age/xid-age.command.ts create mode 100644 apps/cli/src/commands/inspect/db/xid-age/xid-age.handler.ts create mode 100644 apps/cli/src/commands/inspect/db/xid-age/xid-age.query.ts diff --git a/apps/cli/docs/supabase/inspect/db-xid-age.md b/apps/cli/docs/supabase/inspect/db-xid-age.md new file mode 100644 index 0000000000..038a931260 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-xid-age.md @@ -0,0 +1,13 @@ +# db-xid-age + +This command lists all user tables sorted by their transaction ID (XID) age, from oldest to newest. PostgreSQL wraps around at approximately 2 billion transactions. As a table's XID age approaches that limit, PostgreSQL is forced to perform an emergency autovacuum freeze — an operation that can make the database temporarily unavailable and cannot be deferred. + +Tables with an XID age above 1.5 billion transactions (`transactions_remaining` below 500 million) should be treated as urgent: manual `VACUUM FREEZE` or a tuned autovacuum run is needed. Regular monitoring of this view helps prevent the wraparound event before it becomes an emergency. + +``` + TABLE │ XID AGE │ TRANSACTIONS REMAINING +─────────────────────────┼───────────┼──────────────────────── + public.events │ 800000000 │ 1200000000 + public.users │ 500000000 │ 1500000000 + public.sessions │ 120000000 │ 1880000000 +``` diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index f6d78560b9..1451cdc229 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? | @@ -56,6 +56,7 @@ Each subcommand runs one read-only `SELECT` (the embedded Go `.sql`). The | bloat | bloat.sql | yes (`$1`) | | vacuum-stats | vacuum_stats.sql | yes (`$1`) | | table-stats | table_stats.sql | yes (`$1`) | +| xid-age | xid-age.query.ts | yes (`$1`) | | replication-slots | replication_slots.sql | no | | locks | locks.sql | no | | blocking | blocking.sql | no | diff --git a/apps/cli/src/commands/inspect/db/db.command.ts b/apps/cli/src/commands/inspect/db/db.command.ts index d68d765621..c46db68dc8 100644 --- a/apps/cli/src/commands/inspect/db/db.command.ts +++ b/apps/cli/src/commands/inspect/db/db.command.ts @@ -24,6 +24,7 @@ import { inspectDbTotalTableSizesCommand } from "./total-table-sizes/total-table import { inspectDbTrafficProfileCommand } from "./traffic-profile/traffic-profile.command.ts"; import { inspectDbUnusedIndexesCommand } from "./unused-indexes/unused-indexes.command.ts"; import { inspectDbVacuumStatsCommand } from "./vacuum-stats/vacuum-stats.command.ts"; +import { inspectDbXidAgeCommand } from "./xid-age/xid-age.command.ts"; export const inspectDbCommand = Command.make("db").pipe( Command.withDescription("Tools to inspect your Supabase database."), @@ -54,5 +55,6 @@ export const inspectDbCommand = Command.make("db").pipe( inspectDbSeqScansCommand, inspectDbRoleConfigsCommand, inspectDbRoleConnectionsCommand, + inspectDbXidAgeCommand, ]), ); 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..a4a9c8e243 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 @@ -21,6 +21,7 @@ import { roleStatsSpec } from "./role-stats/role-stats.query.ts"; import { tableStatsSpec } from "./table-stats/table-stats.query.ts"; import { trafficProfileSpec } from "./traffic-profile/traffic-profile.query.ts"; import { vacuumStatsSpec } from "./vacuum-stats/vacuum-stats.query.ts"; +import { xidAgeSpec } from "./xid-age/xid-age.query.ts"; const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", @@ -235,6 +236,16 @@ const cases: ReadonlyArray = [ }, expect: ["public.t", "8 kB", "10 kB", "1000"], }, + { + spec: xidAgeSpec, + params: "schemas1", + row: { + name: "public.users", + xid_age: 500000000, + transactions_remaining: 1500000000, + }, + expect: ["public.users", "500000000", "1500000000"], + }, { spec: trafficProfileSpec, params: "none", @@ -251,8 +262,8 @@ const cases: ReadonlyArray = [ ]; 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/xid-age/xid-age.command.ts b/apps/cli/src/commands/inspect/db/xid-age/xid-age.command.ts new file mode 100644 index 0000000000..d6c76a453a --- /dev/null +++ b/apps/cli/src/commands/inspect/db/xid-age/xid-age.command.ts @@ -0,0 +1,16 @@ +import { Command } from "effect/unstable/cli"; +import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; +import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { inspectDbXidAge } from "./xid-age.handler.ts"; + +export const inspectDbXidAgeCommand = Command.make("xid-age", INSPECT_DB_FLAGS).pipe( + Command.withDescription( + "Lists user tables with their transaction ID (XID) age, ordered from oldest to newest. " + + "PostgreSQL wraps around at ~2 billion transactions; as a table's age approaches that limit " + + "an emergency autovacuum freeze is forced, which can make the database temporarily unavailable. " + + "Tables older than 1.5 billion transactions should be treated as urgent.", + ), + Command.withShortDescription("Show XID age for all tables"), + Command.withHandler(inspectDbCommandHandler(inspectDbXidAge)), + Command.provide(inspectDbRuntimeLayer("xid-age")), +); diff --git a/apps/cli/src/commands/inspect/db/xid-age/xid-age.handler.ts b/apps/cli/src/commands/inspect/db/xid-age/xid-age.handler.ts new file mode 100644 index 0000000000..9aba4aa51a --- /dev/null +++ b/apps/cli/src/commands/inspect/db/xid-age/xid-age.handler.ts @@ -0,0 +1,4 @@ +import { makeInspectDbHandler } from "../inspect-query.ts"; +import { xidAgeSpec } from "./xid-age.query.ts"; + +export const inspectDbXidAge = makeInspectDbHandler(xidAgeSpec, "inspect.db.xid-age"); diff --git a/apps/cli/src/commands/inspect/db/xid-age/xid-age.query.ts b/apps/cli/src/commands/inspect/db/xid-age/xid-age.query.ts new file mode 100644 index 0000000000..990f83168a --- /dev/null +++ b/apps/cli/src/commands/inspect/db/xid-age/xid-age.query.ts @@ -0,0 +1,25 @@ +import { inspectInt, inspectText, type InspectQuerySpec } from "../inspect-query.ts"; +import { INTERNAL_SCHEMAS, likeEscapeSchema } from "../inspect-schemas.ts"; + +const SQL = ` +SELECT + FORMAT('%I.%I', n.nspname, c.relname) AS name, + age(c.relfrozenxid) AS xid_age, + 2000000000 - age(c.relfrozenxid) AS transactions_remaining +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.relkind = 'r' + AND NOT n.nspname LIKE ANY($1) +ORDER BY age(c.relfrozenxid) DESC`; + +export const xidAgeSpec: InspectQuerySpec = { + name: "xid-age", + sql: SQL, + params: () => [likeEscapeSchema(INTERNAL_SCHEMAS)], + headers: ["Table", "XID Age", "Transactions Remaining"], + project: (row) => [ + inspectText(row["name"]), + inspectInt(row["xid_age"]), + inspectInt(row["transactions_remaining"]), + ], +}; From 6704fa3fa6cdb7bfb56019608a092dd7fb6595ea Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 8 Sep 2026 22:24:14 +0200 Subject: [PATCH 2/4] feat(cli): include xid-age in inspect report Adds xid_age to REPORT_QUERIES so `supabase inspect report --db-url` includes the XID age data in its CSV output. Co-Authored-By: Claude Sonnet 4.6 --- apps/cli/src/commands/inspect/report/report.queries.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands/inspect/report/report.queries.ts b/apps/cli/src/commands/inspect/report/report.queries.ts index f108351635..0e76ead916 100644 --- a/apps/cli/src/commands/inspect/report/report.queries.ts +++ b/apps/cli/src/commands/inspect/report/report.queries.ts @@ -12,6 +12,7 @@ import { roleStatsSpec } from "../db/role-stats/role-stats.query.ts"; import { tableStatsSpec } from "../db/table-stats/table-stats.query.ts"; import { trafficProfileSpec } from "../db/traffic-profile/traffic-profile.query.ts"; import { vacuumStatsSpec } from "../db/vacuum-stats/vacuum-stats.query.ts"; +import { xidAgeSpec } from "../db/xid-age/xid-age.query.ts"; /** * The `unused_indexes` query. The `inspect db` @@ -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. */ @@ -69,6 +70,7 @@ export const REPORT_QUERIES: ReadonlyArray = [ { fileName: "traffic_profile", sql: trafficProfileSpec.sql }, { fileName: "unused_indexes", sql: UNUSED_INDEXES_REPORT_SQL }, { fileName: "vacuum_stats", sql: vacuumStatsSpec.sql }, + { fileName: "xid_age", sql: xidAgeSpec.sql }, ]; /** From 5717ec0d1aedc2dd7c4bb45311deedf4101501ac Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 8 Sep 2026 22:47:35 +0200 Subject: [PATCH 3/4] test(cli): update report.queries test for xid-age entry Co-Authored-By: Claude Sonnet 4.6 --- .../src/commands/inspect/report/report.queries.unit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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..3b6ccbbe94 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", @@ -54,6 +54,7 @@ describe("REPORT_QUERIES", () => { "traffic_profile", "unused_indexes", "vacuum_stats", + "xid_age", ]); }); From 1aa9d9ca5d1b57550f6ccc0223871673df250c0c Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 8 Sep 2026 23:39:35 +0200 Subject: [PATCH 4/4] test(cli): update report integration test counts for xid-age entry Co-Authored-By: Claude Sonnet 4.6 --- .../inspect/report/report.integration.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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)); });