Skip to content

feat(remote): earn a statistics re-dump on an uncovered column - #233

Merged
veksen merged 1 commit into
mainfrom
feat-column-shape-drift
Aug 14, 2026
Merged

feat(remote): earn a statistics re-dump on an uncovered column#233
veksen merged 1 commit into
mainfrom
feat-column-shape-drift

Conversation

@veksen

@veksen veksen commented Aug 13, 2026

Copy link
Copy Markdown
Member

Goal

Keep a project's production statistics covering the schema its queries run against, so costs come from measurements rather than defaults. ADR 0007 §2 made a re-dump something a database change earns rather than something a timer runs.

This closes the column-shaped hole in that trigger. The matching index check waits on Query-Doctor/Site#4005 and Query-Doctor/Site#3959.

Closes Query-Doctor/Site#4003. The Site half, an ADR amendment and a glossary entry, is Query-Doctor/Site#4007.

What

Before: a migration that added a column earned no statistics refresh. The table set was unchanged and no row count moved, so neither drift signal fired. The new column had no pg_statistic row, so a query filtering on it was costed at Postgres's DEFAULT_EQ_SEL of 0.005 — a fixed guess rather than a reading of production, with nothing in the report marking it as one. The snapshot caught up only on the 24-hour floor.

After: the same migration is detected on the next 60-second poll and earns a re-dump, as a new table does today.

Dropping a column still changes nothing.

How

Read src/remote/stats-drift.ts first. remote.ts only passes an argument through.

detectDrift compared the set of table keys and per-table reltuples, both from one pg_class probe. It now also compares column names, against the schema the 60-second poll has already dumped. The baseline reads them from the capture, which exports every live column from pg_attribute whether or not Postgres has analyzed it. No new query and no new schedule.

Three limits, each one a path to a full DUMP_STATS_SQL every 60 seconds forever:

Only tables the capture covers. The probe is relkind = 'r'; the schema also carries materialized views, which no capture covers. Reporting one asks for a dump that cannot satisfy it.

Presence, never types. A column's type reaches the schema through format_type (character varying(255)) and the capture through pg_type.typname (varchar). Comparing them calls every column changed on every poll.

One direction. A column the capture covers and the database has dropped is ignored. Restore matches by name, so the extra entry matches nothing.

Names collapse to a fixed point before comparison. The capture reads pg_attribute raw; the schema runs quote_ident, and PgIdentifier.toString() escapes that result again, so we"ird arrives as "we""""ird". Undoing one level leaves the two unequal forever.

Indexes are excluded, though the capture carries their names. DUMP_STATS_SQL collects them in a CTE filtered by relname NOT LIKE 'pg_%', where _ is a wildcard, so a table named pgmigrations is captured with its columns and an empty index list, and its indexes read as uncovered after the dump meant to cover them. The same CTE groups by relname alone, so same-named tables in different schemas share one index list and a real new index is masked. Both are fixable only in the dump.

Tests

src/remote/stats-drift.test.ts, unit level, no database.

Covered: an uncovered column fires; the steady state after a refresh does not; a name needing quotes converges, for camelCase, a reserved word, and an embedded double quote; a dropped column does not fire; a relation the capture never covered does not fire; an uncovered table is reported ahead of an uncovered column; an absent schema checks nothing.

The ordering test and the embedded-quote test both fail against a mutated implementation: check order reversed, and one level of unquoting in place of the fixed point.

npm run typecheck clean. 43 files, 454 tests passing, rebased on main at aff59d6.

Shape Drift compared the table-key set and per-table reltuples, both read
from one pg_class probe. A migration that only added a column moved
neither, so it earned no re-dump. The added column then had no
pg_statistic row, and synthesizeReltuples skips any table the snapshot
already covers, so it was costed on the planner's no-statistics defaults
instead of on production.

The 60s schema poll already carries the columns, and the dump already
exports their names, so the comparison costs no new query and no new
schedule.

Columns only. The capture's index list can't serve as a baseline yet:
its CTE filters `relname NOT LIKE 'pg_%'`, where `_` is a wildcard, so a
table named pgmigrations is captured with an empty index list and would
ask for a dump every 60s forever (Query-Doctor/Site#4005); and it groups
by relname alone, so same-named tables across schemas share one list and
a real new index is masked (Query-Doctor/Site#3959).

Names are collapsed to a fixed point before comparison. The capture
reads pg_attribute raw while the schema runs quote_ident and then
PgIdentifier escapes that again, so `we"ird` arrives as `"we""""ird"`;
undoing one level leaves the two unequal, which is the same permanent
loop by another route.

Closes Query-Doctor/Site#4003

Co-Authored-By: Claude <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Query Doctor — 6 successful checks

 Cost regression — No query went up more than 5%
 Untested data access — No changed data-access file without a test
 New query — No new queries
 New query with index recommendation — No new query ships an index recommendation
 Schema drift — No schema changes
 High-value nudge — No index or rewrite past the threshold


More details via MCP → get_ci_run({ runId: "019ffd82-4692-7e96-8815-41777f403d2f" }) · view run · docs
3 queries read against main on assumed statistics of 10,000,000 rows per table. Sync production stats for costs measured against your real data.

@veksen
veksen merged commit 9aa0621 into main Aug 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant