Catch exclusive locks before they take checkout down.
The flagship module. A fresh branch carrying production's shape applies the pending migrations while a second connection samples what is locked, then reports the strongest mode held per table, how long it was held, whether another session was left waiting on it, which tables were rewritten, and how the query plans moved.
rehearsal branch · observer connection at 250ms
An exclusive lock on subscriptions holds for 27 seconds. The rehearsal reports it before it ships.
A 27-second lock is a finding. Not a line in a log nobody reads.
- ACCESS EXCLUSIVE
the strongest mode held on subscriptions, sampled every 250ms
27.4s - Blocked another session
a second session was seen waiting on the lock while it was held
Yes - Table rewrite
reported by Postgres, not inferred from the statement
Yes - Plan change
EXPLAIN before and after, on production's own shape
Seq Scan
three connections · one measured lock window
Example findingA rehearsal of one migration, with the numbers chosen. The measurements are the ones af insights takes: lock mode and hold time from pg_locks, rewrites from Postgres, plans from EXPLAIN.
Measured, not inferred. The lock comes from pg_locks and the rewrite from Postgres itself.
Staging with a handful of rows will not show an exclusive lock or a table rewrite. A sampler on its own connection watches pg_locks and pg_stat_activity while the migration runs, because the session running it cannot see its own lock until the statement returns, which is exactly when the interesting part is over.
no timing inferred from SQL text
20260824_widen_plan_id lock ACCESS EXCLUSIVE subscriptions 27.4s blocked another session was seen waiting on it rewrite subscriptions rewritten in full plan events: Index Scan -> Seq Scan 12ms -> 410ms lint changing plan_id to bigint rewrites the whole table
Failures conventional tests miss. The engine measures what staging cannot.
Locks
The strongest mode held per table, how long, and whether another session was left waiting on it.
Rewrites
Full table rewrites, reported by Postgres rather than guessed from the SQL.
Plans
EXPLAIN before and against the migrated branch, on production's own shape.
Statements
Per-statement duration, so the slow one in a batch is named.
Lint
Missing lock timeouts, constraints added without NOT VALID, index builds that are not concurrent, backfills sharing a transaction with the schema change, and the rewrites and offline table operations. Each finding reaches the pull request under its own rule name with the fix attached, because a finding called migration_lint tells nobody what to change.
Comparison
A saved report from an earlier run, compared against this one.
Safer pattern: expand-and-contract. The lint rule carries the fix, not only the complaint.
Switch the film to expand-and-contract. The strongest lock drops to 0.4s, nothing is left waiting on it, and the table is not rewritten.
schema stays readable across deploys
- 01Expandadd nullable plan_id_v20.4s lock
- 02Backfillcopy values in batchesno rewrite
- 03Dual-readread both schema shapescompatible
- 04Contractdrop old column laterlater deploy
Expand
Add a second column of the new type, nullable. Old rows stay readable by both binaries.
Backfill
Copy values across in batches so checkout never waits on ACCESS EXCLUSIVE.
Dual-read
Deploy code that reads both shapes before you tighten the schema.
Contract
Drop the old column in a later migration, once nothing reads it any more.
Know what happens before you deploy.
Create a disposable production twin for every risky change. Catch migration failures before they reach customers.