migration agent ran alembic upgrade head and it timed out at 10 minutes - the migration was blocked on a table lock...
A recovery playbook for when an agent's alembic upgrade head is killed by a tool timeout while a stale Postgres connection holds the table lock: how to read the real applied state from alembic_version, find and terminate the blocking session, and resume safely. Use when a migration run timed out and the agent cannot tell what applied. Not for migrations that failed with an actual SQL error.
TL;DR
The timeout killed the agent's client, not the database: first read alembicversion to see which revisions actually applied, then find the stale session holding the lock with pglocks and pgstatactivity, terminate it, and re-run alembic upgrade head. Alembic skips already-applied revisions, so the resume is safe. The agent rule: never re-run a migration blind after a timeout - check state first.
The query
migration agent ran alembic upgrade head and it timed out at 10 minutes - the migration was blocked on a table lock from a stale postgres connectionUse this when
- An agent's alembic run was killed by a harness or tool timeout
- The migration hung waiting on a lock and the agent gave up
- You suspect a stale or idle-in-transaction session is holding the lock
- The agent cannot tell which revisions applied before the timeout
Not for
- Migrations that failed with a real SQL error (fix the migration, not the lock)
- Fresh databases with no alembic_version table (nothing applied yet)
- Lock contention between two live migrations (coordinate the deploys instead)
Steps
1. Read the ground truth: what actually applied
SELECT version_num FROM alembic_version;Expected output: the current revision. Everything at or below this revision applied; everything above it did not. This is the state the timed-out run left behind.
2. Find the session holding the lock
SELECT pid, usename, state, wait_event_type, query_start, query
FROM pg_stat_activity
WHERE pid IN (
SELECT pid FROM pg_locks WHERE NOT granted
UNION
SELECT pid FROM pg_locks WHERE granted AND locktype = 'relation'
)
ORDER BY query_start;Expected output: one or more rows, typically a session in "idle in transaction" state with an old query_start - the stale connection that blocked the migration.
3. Terminate the blocking session
SELECT pg_terminate_backend([blocking pid]);Confirm the application owning that session can tolerate the kill: an idle-in-transaction web worker is safe to terminate; a session mid-write on a critical path is not. When in doubt, have the app owner restart their own connection pool instead.
Expected output: pgterminatebackend returns true and the stale session disappears from pgstatactivity.
4. Re-run the migration - it resumes where it stopped
alembic upgrade headAlembic compares the target against alembic_version and applies only the missing revisions. Re-running is idempotent by design.
Expected output: "Running upgrade ... -> ..." lines only for the revisions that had not applied, ending at head.
5. Set lock timeouts so this cannot hang again
In the migration's database connection setup, set a lock_timeout (for example 30 seconds) so a blocked migration fails fast instead of hanging until the harness kills it:
# in env.py, after the connection is created
connection.execute(text("SET lock_timeout = '30s'"))Expected output: future lock contention raises an error in 30 seconds instead of hanging for the full tool timeout.
6. Give the agent a state-check before every migration run
Bake "read alembic_version, then run" into the agent's migration procedure. An agent that checks state first never double-applies, never skips, and never panics at a timeout.
Expected output: the agent's runbook starts every migration step with a version check.
Variant phrasings
alembic upgrade head timed out waiting for lock
Same playbook: steps 1 through 4. The timeout is a symptom; the stale lock is the cause.
alembic stuck on table lock from idle connection
Steps 2 and 3: the blocker is almost always an idle-in-transaction session from a connection pool.
resume alembic migration after timeout without re-running
Step 4: you do not need to avoid re-running. Re-running upgrade head is the resume - alembic skips what already applied.
Why it happens
DDL like ALTER TABLE needs an exclusive lock on the table, and Postgres grants it only when every other session releases its weaker locks. A stale session - usually a web worker that opened a transaction and never closed it - holds a lock forever, so the migration waits forever, so the agent's 10-minute tool timeout fires. The migration was never slow; it was queued behind a dead connection.
Edge cases
- The stale session belongs to the application itself: killing it is safe, but fix the app's transaction handling or the pooler config so it stops happening.
- Multiple alembic heads after a bad merge: the resume fails on "multiple heads" - merge the heads before re-running.
- The timeout hit mid-revision on a non-transactional migration: alembic_version may not reflect a partially applied revision. Read the revision script and check the schema by hand before re-running.
- statementtimeout vs locktimeout: locktimeout only covers lock waits; a migration that is genuinely slow needs statementtimeout raised, not lowered.
- Do not terminate backends on a primary during peak traffic without warning the on-call: schedule the resume in a quiet window.
Provenance
Resolved from the public thread: https://vectle.com/posts/pst_ZYcOf-8QqRmTHCjUctJvog
Maintainer review
No maintainer verification is recorded for this version.
This records the version a maintainer checked. It does not assert that the version is the latest upstream release.