Summary
Boot-time schema work has no cross-instance mutual exclusion, so two overlapping boots against the shared Postgres can re-execute DML backfills and corrupt data — and the boot-time column widening takes ~20 ACCESS EXCLUSIVE locks with no lock_timeout on every start.
docker-compose.yml:14 advertises "multi-instance capable", and overlapping boots are reachable today during a redeploy-companion container recreate or a healthcheck-triggered restart.
Mechanism (verified at HEAD, 776c414)
src/selfhost/migrate.ts:124-222 takes no advisory lock: it reads the ledger at :135 and, on failure, falls into a tolerant per-statement path at :208-217. It is called unguarded at src/server.ts:554-557.
Sequence with two instances and a pending migration:
- A applies the migration file atomically.
- B's
execTransaction fails with "already exists" ⇒ treated as drift ⇒ tolerant per-statement path.
CREATE TABLE is tolerated — but a table-rebuild INSERT … SELECT or UPDATE backfill re-executes, producing duplicate or corrupted rows.
- B's ledger
INSERT then dies on duplicate key value violates unique constraint, which matches neither duplicate column nor already exists (migrate.ts:197, :214) ⇒ B crash-loops one cycle.
#9027 made migrations atomic against the crash case; the concurrency case was not covered.
Separately, widenGithubIdColumnsToBigint (src/selfhost/pg-adapter.ts:167-208) takes ~20 ACCESS EXCLUSIVE locks on every boot with no lock_timeout. On first application it fully rewrites webhook_events (high-volume, 14-day window, 111 MB / 302,588 rows measured on edge-nl-01). With a sibling serving traffic, the lock queue stalls both the boot and the sibling's queries indefinitely.
Deliverables
Tests
Summary
Boot-time schema work has no cross-instance mutual exclusion, so two overlapping boots against the shared Postgres can re-execute DML backfills and corrupt data — and the boot-time column widening takes ~20
ACCESS EXCLUSIVElocks with nolock_timeouton every start.docker-compose.yml:14advertises "multi-instance capable", and overlapping boots are reachable today during a redeploy-companion container recreate or a healthcheck-triggered restart.Mechanism (verified at HEAD, 776c414)
src/selfhost/migrate.ts:124-222takes no advisory lock: it reads the ledger at:135and, on failure, falls into a tolerant per-statement path at:208-217. It is called unguarded atsrc/server.ts:554-557.Sequence with two instances and a pending migration:
execTransactionfails with "already exists" ⇒ treated as drift ⇒ tolerant per-statement path.CREATE TABLEis tolerated — but a table-rebuildINSERT … SELECTorUPDATEbackfill re-executes, producing duplicate or corrupted rows.INSERTthen dies onduplicate key value violates unique constraint, which matches neitherduplicate columnnoralready exists(migrate.ts:197,:214) ⇒ B crash-loops one cycle.#9027 made migrations atomic against the crash case; the concurrency case was not covered.
Separately,
widenGithubIdColumnsToBigint(src/selfhost/pg-adapter.ts:167-208) takes ~20ACCESS EXCLUSIVElocks on every boot with nolock_timeout. On first application it fully rewriteswebhook_events(high-volume, 14-day window, 111 MB / 302,588 rows measured onedge-nl-01). With a sibling serving traffic, the lock queue stalls both the boot and the sibling's queries indefinitely.Deliverables
runSelfHostMigrationsand the boot-timeALTERs in apg_advisory_lockon the Postgres backend (sqlite is single-process and needs nothing).lock_timeoutaround the idempotent bootALTERs so a contended lock fails fast and retries instead of stalling the deployment.duplicate key value violates unique constrainton the ledger insert to the recognised "already applied" set, so a losing instance exits cleanly rather than crash-looping.Tests
runSelfHostMigrationsagainst the same database ⇒ exactly one applies; the other waits and exits cleanly; no backfill runs twice.