Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/cli/docs/supabase/inspect/db-xid-age.md
Original file line number Diff line number Diff line change
@@ -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
```
5 changes: 3 additions & 2 deletions apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `supabase inspect db <subcommand>`

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
Expand Down Expand Up @@ -46,7 +46,7 @@ no new config reads.
## Database Queries

Each subcommand runs one read-only `SELECT` (the embedded Go `<name>.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? |
Expand All @@ -56,6 +56,7 @@ Each subcommand runs one read-only `SELECT` (the embedded Go `<name>.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 |
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/commands/inspect/db/db.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -54,5 +55,6 @@ export const inspectDbCommand = Command.make("db").pipe(
inspectDbSeqScansCommand,
inspectDbRoleConfigsCommand,
inspectDbRoleConnectionsCommand,
inspectDbXidAgeCommand,
]),
);
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -235,6 +236,16 @@ const cases: ReadonlyArray<Case> = [
},
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",
Expand All @@ -251,8 +262,8 @@ const cases: ReadonlyArray<Case> = [
];

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) {
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/commands/inspect/db/xid-age/xid-age.command.ts
Original file line number Diff line number Diff line change
@@ -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")),
);
4 changes: 4 additions & 0 deletions apps/cli/src/commands/inspect/db/xid-age/xid-age.handler.ts
Original file line number Diff line number Diff line change
@@ -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");
25 changes: 25 additions & 0 deletions apps/cli/src/commands/inspect/db/xid-age/xid-age.query.ts
Original file line number Diff line number Diff line change
@@ -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"]),
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -466,10 +466,10 @@ describe("inspect report", () => {
const data = (
success as { data?: { files?: Array<unknown>; outputDir?: string; rules?: Array<unknown> } }
).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));
});
Expand Down Expand Up @@ -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));
});

Expand All @@ -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));
});
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/commands/inspect/report/report.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
*/
Expand All @@ -69,6 +70,7 @@ export const REPORT_QUERIES: ReadonlyArray<ReportQuery> = [
{ 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 },
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -54,6 +54,7 @@ describe("REPORT_QUERIES", () => {
"traffic_profile",
"unused_indexes",
"vacuum_stats",
"xid_age",
]);
});

Expand Down
Loading