feat(remote): re-dump production statistics when the source drifts - #195
Conversation
There was a problem hiding this comment.
Query Doctor Analysis
Caution
Could not verify — this PR changes queries with no related data-layer test.
This PR adds or changes queries in data-access code, but no related real-DB (repository/integration) test changed, so Query Doctor could not verify them — nothing here exercises them against Postgres. This is flagged conservatively; it is not a claim that the query is wrong.
Next step: Add or update a repository/integration test that exercises the changed queries against a real database, following your project's testing conventions — or, if a test genuinely isn't needed (a revert, generated code, a column-drop migration), triage it.
Changed data-access files with no related data-layer test:
src/sync/pg-connector.ts
If this change intentionally needs no test, note why on the PR so the exception is auditable rather than silent.
3 queries analyzed
0 regressed · 0 improved · 0 new · 0 removed
2 pre-existing issues
SELECT "guests"."id", "guests"."session_id", "guests"."username", "guests"."avatar_path", "guests"."color", "guests"."side", "guests"."audio_recording_path", "guests"."audio_recording_public", "gue...
indexassets(event_id, inserted_at desc)
cost 31,003,449 → 1,498 (100% reduction)SELECT * FROM guest_ip_addresses WHERE ip_address = '127.0.0.1';
indexguest_ip_addresses(ip_address)
cost 154,402 → 8 (100% reduction)
Using assumed statistics (10000000 rows/table). For better results, sync production stats.
More detail → get_ci_run({ runId: "019fa526-2c70-72a1-9378-29ee0b747734" }) · view run · docs
Production Statistics only reached the server when something explicitly asked for them — `updateStatistics` or `resetStats`. Neither runs on a cadence, so a long-lived analyzer pushed once on connect and never again. Query Doctor's own snapshot sat six weeks stale: every table added since 2026-06-17 was uncovered, so queries touching them were costed by the synthesizer instead of real data. Implements the refresh half of ADR 0007 §2. The schema poll already runs every 60s, so it doubles as the drift tick: - Shape Drift — the source has tables the last pushed dump doesn't cover, or dropped ones it did. Free; a set comparison on data already in hand. - Size Drift — per-table reltuples moved past a ratio. One pg_class read, never pg_statistic. Either crossing re-dumps from source and pushes through the existing applyStatistics -> statsApplied -> pushStats path. The heavy dump stops being something nobody triggers and becomes earned by detected change. Shape Drift is checked first and deliberately: a table a migration just created has zero rows, so a size comparison would never notice it — which is exactly how the six tables were missed. Only armed once a fromSource dump has set a baseline. An imported or synthetic snapshot describes someone else's database, so drifting against it would push the wrong numbers as this project's production statistics. Co-Authored-By: Claude <noreply@anthropic.com>
e42f509 to
882f724
Compare
Goal
Production Statistics go stale and nothing notices. Query Doctor's own snapshot was six weeks old: every table added since 2026-06-17 was missing from it, so queries touching those tables were costed by the synthesizer rather than real data. This implements the refresh half of ADR 0007 §2.
What
Before, a stats dump reached the server only when something explicitly asked for one. The two callers of
applyStatisticsareupdateStatisticsandresetStats, and neither runs on a schedule. A long-lived analyzer pushed once and never again.After, the analyzer re-dumps and pushes on its own when the source has moved far enough from what it last sent.
How
Read
src/remote/stats-drift.tsfirst. It holds the decision as two pure functions over a baseline, so the policy is testable without a database.Two signals, both cheap:
reltuplesmoved past a ratio. This readspg_classand never touchespg_statistic.Shape Drift is checked first. A table a migration just created has zero rows, so a size comparison would never see it. That is how the six tables were missed.
The check rides the existing 60s schema poll rather than adding a schedule.
SchemaLoadergains apolledevent that fires on every successful poll, not just when the schema changed, because row counts move while the schema stands still. On a drift verdict,Remotedumps from source and callsapplyStatistics, which reuses the existingstatsAppliedtopushStatspath.Two guards worth knowing:
fromStatisticsExportstats, so an imported or synthetic snapshot never arms drift. Drifting against someone else's numbers would push them as this project's production statistics.refreshingStatsflag stops a second dump starting while one is in flight.Size Drift exempts tables under 1,000 rows on both sides. A table going from 2 rows to 8 is a 300% move and means nothing to the planner; without the floor it would re-dump on nearly every poll.
Deliberately not here
The daily cron backstop in Site for analyzers that were asleep, and surfacing snapshot age in the app. Both are Site-side and independent.
Tests
stats-drift.test.tscovers both signals: new table, new empty table, dropped table, shape taking precedence over size, growth and shrink past the ratio, a move under it, the small-table floor, a small table growing past the floor, and an unchanged database. Ten cases, no container needed.Snapshot Age already exists — this is the other two axes
CONTEXT.mdnames three ways a snapshot goes stale. Only one was built before this PR:assessDumpFreshness()inpackages/mcp-server/src/stats-dump-format.tswarns when a dump is over 30 days old. It ismcp-serveronly, nothing inapps/orpackages/coreimports it, and it warns rather than triggering a refresh.Age is a proxy and a weak one. A snapshot two days old already misses a table a migration created that morning, which is the case that left six tables uncovered on the Query Doctor project. Shape Drift catches it; age does not.
Keeping the three named separately so a later change doesn't reimplement age detection under a drift name.