feat(multiplayer): add down migrations and a rollback runner

Add migrations.Rollback(ctx, db, dir, steps): reverses the N most
recently applied migrations, newest first, each in its own committed
transaction under the same advisory lock Apply uses. Down SQL lives in
migrations/down/<version>.sql (a subdirectory, so Apply's *.sql glob
over the main directory is untouched); a missing down file for a
migration being rolled back is a hard error rather than a silent
partial reversal. Wire it into cmd/migrate as --rollback=N.

Add down files for all six existing migrations, each dropping objects
in FK-safe reverse dependency order.

Adversarial review: could not run the new integration test
(TestPostgreSQLMigrationsRollBackAndReapplyCleanly, gated behind
COSMIC_CLASH_POSTGRES_DSN / scripts/run_postgres_integration.sh)
against a real database in this sandbox - Docker Desktop's own
overlayfs ran out of space pulling postgres:17-alpine, unrelated to
this change. Verified instead by hand-tracing every DROP against its
forward migration's FK graph, confirming Apply's directory glob does
not pick up the down/ subdirectory, and a clean go build/vet/test
-tags integration. Worth an explicit real run before this is trusted
in CI.
This commit is contained in:
Josh Creek
2026-09-01 12:41:30 +01:00
parent 7e5cfdeceb
commit 67609d71c0
10 changed files with 191 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
-- Down migration for 0001_initial.sql. Tables drop in FK-safe reverse
-- dependency order (a child table always drops before anything it
-- references); dropping a table drops its own indexes with it.
DROP TABLE IF EXISTS audit_events;
DROP TABLE IF EXISTS outbox;
DROP TABLE IF EXISTS result_receipts;
DROP TABLE IF EXISTS penalties;
DROP TABLE IF EXISTS ranked_season_rollovers;
DROP TABLE IF EXISTS seasons;
DROP TABLE IF EXISTS ratings;
DROP TABLE IF EXISTS match_participants;
DROP TABLE IF EXISTS matches;
DROP TABLE IF EXISTS proposal_participants;
DROP TABLE IF EXISTS proposals;
DROP TABLE IF EXISTS queue_tickets;
DROP TABLE IF EXISTS idempotency_keys;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS identities;
@@ -0,0 +1,2 @@
-- Down migration for 0002_assignments.sql.
DROP TABLE IF EXISTS assignments;
@@ -0,0 +1,2 @@
-- Down migration for 0003_queue_probe_metadata.sql.
ALTER TABLE queue_tickets DROP COLUMN IF EXISTS predicted_rtt;
@@ -0,0 +1,3 @@
-- Down migration for 0004_allocator_registry.sql.
DROP TABLE IF EXISTS allocations;
DROP TABLE IF EXISTS game_servers;
@@ -0,0 +1,8 @@
-- Down migration for 0005_proposal_match_plans.sql.
DROP INDEX IF EXISTS proposal_participants_unique_slot;
ALTER TABLE proposal_participants
DROP COLUMN IF EXISTS slot,
DROP COLUMN IF EXISTS team;
ALTER TABLE proposals
DROP COLUMN IF EXISTS match_protocol,
DROP COLUMN IF EXISTS match_region;
@@ -0,0 +1,6 @@
-- Down migration for 0006_match_allocation_claims.sql.
DROP INDEX IF EXISTS matches_allocating_claimable;
ALTER TABLE matches
DROP CONSTRAINT IF EXISTS matches_allocation_claim_pair,
DROP COLUMN IF EXISTS allocation_claimed_at,
DROP COLUMN IF EXISTS allocation_id;
+73
View File
@@ -72,3 +72,76 @@ func Apply(ctx context.Context, db *sql.DB, directory string) error {
}
return nil
}
// Rollback reverses the `steps` most recently applied migrations, newest
// first, by running each one's down file from the `down/` subdirectory of
// `directory` (e.g. `down/0006_match_allocation_claims.sql` undoes
// `0006_match_allocation_claims.sql`) and deleting its schema_migrations
// marker. Each rollback is committed in its own transaction, same as Apply,
// so a failure partway through leaves the schema at a consistent, resumable
// state rather than a half-applied one. A missing down file for a migration
// being rolled back is a hard error — better a stuck rollback than a schema
// silently left half-reversed.
func Rollback(ctx context.Context, db *sql.DB, directory string, steps int) error {
if db == nil || strings.TrimSpace(directory) == "" || steps <= 0 {
return fmt.Errorf("database, migration directory and a positive step count are required")
}
if _, err := db.ExecContext(ctx, migrationTableSQL); err != nil {
return fmt.Errorf("create migration table: %w", err)
}
rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations ORDER BY version DESC LIMIT $1`, steps)
if err != nil {
return fmt.Errorf("list applied migrations: %w", err)
}
var versions []string
for rows.Next() {
var version string
if err := rows.Scan(&version); err != nil {
rows.Close()
return fmt.Errorf("scan applied migration: %w", err)
}
versions = append(versions, version)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("list applied migrations: %w", err)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("list applied migrations: %w", err)
}
for _, version := range versions {
sqlBytes, err := os.ReadFile(filepath.Join(directory, "down", version))
if err != nil {
return fmt.Errorf("read down migration for %s: %w", version, err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin rollback %s: %w", version, err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('cosmic-clash:migrations'))`); err != nil {
return fmt.Errorf("lock rollback %s: %w", version, err)
}
var applied bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil {
return fmt.Errorf("check rollback %s: %w", version, err)
}
if applied {
if _, err := tx.ExecContext(ctx, string(sqlBytes)); err != nil {
return fmt.Errorf("apply down migration %s: %w", version, err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM schema_migrations WHERE version = $1`, version); err != nil {
return fmt.Errorf("unrecord migration %s: %w", version, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit rollback %s: %w", version, err)
}
committed = true
}
return nil
}
+9
View File
@@ -13,3 +13,12 @@ func TestApplyRejectsMissingDatabaseOrDirectory(t *testing.T) {
t.Fatal("empty directory accepted")
}
}
func TestRollbackRejectsMissingDatabaseDirectoryOrSteps(t *testing.T) {
if err := Rollback(context.Background(), nil, ".", 1); err == nil {
t.Fatal("nil database accepted")
}
if err := Rollback(context.Background(), nil, "", 1); err == nil {
t.Fatal("empty directory accepted")
}
}