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"]), + ], +}; 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..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 }, ]; /** 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", ]); });