One line stops a migration from taking down production. Almost nobody adds it.
A single idle transaction, one ALTER TABLE queued behind it, and every read on the table queued behind that. Measured, with the pg_locks output, and the lock_timeout setting that turns a 26 second outage into a 2 second retry.
Here is how a migration that does no work at all takes a table offline.
ALTER TABLE orders ADD COLUMN note text;
A nullable column, no default. This is a catalog change; on its own it holds its lock for a few milliseconds. It does not matter how big the table is. What matters is who else is holding a lock on the table when it asks for its own, and that is something the migration author can not see and did not think about, because the statement is so obviously harmless.
The queue
ALTER TABLE needs ACCESS EXCLUSIVE, the lock that conflicts with every
other lock including the ACCESS SHARE a plain SELECT takes. So if any
transaction is holding any lock on the table, the ALTER waits. A long report query. A
transaction some worker opened, ran one select in, and then went to make an HTTP call. An
interactive psql session someone forgot about at lunch.
Now the important part. Postgres grants locks in order. Once the ALTER is in the queue, every new
request that conflicts with ACCESS EXCLUSIVE queues behind it, and everything
conflicts with ACCESS EXCLUSIVE. Every read. Every write. The table is not locked by
the ALTER, which has not started yet. It is locked by the ALTER's request, on behalf of a
transaction that is doing nothing.
We reproduced it on Postgres 16 with a 20 million row table. Session one opens a transaction, runs one cheap select, and idles. Session two runs the ALTER. Session three runs an ordinary point read.
pid | state | wait_type | wait_event | query
-------+--------+-----------+------------+---------------------------------------------
10542 | active | Timeout | PgSleep | BEGIN; SELECT count(*) FROM orders WHERE id < 10
10549 | active | Lock | relation | ALTER TABLE orders ADD COLUMN note text;
10556 | active | Lock | relation | SELECT count(*) FROM orders WHERE id = 5;
pid | mode | granted | query
-------+---------------------+---------+------------------------------------------
10542 | AccessShareLock | t | BEGIN; SELECT count(*) FROM orders WHERE
10549 | AccessExclusiveLock | f | ALTER TABLE orders ADD COLUMN note text;
10556 | AccessShareLock | f | SELECT count(*) FROM orders WHERE id = 5
The point read at the bottom wants the same AccessShareLock the idle session
already holds, and those two do not conflict with each other at all. It is waiting anyway,
because the ALTER is in front of it. In our run the idle transaction held on for thirty seconds,
the ALTER finished after 28.1 s, and the point read, a query that takes under a millisecond,
waited 26.1 s. Multiply by every connection in the pool and that is the outage: not a slow
migration, a fast one, stuck behind something irrelevant, with the whole application stuck behind
it.
The one line
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN note text;
Same scenario, same idle transaction. The ALTER waited two seconds, gave up with
canceling statement due to lock timeout, and left the queue. The point read that had
been queued behind it ran immediatly: 0.1 s including the round trip. Nothing was locked, nothing
was changed, and the migration can be retried in a loop until it gets a gap, which on most
systems is the very next attempt.
Two details that matter. lock_timeout is not statement_timeout: the
latter limits how long the statement may run, which for an index build or a backfill is
the wrong thing to cap, while the former only limits how long it may wait for a lock,
which is exactly the dangerous part. And set it per session or per migration, not globally;
the application's own queries should not be cancelled for waiting on a lock, only the schema
change should.
-- a retry loop the migration runner can own
DO $$
BEGIN
FOR i IN 1..10 LOOP
BEGIN
SET LOCAL lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN note text;
RETURN;
EXCEPTION WHEN lock_not_available THEN
PERFORM pg_sleep(1);
END;
END LOOP;
RAISE 'could not take the lock after 10 attempts';
END $$;
Why almost nobody adds it
Because the statement without it works. i have added this line to more migrations than i can
count and it has never once been the wrong call, but every time, in development, in staging, and in
production nine times out of ten, the version without it works too. The tenth time is the one with the idle transaction, and by
then the migration has been copy-pasted from the previous nine. The guard is not a fix for a bug
in the SQL. It is an admission that the SQL will run in an environment the author can not see,
which is true of every migration and is the whole reason
BV034 exists: it fires on exclusive-lock DDL that has no
lock_timeout in scope, so the line gets added before the tenth time, not after.