Skip to content

fix(bootstrap): serialize concurrent migrations with a PostgreSQL advisory lock - #235

Open
robinnsc wants to merge 1 commit into
mainfrom
feat/migrate-advisory-lock
Open

fix(bootstrap): serialize concurrent migrations with a PostgreSQL advisory lock#235
robinnsc wants to merge 1 commit into
mainfrom
feat/migrate-advisory-lock

Conversation

@robinnsc

Copy link
Copy Markdown
Collaborator

What

Serializes concurrent extenddb migrate runs with a namespaced PostgreSQL advisory lock, so replicas starting at the same time cannot race each other applying schema changes.

  • migrate acquires the lock before checking what is pending and releases it on every exit path: success, the "everything is up to date" early return, and errors. The lock is held on a dedicated connection to the catalog database for the whole migration; if the process dies, PostgreSQL releases it when the connection closes.
  • A migrator that has to wait tries the lock first and prints why it is waiting, rather than sitting silent for as long as the peer's migration takes.
  • init takes the same lock around its own schema work, so an init cannot race a migrate on another replica. Two concurrent init runs never reach the migrations — the second aborts earlier at create_catalog_db because the database already exists — so this guards the narrower init-versus-migrate overlap.
  • The Bootstrapper trait gains acquire_migration_lock and release_migration_lock with default no-op bodies, so out-of-tree backends compile and behave exactly as they do today.

Why

Two replicas running migrate at once both evaluate which migrations are pending before either records anything, so both apply, and one will fail as a result PostgreSQL's system catalog rejecting concurrent CREATE TABLE IF NOT EXISTS for the same table. It makes an idempotent container entrypoint, which unsafely runs migrate on every start of every replica

Testing done

New tests/test_cli_migrate_concurrency.py, against a real PostgreSQL:

  • Two concurrent migrate --yes processes both exit 0, with exactly one printing Applying 002_gsi_pending.sql and the other reporting nothing to do. The migration lands exactly once. This asserts serialization directly rather than inferring it from a side effect.
  • With the lock held from an external psycopg2 session, migrate blocks (still running after 3s), then completes and reports that it waited once the lock is released. Contention is guaranteed rather than dependent on process timing.

Both tests were confirmed to fail with the lock disabled — the race test on the duplicate pg_type_typname_nsp_index key, the contention test on "migrate should block while the lock is held". A migration guard whose tests pass without the guard is worthless, so that is the main evidence here.

A MinimalBootstrapper unit test implements only the trait's required methods, so it stops compiling if a defaulted method ever loses its default — the same breakage an out-of-tree backend crate would hit — and pins object safety through Box<dyn Bootstrapper>.

devtools/run-tests excludes the new file from the main pytest suite and runs it in the CLI section instead, alongside test_cli_lifecycle.py: like those tests it starts and stops its own servers and creates its own databases, so it cannot run in parallel against the shared instance the main suite uses.

Decisions worth reviewer attention

The wait is unbounded. pg_advisory_lock blocks until the lock is available, with no timeout. I think that is right: failing fast would make a normal multi-replica rollout flaky, which is the case this change exists to fix. The trade-off is that a wedged migrator blocks its peers indefinitely — in a Kubernetes init container, a pod that never starts. It is at least visible rather than silent, since a waiting migrator says so on stdout. If we want a bound instead, the options are a --lock-timeout flag or a session lock_timeout.

Why two trait methods rather than hiding the lock in the backend. Taking the lock inside the Postgres run_catalog_migrations/run_data_migrations would change no trait surface, but it does not fix the bug: the race is in the window between "which migrations are pending?" and "apply them", and those are separate trait calls made by the CLI, so a lock held only inside each apply call leaves the window open. A closure-taking with_migration_lock avoids two methods but fights object safety under async_trait. Note these are the first defaulted methods on Bootstrapper.

Deployment constraints, documented in the code:

  • Advisory locks are scoped to a database, so migrators only serialize against each other if they share a catalog database. They do, since it comes from the same connection string, but a deployment pointing replicas at different catalog databases over one shared data database would not be protected.
  • A transaction-pooling proxy (pgbouncer in transaction mode, RDS Proxy) cannot hold a session-level advisory lock at all, so migrate must talk to the database directly. This is the one I would most like a second opinion on, given RDS and Aurora are supported targets.
  • Acquiring is not re-entrant: a second acquire would open a second connection and block on the lock the first holds, deadlocking against itself, so it returns an error instead.

Relationship to other work

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

Breaking changes

None for in-tree backends. The two new trait methods have default no-op bodies, so an out-of-tree Bootstrapper implementor compiles and behaves exactly as before — it simply gets no migration serialization, which is what it has today. The MinimalBootstrapper test pins that guarantee.

// whole migration. Taking it on the catalog database serializes every
// migrator, whichever database the migration statements themselves
// touch.
//

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RDS and Aurora direct connections support session advisory locks fully; only a transaction-pooling proxy in between breaks it, and it breaks silently (the lock lands on an arbitrary pooled backend session, so you get false safety, not an error). Documentation alone leaves that silent. Cheap hardening: after acquiring, verify in a single statement (proxy-robust, since one statement maps to one backend) that this session holds the lock:

    SELECT count(*) = 1 FROM pg_locks
    WHERE locktype = 'advisory' AND classid = $1 AND objid = $2
      AND pid = pg_backend_pid()

and error out at startup if false. That turns silent false safety into a hard, diagnosable failure. Suggestion, not blocking.

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Ran the full verification pass on 9442a65 (rebased onto current main, 0 behind): build, fmt, and clippy -D warnings clean, 632 unit tests pass (+12 over baseline including the MinimalBootstrapper compat guard), and both new CLI tests pass live against real Postgres. I also independently reproduced your mutation check: with acquire_migration_lock no-op'd, both tests fail on exactly the failure modes they claim to guard (the pg_type_typname_nsp_index duplicate on the race test, the "migrate should block" assertion on the contention test).

The lock mechanics themselves check out end to end: namespaced two-arg key, try-then-block with operator visibility, dedicated session connection that dies with the process, non-reentrancy guarded, release on all exit paths with connection close as backstop. I grepped the tree for lock-bypass vectors: only init and migrate invoke migrations and both are wrapped; serve never migrates. The init ordering holds too (create_catalog_db precedes the lock acquire, so the lock connection's target database exists). The duplicated test constants in Python are self-checking: if they drifted from the Rust constants, the contention test would stop blocking and fail.

One substantive gap and two suggestions, inline below:

  1. This implements two of the three layers the containerization design (6.7) specifies. The advisory lock is done, and the pending check running under the lock subsumes the ledger re-check. The third layer, per-migration transactions, is absent: crates/storage-postgres/src/migrations.rs is untouched, and there apply and record_migration (lines ~88-95) remain separate round-trips, so a crash between them leaves an executed-but-unrecorded migration that a restart re-runs. Nothing breaks today (001's setval hazard is shielded by the adoption path, 002 is IF NOT EXISTS idempotent), but the design calls this layer out precisely because future migrations won't all be idempotent, and it also requires a kill-mid-migration test, which is likewise absent. Either add both here, or land this and file the follow-up explicitly against 6.7. It shouldn't fall silently.
  2. Your pgbouncer/RDS Proxy concern is real but can be hardened cheaply rather
    than documented away. See the comment on bootstrapper.rs.
  3. Unbounded wait is the right default; the design's (9) open question about a
    migrate --wait-timeout for init-Job ergonomics is worth answering in this
    thread so the doc converges.

@robinnsc
robinnsc force-pushed the feat/migrate-advisory-lock branch from 9442a65 to f8625f0 Compare August 4, 2026 09:07
@robinnsc

robinnsc commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, I followed the pg_locks suggestion through, but doing that exposed a flaw in the session lock version. Looks like acquiring a session lock and checking pg_locks in the next autocommit statement is not actually proxy-proof, the transaction pooling proxy can route both statements to the same arbitrary backend, make the check pass, and then route later work somewhere else. The release can also have the same problem in reverse. RDS Proxy is also different from PgBouncer here, it pins PostgreSQL sessions when the session level advisory lock functions are used, so naming it as unable to hold the lock wouldnt be accurate.

I opted to change the mechanism instead, now the dedicated catalog connection opens an explicit transaction and takes pg_advisory_xact_lock for the whole migration interval. A transaction ooler has to retain one backend until that transaction ends, and PostgreSQL releases the lock automatically on rollback or connection loss. Release is a ROLLBACK, with connection close as the backstop. The pg_locks query remains, but only as an invariant check that the expected two integer, granted ExclusiveLock is present on the current backend.

I also tightened the visibility part. The waiting line is explicitly flushed before the blocking lock call, and the contention test now reads and asserts that line while the external session still holds the lock, rather than only inspecting stdout after the process has completed. That makes the kubectl logs argument real rather than relying on how stdout happens to be buffered.

The concurrency test no longer depends on two processes happening to reach the pending check at the same time. It now inserts the 002 ledger row in another uncommitted transaction. The first migrator cannot see that row during its pending check, so it applies 002 and then blocks deterministically when it tries to record the same filename. Only then does the test start the second migrator and assert that it stops at the advisory lock and emits the waiting message. Rolling back the blocker lets the first record and release; the second then acquires the lock, observes 002 as applied, and no-ops. With the migration lock removed, the second reaches the ledger barrier instead of printing the lock-wait message, so the test fails at the property it is intended to protect.

On the per migration transaction gap, I'm feeling like hand rolling an outer transaction in this PR isnt quite the right fix. Three of the four migration files contain their own BEGIN and COMMIT. PostgreSQL does not create an inner transaction for that BEGIN, it warns that a transaction is already in progress, and the file’s COMMIT ends the one active transaction. Wrapping those files unchanged would therefore let their COMMIT end the wrapper before record_migration, putting the ledger write back outside it and giving us false atomicity.

I removed the "safe" related wording as it was bit of a strong claim at this point. The actual recovery properties would be: catalog 001 normally avoids replay because it writes the expected catalog version, but the file itself is not idempotent; data 001 has the existing adoption guard; data 002 is repeatable against the expected schema; and replaying data 003 drops and recreates the token table, so it is not data-preserving. The absence of checksums in the current ledger is not a reason to call edits to shipped migrations safe, that is the class of problem ADR-0003 and #221 are intended to remove.

Both apply sites now have a TODO(#221) stating that application and ledger recording are separate commits, spelling out those recovery limits, and requiring the sqlx adoption to remove the files’ internal transaction control and commit each migration with its ledger row before another migration lands. I used #221 as the implementation follow-up already in flight rather than opening a duplicate.

The deterministic ledger barrier also gives the kill-mid-migration test a cleaner shape when #221 lands. Hold an uncommitted conflicting row in the migration ledger, let the migration reach and block on its ledger insert, kill the process there, then release the blocker and assert that both the schema work and ledger row rolled back. That targets the apply-versus-record boundary directly; an ACCESS EXCLUSIVE lock held from the beginning would also block the initial ledger read nd would not prove the same thing.

Regarding the wait-timeout question, still feeling this PR shouldn't add a CLI timeout. A timeout converts ordinary contention into a failed or retried Job and loses the migrator’s place in the wait queue. Kubernetes can provide an explicit whole- ob bound with activeDeadlineSeconds when an operator wants one, backoffLimit controls retries rather than the duration of the current wait. If we later expose PostgreSQL lock_timeout, it needs to be applied to every migration connection to cover both the advisory-lock wait and blocking DDL, not just to the dedicated lock transaction.

@robinnsc
robinnsc requested a review from LeeroyHannigan August 4, 2026 09:17
@robinnsc
robinnsc marked this pull request as ready for review August 4, 2026 09:17
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Nice work, and good call chasing the pg_locks check far enough to find the flaw in the session-lock version.

I verified the crash-release claim by hand rather than trusting it, using your own constants: mutual exclusion holds, killing the holder's backend with no rollback drops the granted count to zero, and the peer then acquires. Gates are clean on my side too (fmt, clippy -D warnings, 632 unit tests) and both new tests pass live in 6.25s.

Two things I want to credit because they are the easy ones to get wrong. The version read and pending check are inside the lock, not just the apply, which is what actually makes this correct. And the test uses an uncommitted schema_history insert as a real barrier rather than hoping the second process shows up in time, so it does
not pass just because the machine was idle.

Needs doing before merge: the branch has gone dirty. #230 landed and touched the same two lines of devtools/run-tests, so you will conflict on the --ignore list and on CLI_ARGS. The resolution is a union, both test_cli_container_readiness.py and test_cli_migrate_concurrency.py in both lists. Worth doing carefully: the CLI
section is its own pytest invocation, so dropping a filename removes tests silently and stays green.

One ask: the wait is unbounded. After the try-lock fails you block on pg_advisory_xact_lock with no lock_timeout, so a peer that wedges without dying hangs this migrator forever. In a container that is a pod that looks alive and never finishes. SET LOCAL lock_timeout on the lock connection with an error naming the migration lock would make it diagnosable.

Offer: the crash-release property is the one thing the suite does not cover, and you are a step away. _wait_for_blocked_ledger_insert already puts the first migrator in exactly the right state. Kill it there instead of releasing the blocker and you get both a regression test for lock release on death and a reproducing test for the TODO(#221) state.

@robinnsc
robinnsc force-pushed the feat/migrate-advisory-lock branch from f8625f0 to 76d55f4 Compare August 6, 2026 10:12
@robinnsc

robinnsc commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, addressed those comments:

  • Rebased on the latest main and resolved the devtools conflict
  • The advisory lock wait now has a five minute transaction local lock_timeout. It's applied with set_config('lock_timeout', ..., true), so it's equivalent to SET LOCAL and disappears when the dedicated transaction rolls back. A 55P03 timeout becomes an explicit error saying the migration advisory lock timed out and that another migrator may be wedged, rather than surfacing as a generic database error.
  • Also added the crash release coverage. The test uses the existing uncommitted ledger row barrier, directly asserts that 002’s schema is committed while its ledger row is not, starts a peer that visibly waits on the advisory lock, then SIGKILLs the holder without calling release. The peer observes Migration lock acquired, proving PostgreSQL released the transaction-level lock when the holder connection died, and completes after the ledger blocker is rolled back. The final state has the table and exactly one ledger row.

Two replicas running `extenddb migrate` can both evaluate pending work
before either records it, then apply the same migration concurrently.
PostgreSQL can reject the duplicate DDL in its system catalogs, making
an otherwise idempotent container startup unsafe.

Serialize the complete migration decision and apply sequence with a
namespaced PostgreSQL transaction-level advisory lock. The lock is held
by an explicit transaction on a dedicated catalog connection, so it
covers the version read and data-migration pending check, catalog and
data migrations and final ledger observation. A peer acquires the
lock only after the holder finishes, then observes no pending work.

- Try `pg_try_advisory_xact_lock` first so a contending process can
  explain why it is waiting, then use `pg_advisory_xact_lock` for the
  blocking acquisition.
- Keep the explicit transaction open for the full migration interval.
  Transaction-pooling proxies retain one backend until rollback. This
  avoids session locks split across pooled backend sessions.
  Direct PostgreSQL, RDS, Aurora, PgBouncer transaction pooling, and RDS
  Proxy follow the same transaction-scoped lock contract.
- Bound the wait with a five-minute transaction-local `lock_timeout`.
  PostgreSQL SQLSTATE 55P03 becomes an actionable error naming the
  migration advisory lock and explaining that the holder may be wedged.
- Flush waiting and acquired messages so container logs show progress
  while the process is still blocked rather than only after it exits.
- Verify the granted row in `pg_locks`, including the two-integer key
  subtype, ExclusiveLock mode, and granted state. A key or lock-mode
  mismatch fails closed before migrations run.
- Release by rolling back the dedicated transaction, then close the
  connection as a backstop. PostgreSQL also rolls the transaction back
  and releases it when the process or backend dies without cleanup.
- Reject a second in-process acquire instead of deadlocking on a
  new transaction waiting for the lock already held by the first.
- Scope the lock to the catalog database. Replicas sharing that catalog
  serialize even when migration statements touch the data database;
  deployments using different catalogs remain independent.
- Lock init's schema phase too, so init and migrate cannot race. Two
  fresh init processes still do not queue. The second fails earlier
  while creating the already-existing catalog database.
- Add default no-op lock methods to `Bootstrapper`. Existing external
  backends preserve current behavior until they opt into a backend lock.
  A MinimalBootstrapper test pins defaults and trait object safety.

The advisory lock deliberately does not claim to make one migration
atomic with its ledger write. The custom runner still applies SQL and
records the filename in separate commits. Both apply sites carry a TODO
for #221, which removes the migration files' internal BEGIN/COMMIT and
lets sqlx commit each migration with its ledger row.

Current recovery properties are narrower: catalog 001 is normally
shielded by its version write, data 001 has an adoption guard, data 002
is repeatable, and replaying data 003 drops the token table. This
gap must close before another migration relies on atomic recording.

Add deterministic PostgreSQL CLI coverage:

- Hold an uncommitted `schema_history` row so the first migrator commits
  002's DDL and blocks exactly before its ledger insert. Start a second
  migrator and prove it waits. Release the barrier and verify both
  processes succeed, exactly one applies 002, and one ledger row
  exists.
- Assert `gsi_pending` is committed while the 002 ledger row remains
  invisible, pinning the known apply-versus-ledger gap.
- SIGKILL the holder without release, observe the waiting peer acquire
  the lock, then release the ledger barrier and verify recovery. This
  proves lock release on connection death.
- Hold the lock from an external session and prove migrate explains the
  wait before the holder releases it, then completes successfully.

Make subprocess reads unbuffered and cleanup exception-safe so tests
cannot miss Python-buffered data or leak children after failure.
Explicitly flush the Rust messages that the live tests observe.

Integrate main, including container-readiness and SQLite changes.
Resolve the CLI runner overlap as a union: lifecycle,
container-readiness, and migration-concurrency files are excluded from
the shared pytest run
and executed together in the dedicated PostgreSQL CLI section.

Verification on the final tree:

- fmt and clippy `-D warnings` clean
- release build clean
- 669 workspace tests pass, with 3 ignored
- 28 live PostgreSQL CLI tests pass, with 1 Unix-socket skip
- all three migration-concurrency tests pass on PostgreSQL 16.10
- a one-second timeout mutation emits the exact diagnostic in 2.21s;
  the committed value is restored to five minutes and release rebuilt
@robinnsc
robinnsc force-pushed the feat/migrate-advisory-lock branch from 76d55f4 to d466d27 Compare August 7, 2026 04:54
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.

2 participants