feat(db): versioned schema migrations with idempotent backfill#21
Merged
Conversation
Replace the inline &str SQL array that re-runs on every node startup with a versioned migration system. Each migration is recorded in a new schema_migrations table and applied at most once per node, inside a single transaction. For nodes upgrading from a pre-migration-versioning build, the canonical `repos` table is used as a signal to mark v1 as already applied without re-running its ~140 statements — so existing operators see zero behavior change on first restart after this commit. Closes the gap called out in docs/OSS-READINESS-AUDIT.md:78. - Adds Db::migration_status() returning applied (version, name, applied_at) - Adds 6 unit tests validating the static migration catalogue (versions strictly increasing, names distinct, bodies non-empty, v1 name locked to initial_schema) - cargo fmt + clippy -D warnings + cargo test --workspace all clean
Address two robustness gaps in the v1 migration runner on the live network: - Remove the "repos table present => v1 complete" backfill short-circuit. It assumed every existing node already had the full current schema; a node that was behind would be marked v1-applied while still missing newer tables or columns. Every v1 statement is idempotent (CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS), so legacy installs now simply run v1 once: existing objects are no-ops, missing ones get created. - Guard the whole runner with a Postgres advisory lock so two instances against the same database (blue/green or rolling deploy) can't race to apply the same migration and trip the schema_migrations primary key. The lock is released explicitly, and automatically if the connection drops. Also document that per-migration transactions preclude CREATE INDEX CONCURRENTLY in future migrations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
What
Add a versioned migration system for the Postgres schema. Migrations are
now recorded in a
schema_migrationstable and applied at most once pernode, inside a single transaction.
Closes the gap called out in
docs/OSS-READINESS-AUDIT.md:78("no versioned database migration system; schema is created/altered
from code at startup, which is convenient early on but risky for live
upgrades").
Why
&strSQL statements.That's safe only because every statement is
IF NOT EXISTS. Themoment we need to add a non-
IF NOT EXISTSchange (rename, add aNOT NULLcolumn to a large existing table, add an index withoutIF NOT EXISTS, etc.) we have no record of what's been applied where.is at the same schema version.
schema changes (UCAN revocation table, per-repo authorization,
metrics counters, etc.).
Behavior change
None observable to operators. The schema content is byte-for-byte
identical. What changes internally:
schema_migrationstable is created.one transaction and are recorded as v1 applied.
re-running statements (detected via the canonical
repostable).Operator action required: none.
Safety properties
A failure mid-way rolls the whole version back; the node refuses to
start rather than booting on a partially-applied schema.
version is still unrecorded, so the runner picks it up again and
completes it.
with_contextincludes thefailing SQL string and migration version in any error chain, so
operators don't have to dig through logs.
path detects the
repostable and short-circuits the v1application. No existing operator will see their 140 statements
re-run.
Testing
cargo fmt --all -- --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo test --workspace— all tests pass, including 6 newdb::migration_tests::*tests that validate the static migrationcatalogue:
migrations_are_non_emptymigration_versions_are_strictly_increasingmigration_versions_start_at_onemigration_names_are_non_empty_and_distinctmigration_bodies_are_non_emptyv1_name_is_the_initial_schema(catches an accidental renameof v1, which would silently break the legacy-install backfill)
A new
Db::migration_status()returnsVec<(version, name, applied_at)>for ops/observability. It is intentionally not wired into any
HTTP route in this PR — that's a one-line follow-up. The catalogue
itself is the API.
Risk
Low. The change is purely additive. Every existing
IF NOT EXISTSguard is preserved inside the v1 migration body, so the schema content
is identical. Failures roll back the transaction and prevent the node
from starting (the correct behavior on a partially-applied schema).
The only failure mode worth flagging: if a node's Postgres is reachable
but the
repostable is missing for some reason (manualDROP TABLE,a fresh empty DB, etc.), the legacy-install backfill will run v1 from
scratch. That is the correct behavior.
Follow-ups (not in this PR)
Db::migration_status()viagl statusand/or/api/v1/statsso operators can confirm a node's schema versionat a glance.
(this PR lays the groundwork; v2 will be a no-op
Migration { version: 2, name: "noop_placeholder", stmts: &[] }if we want a clean versionbump point).
🤖 Generated with Claude Code