Concepts¶
The advisory lock¶
Before changing anything, the library takes a PostgreSQL session-level advisory lock on a dedicated connection. Two deployments racing to migrate the same database serialise instead of interleaving.
sequenceDiagram
participant A as Instance A
participant P as PostgreSQL
participant B as Instance B
A->>P: pg_advisory_lock(id) on pinned conn
P-->>A: granted
B->>P: pg_advisory_lock(id)
Note over B,P: waits, bounded by lock_timeout
A->>P: run migrations
A->>P: pg_advisory_unlock(id)
P-->>B: granted
B->>P: nothing left to apply
Why the timeout is server-side¶
The wait is bounded by PostgreSQL's lock_timeout, not by cancelling the query
from the client. That distinction is load-bearing: a cancelled query may
already have been granted the lock, and since the connection then goes back to
the pool rather than being closed, the lock would be stranded on a pooled
connection with nothing tracking it. A server-side abort is unambiguous — the
lock was not granted.
lock_timeout is set with transaction scope, so it reverts on commit and never
leaks onto the pooled connection. The advisory lock itself is session-scoped and
outlives that transaction.
Why two connections¶
(*sql.Conn).Close returns a connection to the pool; it does not end the
session. A session-level advisory lock survives it. So the lock connection stays
pinned for the whole run while the executor issues its queries through the same
*sql.DB.
With SetMaxOpenConns(1) the lock would permanently own the only slot and every
other query would hang forever with no way to time out. Rather than deadlock, the
library fails fast with ErrPoolTooSmall.
Configuring it¶
migrate.WithLockTimeout(30 * time.Second) // 0 waits indefinitely
migrate.WithLockID(8127364512) // isolate from another tool's lock
The default timeout is 5s for the library and 5m for the CLI — a CLI run is usually part of a deployment, where a busy lock means another instance is mid-migration and waiting it out beats failing the rollout.
Reconciliation¶
Reconcile compares the file source against the bookkeeping table and produces
a plan: what to roll back, what to apply.
| Situation | Without options | With the option |
|---|---|---|
| DB has migrations missing from files | ErrDivergence |
WithRollback() rolls them back |
| Files contain IDs sorting before the latest applied | ErrInterleaved |
WithInterleaved() applies them anyway |
| Source empty, DB non-empty | ErrEmptySource |
WithAllowEmptySource() proceeds |
Interleaved migrations¶
Two developers branch at 0005. One merges 0006, the other merges 0006_b
after it is already applied. The second migration sorts before the latest
applied one — it goes in out of order.
Sometimes that is fine (independent tables). Sometimes it is a disaster (the
later migration assumed the earlier one ran). The library refuses by default and
reports which migrations are affected in Result.Interleaved, so the decision is
explicit.
The bookkeeping table¶
State lives in a table named migrations by default; WithTableName and
WithSchema change that. Unqualified by default, resolved through the
connection's search_path.
| Column | Purpose |
|---|---|
seq |
BIGINT GENERATED ALWAYS AS IDENTITY — the ordering key |
id |
Migration ID (primary key) |
up_script |
The up script as applied |
down_script |
The rollback script, executed on rollback |
applied_at |
TIMESTAMPTZ — reporting only, never ordering |
error |
NULL on success; the recorded failure otherwise |
Why seq and not applied_at¶
Wall-clock time is not a reliable order:
- Two migrations applied in the same transaction can share a timestamp exactly.
- Time moves backwards across a DST transition, or when two sessions disagree about the time zone.
That order decides which migrations "the last N" and a rollback act on. Ordering
by applied_at would occasionally roll back the wrong ones — rarely, silently,
and at the worst possible moment. seq is monotonic by construction.
Failure handling¶
Transactional migrations¶
The default. The script and its bookkeeping row commit or fail together — a failure leaves no trace, and the next run retries from the same state.
notransaction migrations¶
There is no transaction to roll back, so the library records what happened:
flowchart TD
A[Run statements on pinned connection] --> B{All succeeded?}
B -->|Yes| C[INSERT row, error = NULL]
B -->|No| D[INSERT row, error = message]
D --> E[Every later Up/Down/Reconcile refuses<br/>with ErrFailedMigrations]
E --> F[Human inspects, fixes schema by hand]
F --> G[Forget clears the row]
G --> H[Normal operation resumes]
The bookkeeping write happens even when the script failed, and it is detached from the caller's context with its own timeout. Losing that row is worse than anything it could report: the next run would re-apply a migration that already half-ran.
If the schema changed but the row could not be written, you get ErrUnrecorded —
the database and the ledger disagree, and that needs a human.
Why failure is terminal¶
ErrFailedMigrations is not transient. A deployment pipeline should stop, not
retry. Recovering automatically would mean either running a rollback script
against a state it was never written for, or rolling back subsequent successful
migrations. Both destroy work based on a guess about what the operator wanted.
Refusing costs a human a few minutes. Guessing wrong costs data.