Migration runner is fragile under parallel-merge — replace single-counter tracker with applied-set #44
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What happened (2026-05-20)
paliad/mainlanded three migration-bearing merges in close succession:6c7e9ef— fermi's t-paliad-207 follow-ups, added mig 104 + mig 105.6a20241— hertz's t-paliad-216 Slice A, added mig 103.e035512— Madrid, added mig 106.Each push triggered a Dokploy redeploy. The deploys ran sequentially with the binary embedded at that revision. Result on production paliad.de:
migrate.Up()only runs versions strictly greater than current → mig 103 silently skipped. Counter stays 105.Production DB state after all three deploys: schema_migrations.version=106, dirty=false, but
paliad.approval_requestshas neithercounter_payloadnorprevious_request_idcolumns and the status CHECK constraint never got'changes_requested'added. The Slice A backend code was deployed against a schema that didn't support it. Recovered by manually applying mig 103 via Supabase SQL, but the recovery is invisible to migrate — schema_migrations still says 106, so a futuremigrate downwould skip 103's down too.Root cause
golang-migrate/migrate/v4tracks applied state as a single integer inpaliad.paliad_schema_migrations(version int, dirty bool). The semantics are "every version ≤ current is considered applied." There is no way to express "version 103 was skipped, please come back to it." In a parallel-merge workflow where two workers' migration numbers race, whichever one lands first claims the counter and the other gets permanently skipped.The rebase-and-renumber guidance in the project CLAUDE.md and the head SKILL.md mitigates this when the head catches it pre-merge — but it relies on every head reading the migrations directory before every merge. That's brittle. The runner itself should be the safety net, not the convention.
Goal
Replace (or wrap) the migration runner with a gap-tolerant version. Specifically: track applied migrations as a set, not a counter. Every deploy scans the embedded FS, compares against the applied-set, and runs anything missing — regardless of when it was authored or which version is currently "highest."
Proposed shape
New table
paliad.applied_migrations(version int PRIMARY KEY, name text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now(), checksum text NULL). One row per applied migration. Thechecksumcolumn is forward-looking — drift detection if we ever care.New runner in
internal/db/migrate.go(or wherever the current one lives):\d+_*.up.sql, parse the version prefix.applied_migrations, run the SQL in a transaction together withINSERT INTO applied_migrations(version, name, ...) VALUES (...). All-or-nothing per migration.Backfill: a one-shot migration
107_backfill_applied_migrations.up.sqlthat populatesapplied_migrationsfrom the existing schema (every embedded mig version + mig 103, which was manually applied). Idempotent. After this lands, the new runner takes over.Keep
paliad_schema_migrationsfor read-only display (or drop after burn-in if nothing reads it). Don't write to it from the new runner — the source of truth isapplied_migrations. Optionally a periodic consistency check that flags divergence.Drift detection (forward-looking, optional v1): store the SHA-256 of each migration's SQL in
checksumon apply. On every deploy, the runner re-hashes the embedded version and compares; if a previously-applied migration's content has changed, that's a programmer error (you mutated a file that was already shipped). Hard-fail with a clear message.Acceptance
applied_migrationsruns on the next deploy.applied_migrations(via the backfill).applied_migrationson successful down-run.Out of scope
embed.FS— both are smaller than a library swap.Role recommendation
inventor — schema + runner contract + backfill plan deserve a design pass before any code. Then coder shift for the build. Branch convention:
mai/<inventor>/migration-runner-applied-set. The fix is small but sensitive (touches every future migration), so design-first is worth the round-trip.