feat(remote): earn a statistics re-dump on an uncovered column - #233
Merged
Conversation
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>
There was a problem hiding this comment.

Query Doctor — 6 successful checks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_statisticrow, so a query filtering on it was costed at Postgres'sDEFAULT_EQ_SELof 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.tsfirst.remote.tsonly passes an argument through.detectDriftcompared the set of table keys and per-tablereltuples, both from onepg_classprobe. 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 frompg_attributewhether or not Postgres has analyzed it. No new query and no new schedule.Three limits, each one a path to a full
DUMP_STATS_SQLevery 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 throughpg_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_attributeraw; the schema runsquote_ident, andPgIdentifier.toString()escapes that result again, sowe"irdarrives as"we""""ird". Undoing one level leaves the two unequal forever.Indexes are excluded, though the capture carries their names.
DUMP_STATS_SQLcollects them in a CTE filtered byrelname NOT LIKE 'pg_%', where_is a wildcard, so a table namedpgmigrationsis 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 byrelnamealone, 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 typecheckclean. 43 files, 454 tests passing, rebased onmainataff59d6.