Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 32 additions & 24 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"database/sql"
"log"
"net/http"
"os"
Expand Down Expand Up @@ -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)
Comment on lines +302 to +303

Copilot AI Jan 27, 2026

Copy link

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_at in the VALUES clause but also sets it via CURRENT_TIMESTAMP. However, the schema at line 256 shows that applied_at has a DEFAULT CURRENT_TIMESTAMP clause. Explicitly specifying CURRENT_TIMESTAMP in 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.

Suggested change
`INSERT INTO schema_migrations (version, applied_at)
VALUES ($1, CURRENT_TIMESTAMP)
`INSERT INTO schema_migrations (version)
VALUES ($1)

Copilot uses AI. Check for mistakes.
ON CONFLICT (version) DO NOTHING
RETURNING version`,
Comment on lines +302 to +305

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: the SQL query uses tabs for the first level of indentation (VALUES, ON CONFLICT, RETURNING) but the surrounding Go code likely uses tabs consistently. The query string should use consistent indentation, preferably aligning with common Go formatting conventions for multi-line strings.

Suggested change
`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 uses AI. Check for mistakes.
entry.Name(),
).Scan(&claimedVersion)
Comment on lines +300 to +307

Copilot AI Jan 27, 2026

Copy link

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 uses AI. Check for mistakes.

if err == sql.ErrNoRows {
// ON CONFLICT happened - another instance already claimed this migration
// This is expected in concurrent scenarios, not an error
log.Printf("Migration %s already claimed by another instance, skipping", entry.Name())
continue
} else if err != nil {
// Unexpected database error
return err
}

// Execute migration
if _, err := tx.Exec(string(migrationSQL)); err != nil {
tx.Rollback()
return err
}
log.Printf("Applying migration: %s", entry.Name())

// Record migration as applied
_, err = tx.Exec(
"INSERT INTO schema_migrations (version) VALUES ($1)",
entry.Name(),
)
// Read migration file
migrationSQL, err := os.ReadFile("migrations/" + entry.Name())
if err != nil {
tx.Rollback()
// Migration was claimed but can't be read - attempt to remove the claim
if _, delErr := db.Exec("DELETE FROM schema_migrations WHERE version = $1", entry.Name()); delErr != nil {
log.Printf("Warning: failed to remove claim for %s after read error: %v", entry.Name(), delErr)
}
return err
}

// Commit transaction
if err := tx.Commit(); err != nil {
// Execute migration DDL
// 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
Comment on lines +332 to +333

Copilot AI Jan 27, 2026

Copy link

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
if _, err := db.Exec(string(migrationSQL)); err != nil {
log.Printf("✗ Failed to apply migration %s: %v", entry.Name(), err)
// Leave the tracking row to prevent re-attempts; manual intervention required
return err
}

Expand Down
122 changes: 122 additions & 0 deletions cmd/server/migrations_test.go
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

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test creates a migration file at line 28 in tmpDir, then creates a migrations subdirectory at lines 36-42 with the same migration. The file created at line 28 is never used and could cause confusion. Only the migration in the migrations/ subdirectory is needed for the test since runMigrations reads from that directory.

Suggested change
// 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 uses AI. Check for mistakes.

// Change to temp directory for migration discovery
oldWd, _ := os.Getwd()

Copilot AI Jan 27, 2026

Copy link

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 uses AI. Check for mistakes.
migrationsDir := filepath.Join(tmpDir, "migrations")
if err := os.Mkdir(migrationsDir, 0755); err != nil {
t.Fatalf("Failed to create migrations dir: %v", err)
}
if err := os.WriteFile(filepath.Join(migrationsDir, "001_test.sql"), []byte(migrationSQL), 0644); err != nil {
t.Fatalf("Failed to create migration in migrations dir: %v", err)
}
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
Comment on lines +43 to +44

Copilot AI Jan 27, 2026

Copy link

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 uses AI. Check for mistakes.

// Test concurrent migration attempts
const numConcurrent = 5
var wg sync.WaitGroup
errors := make(chan error, numConcurrent)

for i := 0; i < numConcurrent; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()

// Each goroutine creates its own DB connection
db, err := database.NewDB(dbURL)
if err != nil {
errors <- err
return
}
defer db.Close()

// Attempt to run migrations
if err := runMigrations(db); err != nil {
errors <- err
}
}(i)
Comment on lines +51 to +68

Copilot AI Jan 27, 2026

Copy link

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 uses AI. Check for mistakes.
}

wg.Wait()
close(errors)

// Check for any errors
for err := range errors {
t.Errorf("Migration error: %v", err)
}

// Verify migration was applied exactly once
db, err := database.NewDB(dbURL)
if err != nil {
t.Fatalf("Failed to connect to verify: %v", err)
}
defer db.Close()

// Check schema_migrations table
var count int
err = db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = '001_test.sql'").Scan(&count)
if err != nil {
t.Fatalf("Failed to query migrations: %v", err)
}
if count != 1 {
t.Errorf("Expected migration to be recorded exactly once, got %d times", count)
}
Comment on lines +88 to +94

Copilot AI Jan 27, 2026

Copy link

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 uses AI. Check for mistakes.

// Verify the test table was created
var exists bool
err = db.QueryRow(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'test_table'
)
`).Scan(&exists)
if err != nil {
t.Fatalf("Failed to check table existence: %v", err)
}
if !exists {
t.Error("Expected test_table to exist after migration")
}
}

func cleanupDB(t *testing.T, dbURL string) {
db, err := sql.Open("postgres", dbURL)
if err != nil {
t.Fatalf("Failed to open DB for cleanup: %v", err)
}
defer db.Close()

// Drop test tables
_, _ = db.Exec("DROP TABLE IF EXISTS test_table CASCADE")
_, _ = db.Exec("DROP TABLE IF EXISTS schema_migrations CASCADE")
Comment on lines +120 to +121

Copilot AI Jan 27, 2026

Copy link

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.

Suggested change
_, _ = 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)
}

Copilot uses AI. Check for mistakes.
}
84 changes: 84 additions & 0 deletions docs/MIGRATION_CONCURRENCY.md
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

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling shown in this documentation example is inconsistent with the actual implementation in main.go. The actual implementation at lines 309-317 checks for sql.ErrNoRows first and then handles other errors separately with an else if. This documentation suggests any error triggers the skip behavior, which is incorrect. The documentation should show the same two-branch error handling pattern used in the actual code.

Suggested change
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

Copilot uses AI. Check for mistakes.
}

// Only the instance that successfully claimed the migration reaches here
// and executes the DDL
```

### How It Works

1. Each instance attempts to insert a row into `schema_migrations` for the migration
2. The `ON CONFLICT DO NOTHING` clause prevents errors if the row already exists
3. The `RETURNING version` clause returns the version only if the INSERT succeeded
4. If another instance already claimed the migration:
- `ON CONFLICT` prevents the insert
- No rows are returned
- `Scan()` returns `sql.ErrNoRows`
- The instance safely skips this migration
5. 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:

```bash
export TEST_DATABASE_URL="postgresql://user:pass@localhost/testdb"
go test ./cmd/server -v -run TestMigrationConcurrentSafety
```

Or test manually with Docker Compose:

```bash
docker-compose up --scale app=3 -d
docker-compose logs app
# All three instances should start without migration conflicts
```
Loading