diff --git a/server/src/schema-change/catalog.ts b/server/src/schema-change/catalog.ts index 58bac9e..9ebbd0d 100644 --- a/server/src/schema-change/catalog.ts +++ b/server/src/schema-change/catalog.ts @@ -1,3 +1,4 @@ +import { unknownRows } from "./row-estimate.js"; import { runReadOnly, type Queryable } from "../services/pg-read.js"; import { USER_SCHEMAS } from "../services/pg-relations.js"; @@ -55,9 +56,9 @@ const TABLES_SQL = ` SELECT n.nspname AS schema, c.relname AS name, c.relkind = 'p' AS partitioned, CASE WHEN c.relkind = 'p' THEN (SELECT count(*) FROM pg_partition_tree(c.oid) t WHERE t.level > 0) ELSE 0 END AS partition_count, CASE WHEN c.relkind = 'p' - THEN (SELECT CASE WHEN bool_or(l.reltuples < 0) THEN NULL ELSE sum(l.reltuples)::int8::text END + THEN (SELECT CASE WHEN bool_or(${unknownRows("l")}) THEN NULL ELSE sum(l.reltuples)::int8::text END FROM pg_partition_tree(c.oid) t JOIN pg_class l ON l.oid = t.relid WHERE t.isleaf) - WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples::int8::text END AS estimated_rows, + WHEN ${unknownRows("c")} THEN NULL ELSE c.reltuples::int8::text END AS estimated_rows, CASE WHEN c.relkind = 'p' THEN (SELECT COALESCE(sum(pg_total_relation_size(t.relid)), 0) FROM pg_partition_tree(c.oid) t WHERE t.isleaf)::text ELSE pg_total_relation_size(c.oid)::text END AS total_bytes, diff --git a/server/src/schema-change/facts.ts b/server/src/schema-change/facts.ts index bef2f19..01dc442 100644 --- a/server/src/schema-change/facts.ts +++ b/server/src/schema-change/facts.ts @@ -1,3 +1,4 @@ +import { unknownRows } from "./row-estimate.js"; import { runReadOnly, type Queryable } from "../services/pg-read.js"; import { quoteLiteral } from "../services/pg-sql.js"; @@ -182,8 +183,8 @@ export async function loadFacts(client: Queryable, qualified: string[]): Promise `${cte} SELECT t.oid::int8::text AS oid, t.schema, t.name, t.relkind = 'p' AS partitioned, CASE WHEN t.relkind = 'p' - THEN (SELECT CASE WHEN bool_or(l.reltuples < 0) THEN NULL ELSE sum(l.reltuples)::int8::text END FROM pg_partition_tree(t.oid) p JOIN pg_class l ON l.oid = p.relid WHERE p.isleaf) - ELSE (SELECT CASE WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples::int8::text END FROM pg_class c WHERE c.oid = t.oid) END AS estimated_rows, + THEN (SELECT CASE WHEN bool_or(${unknownRows("l")}) THEN NULL ELSE sum(l.reltuples)::int8::text END FROM pg_partition_tree(t.oid) p JOIN pg_class l ON l.oid = p.relid WHERE p.isleaf) + ELSE (SELECT CASE WHEN ${unknownRows("c")} THEN NULL ELSE c.reltuples::int8::text END FROM pg_class c WHERE c.oid = t.oid) END AS estimated_rows, CASE WHEN t.relkind = 'p' THEN (SELECT COALESCE(sum(pg_relation_size(p.relid)), 0) FROM pg_partition_tree(t.oid) p WHERE p.isleaf)::text ELSE pg_relation_size(t.oid)::text END AS heap_bytes, CASE WHEN t.relkind = 'p' THEN (SELECT COALESCE(sum(pg_indexes_size(p.relid)), 0) FROM pg_partition_tree(t.oid) p WHERE p.isleaf)::text ELSE pg_indexes_size(t.oid)::text END AS index_bytes, CASE WHEN t.relkind = 'p' THEN (SELECT COALESCE(sum(pg_total_relation_size(p.relid)), 0) FROM pg_partition_tree(t.oid) p WHERE p.isleaf)::text ELSE pg_total_relation_size(t.oid)::text END AS total_bytes, diff --git a/server/src/schema-change/health.ts b/server/src/schema-change/health.ts index 2a34115..37caefa 100644 --- a/server/src/schema-change/health.ts +++ b/server/src/schema-change/health.ts @@ -1,3 +1,4 @@ +import { unknownRows } from "./row-estimate.js"; import { runReadOnly, type Queryable } from "../services/pg-read.js"; import { USER_SCHEMAS } from "../services/pg-relations.js"; @@ -161,7 +162,7 @@ const TABLES_SQL = ` SELECT n.nspname AS schema, c.relname AS name, c.relkind = 'p' AS partitioned, (SELECT count(*) FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped)::int AS columns, EXISTS (SELECT 1 FROM pg_index ix WHERE ix.indrelid = c.oid AND ix.indisprimary) AS has_pk, - CASE WHEN c.relkind = 'p' THEN NULL WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples::int8::text END AS estimated_rows, + CASE WHEN c.relkind = 'p' THEN NULL WHEN ${unknownRows("c")} THEN NULL ELSE c.reltuples::int8::text END AS estimated_rows, CASE WHEN c.relkind = 'p' THEN (SELECT COALESCE(sum(pg_total_relation_size(p.relid)), 0) FROM pg_partition_tree(c.oid) p WHERE p.isleaf)::text ELSE pg_total_relation_size(c.oid)::text END AS total_bytes, s.n_live_tup::text AS live, s.n_dead_tup::text AS dead, s.n_mod_since_analyze::text AS mods, GREATEST(s.last_analyze, s.last_autoanalyze) AS last_analyze, (s.relid IS NOT NULL) AS has_stats_row diff --git a/server/src/schema-change/row-estimate.test.ts b/server/src/schema-change/row-estimate.test.ts new file mode 100644 index 0000000..697fdee --- /dev/null +++ b/server/src/schema-change/row-estimate.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { unknownRows } from "./row-estimate.js"; + +describe("unknownRows", () => { + it("reads -1 as unknown on every version, and 0 on a table with data only before PostgreSQL 14", () => { + const sql = unknownRows("c"); + expect(sql).toContain("c.reltuples < 0"); + expect(sql).toContain("c.reltuples = 0 AND current_setting('server_version_num')::int < 140000 AND pg_relation_size(c.oid) > 0"); + }); + + it("uses the alias it is given", () => { + expect(unknownRows("leaf")).toContain("leaf.reltuples"); + expect(unknownRows("leaf")).toContain("pg_relation_size(leaf.oid)"); + expect(unknownRows("leaf")).not.toMatch(/\bc\./); + }); +}); diff --git a/server/src/schema-change/row-estimate.ts b/server/src/schema-change/row-estimate.ts new file mode 100644 index 0000000..6249a15 --- /dev/null +++ b/server/src/schema-change/row-estimate.ts @@ -0,0 +1,8 @@ +/** + * SQL for "this table's row count is not known". PostgreSQL 14 and later store -1 in pg_class.reltuples for a table that has never been + * vacuumed or analyzed. Before that the same table reports 0, which cannot be told from an empty table by reltuples alone: a table that + * holds data (its file is not empty) but reports 0 has simply not been analyzed. Reading that 0 as "empty" would let the compiler take + * the shortcuts that are only safe for empty tables on a table of any size. + */ +export const unknownRows = (alias: string): string => + `(${alias}.reltuples < 0 OR (${alias}.reltuples = 0 AND current_setting('server_version_num')::int < 140000 AND pg_relation_size(${alias}.oid) > 0))`;