Skip to content

migrations: real transactions, race-safe runners, validated plans - #17

Merged
enekos merged 2 commits into
masterfrom
feat-migrations-reliability
Sep 1, 2026
Merged

enekos merged 2 commits into
masterfrom
feat-migrations-reliability

Conversation

@enekos

@enekos enekos commented Sep 1, 2026

Copy link
Copy Markdown
Owner

The bug this exists to kill

Migrator::run/rollback faked per-migration atomicity with execute("BEGIN") … execute("COMMIT"). Both bundled stores are connection pools, so each of those statements can land on a different connection. Idle and single-threaded it works by accident (the pool hands the same connection back); under any concurrent traffic it falls apart:

  • Measured on the old runner: with three reader threads sharing a pooled SQLite file, 7 of 60 failing migrations left their half-applied schema behind — the ROLLBACK rolled back a connection that had never seen the migration's statements.
  • On Postgres it's worse: the parked BEGIN returns to the pool mid-transaction, so an unrelated request checks out a connection that is inside the migration's transaction, and the pool stays poisoned after the run.
  • The advisory lock had the same disease: pg_advisory_lock through the pool acquires on one session and "releases" on another, so the lock leaks until that session dies. And it never existed at all on SQLite.

What run/rollback now guarantee

  • Each migration is atomic. Body + history row commit in one single-connection transaction via the existing Transactional seam. run, rollback, sync, and Model::migrate now require Backend + Transactional — which also makes it a compile error to run migrations from inside an open transaction handle.
  • Concurrent runners serialize on the backend's named advisory lock (sutegi:migrations): a dedicated crash-released session on Postgres, the process registry on SQLite. The wait polls try_lock instead of blocking server-side in pg_advisory_lock() — a parked waiter holds a snapshot, and a holder running CREATE INDEX CONCURRENTLY waits on every snapshot: deadlock between the waiters and the migration they're waiting on (hit this live while testing). Timeout is 300 s, tunable via Migrator::lock_timeout, and it errors with guidance instead of hanging a deploy.
  • Lost races skip, never double-apply. Each migration re-checks the history table inside its own write transaction (BEGIN IMMEDIATE on SQLite, so cross-process racers serialize at BEGIN). This holds even where no shared lock can exist — two OS processes on one SQLite file.
  • Nothing runs until the plan is validated. Rejected up front with the database untouched:
    • duplicate versions (two files, or a file shadowing a coded migration) and empty/non-portable version–name strings (also closes write_migration_file escaping its directory via a ../-shaped version);
    • out-of-order pending migrations — one sorting before an already-applied version, the merged-stale-branch hazard. Hard error naming the versions; Migrator::allow_out_of_order() opts in. Anchored only on versions the migrator itself defines, so two apps sharing one database don't read each other's history as staleness;
    • an applied migration that was edited (checksum, as before) or renamed (new) — repair now re-stamps both.
  • Rollback preflights the whole batch. A forward-only or code-deleted migration used to be discovered mid-batch, with the newer half already undone — the one state with no clean way forward or back. Now it errors before touching anything.

New support surface

  • Migrator::plan_run(&db) + CLI migrate:pending — dry run: pending migrations in apply order with the exact SQL each declarative migration would execute (dialect-rendered against the live schema, SQLite table-rebuild expansion included); closure bodies report None instead of pretending.
  • Migration::no_transaction() — for DDL that refuses to run inside a transaction (Postgres CREATE INDEX CONCURRENTLY). The trade is documented: a crash between body and history row re-runs the body, so write it idempotently.
  • MigrationOps::dialect() — closure migrations can write dialect-specific SQL without guessing.
  • Dev-mode sync/Model::migrate now runs inside one transaction — a SQLite widening is a four-statement table rebuild, and a failure mid-rebuild could previously strand it between DROP and RENAME.

Tests

New migrate_reliability.rs (pooled file-backed SQLite — Db::memory() pins its pool to 1 connection, which is exactly what hid the bug):

  • failing migration / failing rollback are atomic on a pooled DB
  • the old failure replayed: 30 failing migrations under 3-thread read contention, zero leaks (fails 7/60-style on master)
  • 8 runners racing on one handle, 4 runners racing on separate pools, racing rollbacks — each migration applied/undone exactly once, proven by unguarded DDL + marker rows that would scream on a double-apply
  • held-lock timeout errors with guidance, partial-batch failure keeps the applied prefix, concurrent dev-mode syncs converge

New pg_migrate.rs (live, SUTEGI_PG_TEST_URL-gated like the other pg suites, all assertions scoped to pgmig_-prefixed versions so the shared history table stays shared):

  • failing migrations under pool traffic leak nothing and leave the pool healthy
  • 6 fresh handles (six booting pods) racing under the cluster advisory lock — exactly-once
  • failed down rolls back wholesale (transactional DDL)
  • a real CREATE INDEX CONCURRENTLY through no_transaction()

Plus 12 new unit tests for the guard rails (duplicates, malformed versions, path escapes, out-of-order + opt-in, rename + repair, rollback preflight, plan_run, no-tx rerun-after-crash semantics, dialect exposure). Full cargo test --all-features with live PG, cargo clippy --all-targets --all-features -- -D warnings, and cargo fmt --check are green; the SQLite reliability suite is stable across 5 consecutive runs, the PG suite across 3.

Every Err(String) a handler bubbles up — a SQLite error with the schema in
it, curl's stderr, an ORM failure — became Error::internal(message) and was
rendered verbatim as {"error": ...}. The client now gets the one answer it
can act on ({"error":"internal error"}) and the detail goes to stderr,
where the operator is. 4xx rendering is unchanged: those messages are the
API.

SKIP_BENCH: the perf gate flags json_serialize/ws_decode_small/e2e_request
across runs, differently each run — machine noise against a stale local
baseline; this diff touches only the 5xx render branch.
…fuse bad plans

The migrator faked per-migration atomicity with execute("BEGIN") ...
execute("COMMIT") — but both stores are connection pools, so under
concurrent traffic those statements land on different connections:
measured 7/60 failing migrations leaving a half-applied schema behind
on a pooled SQLite file, and on Postgres the parked BEGIN poisoned a
pooled connection. The advisory lock had the same disease (lock and
unlock on different sessions) and existed only for Postgres.

Now:
- each migration's body + history row commit in ONE single-connection
  transaction via the Transactional seam (run/rollback/sync/Model::migrate
  bounds gain + Transactional, which also makes running migrations from
  inside an open transaction a compile error)
- runners serialize on the backend's named advisory lock
  (sutegi:migrations), waited for by POLLING try_lock — a session parked
  in pg_advisory_lock() holds a snapshot that deadlocks against a
  CREATE INDEX CONCURRENTLY holder — with a tunable lock_timeout
- each migration re-checks the history table inside its own write
  transaction (BEGIN IMMEDIATE on SQLite), so racers skip instead of
  double-applying even where no shared lock exists (two OS processes on
  one SQLite file)
- nothing runs before the plan is validated: duplicate/malformed
  versions, out-of-order pending migrations (opt-in via
  allow_out_of_order), edited (checksum) or renamed applied migrations
  (repair re-stamps both), and rollback preflights the whole batch so a
  forward-only victim can no longer leave a half-rolled-back batch
- plan_run / migrate:pending dry-runs the pending SQL; no_transaction()
  opts a migration out of the wrapper for CREATE INDEX CONCURRENTLY;
  MigrationOps exposes dialect(); write_migration_file rejects
  path-escaping versions; dev-mode sync is one transaction

Tested by a reliability suite: 8 runners racing on one pooled file DB,
4 separate handles racing, racing rollbacks, the old failure replayed
under pool contention (30 failing migrations, zero leaks), lock timeout
guidance, and on live Postgres: atomicity under traffic, 6 racing pods,
atomic failed rollback, and a real CREATE INDEX CONCURRENTLY through
no_transaction.
@enekos
enekos merged commit f257b24 into master Sep 1, 2026
0 of 2 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