Files
supabase/packages/pg-meta/test/sql/studio/rows-count.test.ts
Andrew Valleteau 6c6a721cb7 fix(pg-meta): scope remaining O(catalog) introspection queries behind pgMetaScopedIntrospection (#48148)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix (performance), follow-up to #47894, plus regression-guard tests.

## What is the current behavior?

#47894 scoped the Table Editor and entity-definition introspection
queries, but four more `@supabase/pg-meta` query families still do
O(catalog) work per request. On a production project with a very large
catalog (hundreds of schemas, ~465K `pg_constraint` rows) they run 5 to
55 seconds each, trip the 58s `statement_timeout`, and spill sorts to
temp files. During a recent "DB CPU > 85%" incident on such a project,
24 of 27 active backends were running these queries concurrently.

1. **`tables.retrieve()` (single-table lookup by name+schema or id)**:
the `tables`/`columns` CTEs scan the whole catalog (`pg_class`,
`pg_constraint`, `pg_index`, all of `pg_attribute`, per-table sizes) and
the one-table predicate is applied only on the outer select. Same bug
class #47894 fixed for the OID-based table editor query; this sibling
path never got the treatment. It accounted for 94 of the 96
statement-timeout cancellations in the incident.
2. **Types listing**: the `t_enums` and `t_attributes` subqueries
aggregate the entire `pg_enum` and every composite relation before the
wrapper's schema filter applies.
3. **Table privileges**: `aclexplode` + double `pg_roles` join + GROUP
BY over every relation in the database; schema/OID filters applied only
after aggregation, in both `list()` and `retrieve()`.
4. **Row counts**: `getTableRowsCountSql` treats `reltuples = -1`
(never-analyzed table) as "small table, run exact count(*)". A freshly
bulk-loaded multi-million-row table times out on every Table Editor
pagination render.

Two Studio-side amplifiers turned one slow query into a sustained load
storm:

- `useTableQuery` (behind `tables.retrieve()`) mounts once per visible
foreign-key grid cell via `ForeignKeyFormatter`, so a single Table
Editor view fires ~20 concurrent copies against the FK target table. A
timed-out query caches nothing, and TanStack retries errored no-data
queries on every observer mount by default, so scrolling kept re-issuing
the 58s scan.
- `useTableApiAccessQuery` fetched table privileges for the entire
database and filtered down to one schema client-side.

## What is the new behavior?

**pg-meta (all behind the existing `pgMetaScopedIntrospection` flag,
same rollout mechanism as #47894; `scoped: false` keeps serving the
current SQL):**

- `tables.retrieve()`: the identifier is resolved to a scalar
`targetOid` init-plan and pushed into the base scan, primary-key,
relationships (both FK directions kept: `conrelid` or `confrelid`) and
columns CTEs. A materialized `target` CTE was deliberately avoided: it
acts as an optimization barrier and forces the very seq scans being
removed.
- Types: filter `pg_type`/`pg_namespace` first, then compute
enums/attributes per surviving row via correlated index-scan subqueries
(`pg_enum(enumtypid, enumsortorder)`, `pg_attribute(attrelid, attnum)`).
- Table privileges: schema/OID predicates injected into the base WHERE
before `aclexplode`/GROUP BY for `list()` and `retrieve()`.
- Row counts: `reltuples = -1` is treated as "unknown" and gated on
physical size via `pg_relation_size` (a cheap stat call; `relpages` is
equally stale pre-vacuum). At or below `THRESHOLD_ESTIMATE_BYTES`
(~10MB, derived from `THRESHOLD_COUNT` at a conservative ~200 bytes/row)
the exact count runs as before: fast by construction, and it avoids
bogus estimates since Postgres floors never-vacuumed heaps at 10 pages,
so an empty table would otherwise report ~2K estimated rows. Above the
gate the count routes through the EXPLAIN-based
`pg_temp.count_estimate`, or returns `-1`/`is_estimate = true` in
read-only contexts where the temp function cannot be created. The scoped
branch embeds the estimated select via `literal()` instead of legacy's
apostrophe-only escaping, so it stays correct under
`standard_conforming_strings = off`. `enforceExactCount` unchanged.

**Studio:**

- The flag decision is contained in the data layer instead of
prop-drilled: a small imperative accessor
(`apps/studio/data/scoped-introspection.ts`) is hydrated from `useFlag`
via a one-line `useSyncScopedIntrospection()` call in `DefaultLayout`,
and the query functions read it internally when building the pg-meta
SQL. `DefaultLayout` is shared by both the Next and TanStack router
trees; hydrating from `_app.tsx` alone would leave TanStack-served pages
permanently unscoped since `routes/__root.tsx` mounts its own flag
provider. Cold loads cannot race the flag: the query functions await a
readiness promise that resolves only after the sync hook has hydrated
the accessor with a loaded flag store (immediately on self-hosted where
flags are disabled; a 5s safety net armed lazily on the first `ready()`
call - not at module import, which would let the timer expire before a
project page ever mounts - bounds genuine ConfigCat outages). No
component threading, no query-key changes (remaining tradeoff,
documented in the module: a mid-session flag flip can serve stale-keyed
caches until refetch, fine for a session-stable rollout flag). #47894's
existing threading is left as-is and gets deleted together with the flag
in the cleanup PR. Also fixes the previously-missing `scoped`
pass-through in `getTableRowsCount`.
- Flag-independent hardening: `useTableQuery` now sets `retryOnMount:
false`, `refetchOnWindowFocus: false` and `staleTime: 5min`. Errored
(timed-out) queries no longer refire on every grid cell remount, while
stale successful metadata still revalidates on mount after `staleTime`.
- `useTableApiAccessQuery` now passes `includedSchemas: [schemaName]`;
the client-side filter stays as a safety net.
- The rows-count query is `enabled`-gated on the permission check
settling, so a transiently-false `canSQLAdminWrite` can no longer cache
a read-only `-1` count for a writable user (read replicas short-circuit
synchronously as before).

**Regression guards (extending the #47894 infrastructure):**

- Execution-based scoped-vs-legacy equivalence tests for all four
queries: both variants run against the test database and are compared
with raw `toEqual` - no normalization, ids included (types across 6
option combos, privileges incl. multi-grantee + PUBLIC,
`tables.retrieve` for both identifier branches, row counts for every
case where the two paths must agree). Two documented exceptions where
only the LEGACY side is sorted, because a de-normalized diagnostic run
proved legacy emits genuinely plan-dependent order there (an
adversarial-FK fixture shows it is neither oid, name, nor creation
order): the `types.list` outer row order (scoped adds `order by t.oid`;
legacy has no ORDER BY) and the `tables.retrieve` relationships array
(scoped orders by `constraint_name` + column-name tie-breakers - a
composite two-column FK expands to 4 entries sharing one
constraint_name). Everything else (privileges via `aclexplode` over the
same relacl, columns by `ordinal_position`, primary keys by `indkey`
order, enums by `enumsortorder`) is byte-identical between the two paths
with no test-side help. The one intentional value divergence,
never-analyzed tables above the size gate where legacy's exact count is
the timeout bug itself, is asserted explicitly as a divergence.
- Plan-guard budgets for every scoped query against the stress catalog
(extended with 200 enums + 200 composite types). Residual seq scans are
justified in-budget: `pg_constraint` max 2 (no index on `confrelid`),
`pg_attrdef` max 1, `pg_authid` max 2 (scales with role count, not
schema count).
- Legacy templates carry a FROZEN do-not-edit marker (they must keep
matching production behavior until the flag cleanup deletes them); the
ordinary test suite runs against the legacy default, so behavioral drift
there fails regular tests.

### Validation

- pg-meta: typecheck clean; the affected suites (types,
table-privileges, tables, rows-count, catalog-plan-guard) pass in full.
- Cross-version: the scoped-vs-legacy equivalence and rows-count
behavioral suites were validated on PostgreSQL 14, 15, and 17 (identical
results on all three). Two version-marginal planner choices surfaced on
17 (`pg_type` / `pg_class` seq scan vs full-index bitmap for per-schema
listings, both structurally unavoidable without an index leading on the
namespace column) and are carried as justified plan-guard budget
entries. A full 468-test suite run sequentially: 452 passed, 16 failures
verified environmental (13 timeouts in an untouched file that passes
27/27 in isolation on the marathon-run cluster, 3 cluster-global role
collisions from container reuse).
- Studio: `pnpm --filter studio typecheck` clean; 39/39 tests across the
touched data hooks; eslint clean on touched files.

### Rollout

Same staged ConfigCat rollout as #47894 via `pgMetaScopedIntrospection`
(user-email targeting first, then percentage, then 100%). The
`useTableQuery` hardening and the API-access schema scoping ship
unflagged (behavior-safe). Gate before percentage rollout: functionally
verify the FK popover/selector UX under the new
`staleTime`/`retryOnMount` settings (a just-edited FK target must not
look stale anywhere Studio does not already refetch on save). Once fully
rolled out, the legacy templates and flag get deleted together with
#47894's in one cleanup PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:07:08 +02:00

370 lines
16 KiB
TypeScript

import { afterAll, expect, test } from 'vitest'
import { getTableRowsCountSql } from '../../../src'
import type { Filter } from '../../../src/query'
import { cleanupRoot, createTestDatabase } from '../../db/utils'
type Db = Awaited<ReturnType<typeof createTestDatabase>>
type CountRow = { count: number; is_estimate: boolean }
type CountArgs = Parameters<typeof getTableRowsCountSql>[0]
afterAll(async () => {
await cleanupRoot()
})
const withTestDatabase = (name: string, fn: (db: Db) => Promise<void>) => {
test(name, async () => {
const db = await createTestDatabase()
try {
await fn(db)
} finally {
await db.cleanup()
}
})
}
const tableOf = async (db: Db, qualified: string, name: string, schema: string) => {
const [{ id }] = await db.executeQuery<{ id: number }[]>(
`select '${qualified}'::regclass::oid::int8 as id;`
)
return { id: Number(id), name, schema }
}
const reltuplesOf = async (db: Db, qualified: string) => {
const [{ reltuples }] = await db.executeQuery<{ reltuples: number }[]>(
`select reltuples::int8 as reltuples from pg_class where oid = '${qualified}'::regclass;`
)
return Number(reltuples)
}
const runCount = async (db: Db, args: CountArgs) => {
const [row] = await db.executeQuery<CountRow[]>(getTableRowsCountSql(args))
return { count: Number(row.count), is_estimate: row.is_estimate }
}
// Execute BOTH the scoped and legacy renderings of the same args against the DB
// and assert identical results -- the equivalence contract for every case where
// the two paths must agree (the ONLY intentional divergence is a never-analyzed
// table whose heap exceeds the byte gate; that asymmetry is the fix and is
// asserted separately below).
const assertScopedEqualsLegacy = async (db: Db, base: Omit<CountArgs, 'scoped'>) => {
const legacy = await runCount(db, { ...base, scoped: false })
const scoped = await runCount(db, { ...base, scoped: true })
expect(scoped, 'scoped must equal legacy for this case').toEqual(legacy)
return scoped
}
// A never-analyzed table has pg_class.reltuples = -1 -- true for a brand-new
// EMPTY table, a small one, AND a freshly bulk-loaded huge one. autovacuum is
// disabled on every fixture so reltuples cannot flip mid-test.
withTestDatabase(
'scoped: empty never-analyzed table -> exact count 0, is_estimate=false (both modes)',
async (db) => {
await db.executeQuery(
`create table public.empty_t (id int primary key) with (autovacuum_enabled = false);`
)
expect(await reltuplesOf(db, 'public.empty_t')).toBe(-1)
const table = await tableOf(db, 'public.empty_t', 'empty_t', 'public')
// Postgres estimates a never-vacuumed heap at a ~10-page minimum, so a naive
// reltuples=-1 -> estimate would report phantom rows here. The size gate
// routes an empty (0-byte) heap to an exact count instead.
expect(await runCount(db, { table, scoped: true })).toEqual({ count: 0, is_estimate: false })
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
count: 0,
is_estimate: false,
})
}
)
withTestDatabase(
'scoped: small never-analyzed table -> exact count, is_estimate=false (both modes)',
async (db) => {
await db.executeQuery(`
create table public.small_unanalyzed (id int primary key, val text)
with (autovacuum_enabled = false);
insert into public.small_unanalyzed select g, 'r' || g from generate_series(1, 1000) g;
`)
expect(await reltuplesOf(db, 'public.small_unanalyzed')).toBe(-1)
const table = await tableOf(db, 'public.small_unanalyzed', 'small_unanalyzed', 'public')
// Heap is a few tens of KB -- well under the byte gate -> exact count.
expect(await runCount(db, { table, scoped: true })).toEqual({ count: 1000, is_estimate: false })
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
count: 1000,
is_estimate: false,
})
}
)
withTestDatabase(
'scoped INTENTIONALLY diverges from legacy: large never-analyzed table -> estimate',
async (db) => {
// Wide rows (~300-byte payload) so the heap clears the ~10MB byte gate with a
// modest, fast-to-insert row count (~19MB at 60k rows) -- the case the legacy
// path mishandles (treats reltuples=-1 as small, runs a timing-out count).
await db.executeQuery(`
create table public.bulk_unanalyzed (id int primary key, val text)
with (autovacuum_enabled = false);
insert into public.bulk_unanalyzed
select g, repeat('x', 300) from generate_series(1, 60000) g;
`)
expect(await reltuplesOf(db, 'public.bulk_unanalyzed')).toBe(-1)
const [{ bytes }] = await db.executeQuery<{ bytes: number }[]>(
`select pg_relation_size('public.bulk_unanalyzed'::regclass)::int8 as bytes;`
)
expect(Number(bytes)).toBeGreaterThan(10_000_000)
const table = await tableOf(db, 'public.bulk_unanalyzed', 'bulk_unanalyzed', 'public')
// Non-readonly scoped: EXPLAIN-based estimate (works without ANALYZE).
const scoped = await runCount(db, { table, scoped: true })
expect(scoped.is_estimate).toBe(true)
expect(scoped.count).toBeGreaterThan(1000)
expect(scoped.count).not.toBe(-1)
// Readonly scoped: cannot create the estimate function -> reports -1 as an
// estimate rather than a timing-out exact count.
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
count: -1,
is_estimate: true,
})
// The intentional divergence: legacy (scoped:false) still runs an exact count
// on the -1 table (the pre-fix behavior the scoped path corrects).
const legacy = await runCount(db, { table })
expect(legacy).toEqual({ count: 60000, is_estimate: false })
expect(legacy.is_estimate).not.toBe(scoped.is_estimate)
}
)
withTestDatabase(
'scoped == legacy for an analyzed table below THRESHOLD_COUNT (default, filtered, enforceExactCount)',
async (db) => {
await db.executeQuery(`
create table public.analyzed_small (id int primary key, status text);
insert into public.analyzed_small
select g, case when g % 2 = 0 then 'active' else 'inactive' end
from generate_series(1, 10) g;
analyze public.analyzed_small;
`)
const table = await tableOf(db, 'public.analyzed_small', 'analyzed_small', 'public')
const activeFilter: Filter[] = [{ column: 'status', operator: '=', value: 'active' }]
// Default count: both paths exact-count a small analyzed table.
expect(await assertScopedEqualsLegacy(db, { table })).toEqual({ count: 10, is_estimate: false })
// Read-only default count agrees too.
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
count: 10,
is_estimate: false,
})
// Filtered count agrees.
expect(await assertScopedEqualsLegacy(db, { table, filters: activeFilter })).toEqual({
count: 5,
is_estimate: false,
})
// enforceExactCount ignores scoped entirely and agrees, with/without filters.
expect(await assertScopedEqualsLegacy(db, { table, enforceExactCount: true })).toEqual({
count: 10,
is_estimate: false,
})
expect(
await assertScopedEqualsLegacy(db, {
table,
enforceExactCount: true,
filters: activeFilter,
})
).toEqual({ count: 5, is_estimate: false })
}
)
withTestDatabase(
'scoped == legacy for an analyzed table over THRESHOLD_COUNT (estimate path unchanged)',
async (db) => {
// reltuples > 50000 after analyze routes BOTH paths to the estimate branch
// (raw reltuples when unfiltered) -- identical output; the byte gate only
// affects the reltuples = -1 case, not this one.
await db.executeQuery(`
create table public.big_analyzed (id int primary key)
with (autovacuum_enabled = false);
insert into public.big_analyzed select generate_series(1, 60000);
analyze public.big_analyzed;
`)
expect(await reltuplesOf(db, 'public.big_analyzed')).toBeGreaterThan(50000)
const table = await tableOf(db, 'public.big_analyzed', 'big_analyzed', 'public')
// Non-readonly: both return the raw reltuples estimate.
const scoped = await assertScopedEqualsLegacy(db, { table })
expect(scoped.is_estimate).toBe(true)
expect(scoped.count).toBeGreaterThan(1000)
// Read-only: both report -1 as an estimate.
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
count: -1,
is_estimate: true,
})
// enforceExactCount over the threshold still runs a real count in both paths.
expect(await assertScopedEqualsLegacy(db, { table, enforceExactCount: true })).toEqual({
count: 60000,
is_estimate: false,
})
}
)
// A partitioned PARENT (relkind 'p') has no storage of its own, so
// pg_relation_size(parent) is 0. The size gate must use the whole partition tree
// or a large never-analyzed partitioned table would be misclassified as small
// and exact-counted across all partitions -- the exact timeout being fixed.
withTestDatabase(
'scoped: large never-analyzed PARTITIONED table -> estimate (partition-tree size gate)',
async (db) => {
await db.executeQuery(`
create table public.part_big (id int, region text, val text) partition by list (region);
create table public.part_big_e partition of public.part_big for values in ('east')
with (autovacuum_enabled = false);
create table public.part_big_w partition of public.part_big for values in ('west')
with (autovacuum_enabled = false);
insert into public.part_big
select g, case when g % 2 = 0 then 'east' else 'west' end, repeat('x', 300)
from generate_series(1, 45000) g;
`)
// Parent is never-analyzed (reltuples = -1) and has zero own heap size...
expect(await reltuplesOf(db, 'public.part_big')).toBe(-1)
const [{ own, tree }] = await db.executeQuery<{ own: number; tree: number }[]>(`
select
pg_relation_size('public.part_big'::regclass)::int8 as own,
(select coalesce(sum(pg_relation_size(relid)), 0)
from pg_partition_tree('public.part_big'::regclass))::int8 as tree;
`)
expect(Number(own)).toBe(0) // ...so pg_relation_size alone would say "small"
expect(Number(tree)).toBeGreaterThan(10_000_000) // the tree sum clears the gate
const table = await tableOf(db, 'public.part_big', 'part_big', 'public')
const scoped = await runCount(db, { table, scoped: true })
expect(scoped.is_estimate).toBe(true)
expect(scoped.count).toBeGreaterThan(1000)
expect(scoped.count).not.toBe(-1)
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
count: -1,
is_estimate: true,
})
// Legacy still exact-counts across all partitions (the pre-fix behavior).
expect(await runCount(db, { table })).toEqual({ count: 45000, is_estimate: false })
}
)
withTestDatabase(
'scoped: small never-analyzed PARTITIONED table -> exact count (both modes)',
async (db) => {
await db.executeQuery(`
create table public.part_small (id int, region text) partition by list (region);
create table public.part_small_e partition of public.part_small for values in ('east')
with (autovacuum_enabled = false);
create table public.part_small_w partition of public.part_small for values in ('west')
with (autovacuum_enabled = false);
insert into public.part_small
select g, case when g % 2 = 0 then 'east' else 'west' end from generate_series(1, 100) g;
`)
expect(await reltuplesOf(db, 'public.part_small')).toBe(-1)
const table = await tableOf(db, 'public.part_small', 'part_small', 'public')
expect(await runCount(db, { table, scoped: true })).toEqual({ count: 100, is_estimate: false })
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
count: 100,
is_estimate: false,
})
}
)
withTestDatabase(
'scoped == legacy for an ANALYZED partitioned table below THRESHOLD_COUNT',
async (db) => {
await db.executeQuery(`
create table public.part_analyzed (id int, region text) partition by list (region);
create table public.part_analyzed_e partition of public.part_analyzed for values in ('east');
create table public.part_analyzed_w partition of public.part_analyzed for values in ('west');
insert into public.part_analyzed
select g, case when g % 2 = 0 then 'east' else 'west' end from generate_series(1, 100) g;
analyze public.part_analyzed;
`)
const table = await tableOf(db, 'public.part_analyzed', 'part_analyzed', 'public')
expect(await assertScopedEqualsLegacy(db, { table })).toEqual({
count: 100,
is_estimate: false,
})
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
count: 100,
is_estimate: false,
})
}
)
withTestDatabase(
'scoped == legacy for a view flowing through the row-count builder',
async (db) => {
await db.executeQuery(`
create table public.view_src (id int primary key);
insert into public.view_src select generate_series(1, 7);
create view public.v_rows as select * from public.view_src;
`)
const table = await tableOf(db, 'public.v_rows', 'v_rows', 'public')
// A view has no heap (pg_relation_size 0, no partition tree) -> the gate keeps
// an exact count; scoped and legacy agree.
expect(await assertScopedEqualsLegacy(db, { table })).toEqual({ count: 7, is_estimate: false })
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
count: 7,
is_estimate: false,
})
}
)
// ── Fix #1: the embedded estimate select is quoted with literal(), so backslash
// identifiers survive regardless of the session's standard_conforming_strings.
withTestDatabase(
'scoped estimate path quotes the embedded select safely (backslash names, scs on & off)',
async (db) => {
// Names contain a backslash; in the JS template `\\` is one literal backslash.
await db.executeQuery(`
create table public."wei\\rd" ("col\\umn" int) with (autovacuum_enabled = false);
insert into public."wei\\rd" select g % 3 from generate_series(1, 60000) g;
analyze public."wei\\rd";
`)
const [{ id }] = await db.executeQuery<{ id: number }[]>(
`select 'public."wei\\rd"'::regclass::oid::int8 as id;`
)
// reltuples > THRESHOLD_COUNT + a filter -> the estimate (count_estimate)
// branch runs, embedding the filtered select (with the backslash names) as a
// literal inside the function call.
const table = { id: Number(id), name: 'wei\\rd', schema: 'public' }
const filters: Filter[] = [{ column: 'col\\umn', operator: '=', value: 1 }]
// Default standard_conforming_strings (on): both paths take the estimate
// branch and agree on the value.
const scopedOn = await runCount(db, { table, scoped: true, filters })
expect(scopedOn.is_estimate).toBe(true)
expect(Number.isFinite(scopedOn.count)).toBe(true)
expect(await assertScopedEqualsLegacy(db, { table, filters })).toEqual(scopedOn)
// standard_conforming_strings = off in the SAME connection: the SET, the
// CREATE FUNCTION, and the count must share one query (the test uses a pool).
const withScsOff = (sql: string) => `set standard_conforming_strings = off;\n${sql}`
const [scopedOff] = await db.executeQuery<CountRow[]>(
withScsOff(getTableRowsCountSql({ table, scoped: true, filters }))
)
// literal()/E'...' keeps the backslash identifiers intact under scs=off.
expect(scopedOff.is_estimate).toBe(true)
expect(Number.isFinite(Number(scopedOff.count))).toBe(true)
// The legacy apostrophe-only escaping mangles the backslashes under scs=off
// (the bug the scoped path fixes), so legacy errors there -- assert only the
// scoped behavior, per contract.
await expect(
db.executeQuery(withScsOff(getTableRowsCountSql({ table, filters })))
).rejects.toThrow()
}
)