package db import ( "context" "embed" "fmt" "sort" "strings" "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/*.sql var migrationsFS embed.FS // migrateLockKey is the pg advisory-lock key serializing Migrate across // controllers sharing one database. Arbitrary but fixed: fnv-ish of // "panel:schema_migrations". Session-scoped (not transaction-scoped) so it // spans the whole multi-transaction run and releases on disconnect if the // process dies mid-migrate. const migrateLockKey int64 = 0x70616e656c6d6967 // "panelmig" // Migrate applies any pending SQL files in migrations/ in filename-sorted // order. Each file is wrapped in a transaction alongside its schema_migrations // insert so failure leaves the DB in a consistent state. A session-level // pg advisory lock serializes concurrent controllers: the second one blocks // here, then finds every version already recorded and no-ops. func Migrate(ctx context.Context, pool *pgxpool.Pool) error { // Dedicated connection: session advisory locks belong to a connection, // and pool.Exec could lock on one conn and unlock on another. lockConn, err := pool.Acquire(ctx) if err != nil { return fmt.Errorf("acquire migrate lock conn: %w", err) } defer lockConn.Release() if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockKey); err != nil { return fmt.Errorf("acquire migrate advisory lock: %w", err) } defer func() { _, _ = lockConn.Exec(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrateLockKey) }() if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`); err != nil { return fmt.Errorf("create schema_migrations: %w", err) } entries, err := migrationsFS.ReadDir("migrations") if err != nil { return fmt.Errorf("read embedded migrations: %w", err) } var files []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { files = append(files, e.Name()) } } sort.Strings(files) for _, name := range files { var exists bool if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name, ).Scan(&exists); err != nil { return fmt.Errorf("check %s: %w", name, err) } if exists { continue } body, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return fmt.Errorf("read %s: %w", name, err) } tx, err := pool.Begin(ctx) if err != nil { return fmt.Errorf("begin tx for %s: %w", name, err) } if _, err := tx.Exec(ctx, string(body)); err != nil { _ = tx.Rollback(ctx) return fmt.Errorf("apply %s: %w", name, err) } if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, name); err != nil { _ = tx.Rollback(ctx) return fmt.Errorf("record %s: %w", name, err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit %s: %w", name, err) } } return nil }