Migration Concurrency Safety
Problem
The original migration system had a race condition when multiple instances started simultaneously:
- Instance A reads
schema_migrations, sees migration X is not applied - Instance B reads
schema_migrations, sees migration X is not applied - Instance A executes migration X DDL successfully
- Instance B executes migration X DDL (may succeed if idempotent)
- Instance A inserts
version=Xintoschema_migrations→ SUCCESS - Instance B tries to insert
version=Xintoschema_migrations→ PRIMARY KEY CONFLICT → CRASH
Even though the migration was successfully applied by Instance A, Instance B would crash, preventing the application from starting.
Solution
The implementation now uses PostgreSQL's INSERT ... ON CONFLICT DO NOTHING RETURNING pattern to atomically claim migrations:
var claimedVersion string
err = db.QueryRow(
`INSERT INTO schema_migrations (version, applied_at)
VALUES ($1, CURRENT_TIMESTAMP)
ON CONFLICT (version) DO NOTHING
RETURNING version`,
entry.Name(),
).Scan(&claimedVersion)
if err != nil {
// sql.ErrNoRows means ON CONFLICT happened - another instance claimed this
log.Printf("Migration %s already claimed by another instance, skipping", entry.Name())
continue
}
// Only the instance that successfully claimed the migration reaches here
// and executes the DDL
How It Works
- Each instance attempts to insert a row into
schema_migrationsfor the migration - The
ON CONFLICT DO NOTHINGclause prevents errors if the row already exists - The
RETURNING versionclause returns the version only if the INSERT succeeded - If another instance already claimed the migration:
ON CONFLICTprevents the insert- No rows are returned
Scan()returnssql.ErrNoRows- The instance safely skips this migration
- Only one instance will successfully claim and execute each migration
Benefits
- No race conditions: The database enforces atomicity
- No crashes: Concurrent instances gracefully skip already-claimed migrations
- Simple: No need for advisory locks or external coordination
- Docker-safe: Works correctly with
docker-compose up --scale app=3 - Production-safe: Multiple pods/containers can start simultaneously
Trade-offs
- Migrations are no longer wrapped in a single transaction
- The tracking row is inserted before executing the DDL
- If a migration fails:
- The tracking row remains (prevents re-attempts)
- Manual intervention is required to fix and continue
- This is intentional: failed migrations should not auto-retry
Testing Concurrent Safety
Run the integration test with a test database:
export TEST_DATABASE_URL="postgresql://user:pass@localhost/testdb"
go test ./cmd/server -v -run TestMigrationConcurrentSafety
Or test manually with Docker Compose:
docker-compose up --scale app=3 -d
docker-compose logs app
# All three instances should start without migration conflicts