Skip to content

fix(backend): serialize concurrent migrations with a Postgres advisory lock - #2574

Open
feiiiiii5 wants to merge 2 commits into
rhesis-ai:mainfrom
feiiiiii5:fix/migrate-advisory-lock
Open

fix(backend): serialize concurrent migrations with a Postgres advisory lock#2574
feiiiiii5 wants to merge 2 commits into
rhesis-ai:mainfrom
feiiiiii5:fix/migrate-advisory-lock

Conversation

@feiiiiii5

Copy link
Copy Markdown
Contributor

Fixes #2545

Problem

Every backend replica runs migrate.sh at container start, so rolling out a new version with multiple Cloud Run Job instances fires alembic upgrade head against the same database simultaneously. As reported in #2545, during the v0.13.0 rollout three backend pods ran the 582-line org migration concurrently and only avoided corruption because Postgres row-level locks serialized the writes and the migration guards happened to be idempotent.

Solution

Acquire a session-scoped pg_advisory_lock in Alembic's env.py (online mode) so exactly one process performs the upgrade while any other replica waits, then finds the database already at head:

  • Dedicated autocommit connection holds the session-scoped lock for the whole upgrade; Alembic keeps using its own connection/transactions untouched.
  • Crash-safe: the lock auto-releases if a holder dies mid-upgrade (session end).
  • Bounded wait: pg_try_advisory_lock polling with ALEMBIC_LOCK_TIMEOUT (default 600s); on timeout it raises a clear RuntimeError instead of hanging replicas forever or racing through the upgrade.
  • Non-PostgreSQL databases: locking is skipped entirely (dialect guard), so community/non-PG setups are unaffected.
  • Covers every entry point: since the lock lives in env.py, manual alembic upgrade, CI jobs, and migrate.sh are all serialized — not just the shell script path.
  • migrate.sh got a short comment pointing at the mechanism for discoverability.

Lock key is the fixed constant crc32(b"rhesis-backend-migrations") = 1260693429.

Testing

Verified against Postgres 16 (local Docker, full alembic upgrade head from an empty DB to head with the change active):

Scenario Result
Fresh DB, single upgrade ✅ migrates to head, lock released after (0 rows in pg_locks)
External holder + ALEMBIC_LOCK_TIMEOUT=6 ✅ logs "Another replica is running migrations; waiting up to 6.0s", times out with clear RuntimeError, exit 1
External holder releases mid-wait (ALEMBIC_LOCK_TIMEOUT=60) ✅ warning → "Migration advisory lock acquired" → completes at head, exit 0
Re-run at head ✅ no-op
WARNI [alembic.runtime.migration] Another replica is running migrations; waiting up to 6.0s for the migration advisory lock
INFO  [alembic.runtime.migration] Migration advisory lock acquired

Also: ruff check + ruff format --check clean, bash -n migrate.sh clean.

Note

LLM-assisted contribution, per repo guidelines.

…y lock

Every backend replica runs migrate.sh at container start, so a rollout
with multiple instances fires 'alembic upgrade head' against the same
database simultaneously and only survives on row-level lock timing plus
idempotent migration guards.

Acquire a session-scoped pg_advisory_lock in alembic's env.py so exactly
one process performs the upgrade while the others wait, then find the
database already at head. The lock lives on a dedicated autocommit
connection for the whole upgrade, releases in finally, auto-releases if
a replica crashes mid-upgrade (session end), and is skipped entirely for
non-PostgreSQL databases. ALEMBIC_LOCK_TIMEOUT (default 600s) bounds the
wait so a stuck holder fails loudly instead of hanging replicas forever.

Fixes rhesis-ai#2545

Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>
@feiiiiii5
feiiiiii5 force-pushed the fix/migrate-advisory-lock branch from 27c2d64 to a1d74bc Compare August 22, 2026 16:58

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

[Improvement] The non-Postgres skip isn’t fully safe: _acquire_migration_advisory_lock() applies execution_options(isolation_level="AUTOCOMMIT") before checking dialect.name, which may fail on SQLite/other dialects. Please move the dialect guard ahead of the autocommit connection/options so non-Postgres setups truly bypass the locking path.

keeps the lock beyond ``ALEMBIC_LOCK_TIMEOUT`` seconds so orchestrators
can retry instead of racing through an upgrade.
"""
lock_connection = connectable.connect().execution_options(isolation_level="AUTOCOMMIT")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

_acquire_migration_advisory_lock() calls connectable.connect().execution_options(isolation_level="AUTOCOMMIT") before the dialect guard. On non-Postgres (e.g. SQLite), that AUTOCOMMIT isolation level can raise and would contradict the “skipped entirely” promise in the PR description.

Fix: check connectable.dialect.name first, and only then open the lock connection + apply the Postgres-specific AUTOCOMMIT option.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks fixed in c367047: dialect is checked at the engine level before opening the lock connection / applying AUTOCOMMIT, so non-Postgres setups should now skip locking cleanly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c367047 — the dialect guard now runs at engine level (connectable.dialect.name) before the lock connection is opened, so non-PostgreSQL setups never touch execution_options(...) at all. Re-ran the full verification against Postgres 16 (fresh upgrade to head, contention wait/acquire, timeout path) after the change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yep, that addresses my concern — checking connectable.dialect.name before opening the connection / applying AUTOCOMMIT means non-Postgres setups truly skip the lock path. Thanks for the quick fix + re-test.

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

Improvement: The current implementation still sets isolation_level="AUTOCOMMIT" before the Postgres dialect check in _acquire_migration_advisory_lock(). SQLAlchemy may reject that option on some non-Postgres dialects, which would break the “skip entirely for non-PostgreSQL” guarantee.

Fix: check connectable.dialect.name (engine-level) before opening/configuring the lock connection, or guard the execution_options(...) call so non-Postgres never sees it.

Move the PostgreSQL dialect check to engine level ahead of
execution_options(isolation_level=AUTOCOMMIT): some dialects reject
that option, which would break the guarantee that non-PostgreSQL
setups bypass the locking path entirely.

Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>

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

Looks good. The advisory-lock approach in Alembic env.py should reliably serialize concurrent upgrades, and the non-Postgres guard is now done before opening/applying AUTOCOMMIT. Ship it.

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.

Add advisory lock to migrate.sh so concurrent replicas can't race alembic upgrade head

1 participant