-
Notifications
You must be signed in to change notification settings - Fork 1
Fix migration race condition in concurrent container startups #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
09f83b3
85d449f
030bc31
f343516
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,7 @@ | ||||||||||||||||||
| package main | ||||||||||||||||||
|
|
||||||||||||||||||
| import ( | ||||||||||||||||||
| "database/sql" | ||||||||||||||||||
| "log" | ||||||||||||||||||
| "net/http" | ||||||||||||||||||
| "os" | ||||||||||||||||||
|
|
@@ -294,38 +295,45 @@ func runMigrations(db *database.DB) error { | |||||||||||||||||
| continue | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| log.Printf("Applying migration: %s", entry.Name()) | ||||||||||||||||||
|
|
||||||||||||||||||
| // Read migration file | ||||||||||||||||||
| migrationSQL, err := os.ReadFile("migrations/" + entry.Name()) | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return err | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Start transaction for this migration | ||||||||||||||||||
| tx, err := db.Begin() | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| // Try to claim this migration using INSERT ... ON CONFLICT | ||||||||||||||||||
| // This ensures only one instance will successfully claim and execute the migration | ||||||||||||||||||
| var claimedVersion string | ||||||||||||||||||
| err = db.QueryRow( | ||||||||||||||||||
| `INSERT INTO schema_migrations (version, applied_at) | ||||||||||||||||||
| VALUES ($1, CURRENT_TIMESTAMP) | ||||||||||||||||||
| ON CONFLICT (version) DO NOTHING | ||||||||||||||||||
| RETURNING version`, | ||||||||||||||||||
|
Comment on lines
+302
to
+305
|
||||||||||||||||||
| `INSERT INTO schema_migrations (version, applied_at) | |
| VALUES ($1, CURRENT_TIMESTAMP) | |
| ON CONFLICT (version) DO NOTHING | |
| RETURNING version`, | |
| `INSERT INTO schema_migrations (version, applied_at) | |
| VALUES ($1, CURRENT_TIMESTAMP) | |
| ON CONFLICT (version) DO NOTHING | |
| RETURNING version`, |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The variable claimedVersion is declared and populated via Scan() but never used afterward. While this is necessary for the Scan() call to work (scanning the RETURNING value distinguishes successful INSERT from ON CONFLICT), consider adding a comment explaining why this variable exists, or adding a sanity check like if claimedVersion != entry.Name() to verify the claim succeeded for the expected migration.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says 'We don't use a transaction here because we've already inserted the tracking row', but it doesn't explain why this is the desired behavior. Consider expanding this comment to mention the trade-off: without a transaction, if DDL execution fails, the tracking row remains, which prevents automatic retries and requires manual intervention. This is intentional to avoid retry loops with failing migrations, as documented in MIGRATION_CONCURRENCY.md.
| // Note: We don't use a transaction here because we've already inserted the tracking row | |
| // If the migration fails, the tracking row remains as a record that it was attempted | |
| // Note: We intentionally do not wrap this in a transaction because the tracking row | |
| // in schema_migrations has already been inserted to "claim" the migration. If the DDL | |
| // execution fails, the tracking row is left in place. This prevents automatic retries | |
| // of a migration that is known to be failing, avoiding retry loops across multiple | |
| // instances. Instead, manual intervention is required to fix the migration and adjust | |
| // the tracking row if appropriate. See MIGRATION_CONCURRENCY.md for the full rationale. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,122 @@ | ||||||||||||||||||||||
| package main | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import ( | ||||||||||||||||||||||
| "database/sql" | ||||||||||||||||||||||
| "os" | ||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||
| "sync" | ||||||||||||||||||||||
| "testing" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| _ "github.com/lib/pq" | ||||||||||||||||||||||
| "github.com/techbuzzz/agent-shaker/internal/database" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // TestMigrationConcurrentSafety verifies that concurrent migration attempts | ||||||||||||||||||||||
| // don't cause conflicts or duplicate executions | ||||||||||||||||||||||
| func TestMigrationConcurrentSafety(t *testing.T) { | ||||||||||||||||||||||
| // Skip if no test database is available | ||||||||||||||||||||||
| dbURL := os.Getenv("TEST_DATABASE_URL") | ||||||||||||||||||||||
| if dbURL == "" { | ||||||||||||||||||||||
| t.Skip("TEST_DATABASE_URL not set, skipping integration test") | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Clean up any existing test schema | ||||||||||||||||||||||
| cleanupDB(t, dbURL) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Create temporary test migration file | ||||||||||||||||||||||
| tmpDir := t.TempDir() | ||||||||||||||||||||||
| migrationFile := filepath.Join(tmpDir, "001_test.sql") | ||||||||||||||||||||||
| migrationSQL := `CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name TEXT);` | ||||||||||||||||||||||
| if err := os.WriteFile(migrationFile, []byte(migrationSQL), 0644); err != nil { | ||||||||||||||||||||||
| t.Fatalf("Failed to create test migration: %v", err) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+26
to
+32
|
||||||||||||||||||||||
| // Create temporary test migration file | |
| tmpDir := t.TempDir() | |
| migrationFile := filepath.Join(tmpDir, "001_test.sql") | |
| migrationSQL := `CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name TEXT);` | |
| if err := os.WriteFile(migrationFile, []byte(migrationSQL), 0644); err != nil { | |
| t.Fatalf("Failed to create test migration: %v", err) | |
| } | |
| // Set up temporary migrations directory and test migration file | |
| tmpDir := t.TempDir() | |
| migrationSQL := `CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name TEXT);` |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error from os.Getwd() is ignored using the blank identifier. If os.Getwd() fails, oldWd will be an empty string, and the deferred os.Chdir(oldWd) will fail silently, leaving the working directory changed. This error should be checked and the test should fail if it cannot obtain the current working directory.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test changes the working directory at line 44 but this could affect other tests running concurrently in the same process. While t.TempDir() provides isolation for the temporary directory, changing the process-wide current working directory with os.Chdir() is not test-safe. Consider using the full path to the migrations directory instead of relying on a relative path, or ensure the runMigrations function accepts a directory parameter for testing.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test creates database connections inside each goroutine but doesn't ensure all goroutines start executing runMigrations simultaneously, which could reduce the effectiveness of testing the race condition. Consider using a sync mechanism like sync.WaitGroup with a countdown and wg.Wait() to ensure all goroutines reach the migration execution point before any proceed, maximizing the chance of detecting race conditions. For example, add a shared startWg that each goroutine adds to, then all wait on before calling runMigrations.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test checks that exactly one migration record exists in schema_migrations (line 92), but the test doesn't verify that the migration was executed only once. If the migration SQL CREATE TABLE IF NOT EXISTS test_table runs multiple times, it would succeed each time due to IF NOT EXISTS. A more robust test would use a migration that fails on duplicate execution (e.g., CREATE TABLE test_table without IF NOT EXISTS) to verify the claiming mechanism truly prevents concurrent execution, or track execution count in a different way.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cleanup function ignores errors from db.Exec() using the blank identifier. While this might be intentional for cleanup (to avoid failures during teardown), if the cleanup fails silently, subsequent test runs could be affected by leftover data. Consider logging warnings for cleanup failures using t.Logf() so test authors are aware when cleanup doesn't succeed, which could help debug flaky tests.
| _, _ = db.Exec("DROP TABLE IF EXISTS test_table CASCADE") | |
| _, _ = db.Exec("DROP TABLE IF EXISTS schema_migrations CASCADE") | |
| if _, err := db.Exec("DROP TABLE IF EXISTS test_table CASCADE"); err != nil { | |
| t.Logf("warning: failed to drop test_table during cleanup: %v", err) | |
| } | |
| if _, err := db.Exec("DROP TABLE IF EXISTS schema_migrations CASCADE"); err != nil { | |
| t.Logf("warning: failed to drop schema_migrations during cleanup: %v", err) | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,84 @@ | ||||||||||||||||||||||||||
| # Migration Concurrency Safety | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ## Problem | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| The original migration system had a race condition when multiple instances started simultaneously: | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 1. **Instance A** reads `schema_migrations`, sees migration X is not applied | ||||||||||||||||||||||||||
| 2. **Instance B** reads `schema_migrations`, sees migration X is not applied | ||||||||||||||||||||||||||
| 3. **Instance A** executes migration X DDL successfully | ||||||||||||||||||||||||||
| 4. **Instance B** executes migration X DDL (may succeed if idempotent) | ||||||||||||||||||||||||||
| 5. **Instance A** inserts `version=X` into `schema_migrations` → SUCCESS | ||||||||||||||||||||||||||
| 6. **Instance B** tries to insert `version=X` into `schema_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: | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ```go | ||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||
|
Comment on lines
+30
to
+33
|
||||||||||||||||||||||||||
| 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 | |
| if errors.Is(err, sql.ErrNoRows) { | |
| // ON CONFLICT happened - another instance claimed this | |
| log.Printf("Migration %s already claimed by another instance, skipping", entry.Name()) | |
| continue | |
| } else if err != nil { | |
| // Any other error means something went wrong claiming the migration | |
| log.Printf("Failed to claim migration %s: %v", entry.Name(), err) | |
| return err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The SQL INSERT statement uses
applied_atin the VALUES clause but also sets it viaCURRENT_TIMESTAMP. However, the schema at line 256 shows thatapplied_athas aDEFAULT CURRENT_TIMESTAMPclause. Explicitly specifyingCURRENT_TIMESTAMPin the INSERT is redundant with the default and could be omitted for cleaner code, or the column name should be included in the INSERT if you want to be explicit about all columns being set.