Skip to content

feat(remote): re-dump production statistics when the source drifts - #195

Merged
veksen merged 1 commit into
mainfrom
feat-stats-drift-refresh
Jul 27, 2026
Merged

feat(remote): re-dump production statistics when the source drifts#195
veksen merged 1 commit into
mainfrom
feat-stats-drift-refresh

Conversation

@veksen

@veksen veksen commented Jul 27, 2026

Copy link
Copy Markdown
Member

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 applyStatistics are updateStatistics and resetStats, 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.ts first. It holds the decision as two pure functions over a baseline, so the policy is testable without a database.

Two signals, both cheap:

  • Shape Drift: the source has tables the last pushed dump doesn't cover, or dropped ones it did. This is a set comparison on data already in hand, so it costs nothing.
  • Size Drift: a table's reltuples moved past a ratio. This reads pg_class and never touches pg_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. SchemaLoader gains a polled event 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, Remote dumps from source and calls applyStatistics, which reuses the existing statsApplied to pushStats path.

Two guards worth knowing:

  • The baseline is only recorded for fromStatisticsExport stats, so an imported or synthetic snapshot never arms drift. Drifting against someone else's numbers would push them as this project's production statistics.
  • A refreshingStats flag 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.ts covers 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.md names three ways a snapshot goes stale. Only one was built before this PR:

  • Snapshot Age — built. assessDumpFreshness() in packages/mcp-server/src/stats-dump-format.ts warns when a dump is over 30 days old. It is mcp-server only, nothing in apps/ or packages/core imports it, and it warns rather than triggering a refresh.
  • Shape Drift — this PR.
  • Size Drift — this PR.

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.

@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 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

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>
@veksen
veksen force-pushed the feat-stats-drift-refresh branch from e42f509 to 882f724 Compare July 27, 2026 19:55
@veksen
veksen changed the base branch from adopt-core-017 to main July 27, 2026 19:55
@veksen
veksen merged commit ff2e707 into main Jul 27, 2026
5 of 6 checks passed
@veksen
veksen deleted the feat-stats-drift-refresh branch July 27, 2026 20:13
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