spaxel/mothership/internal/db/migrate.go
jedarden f9632c7df4 fix: resolve deadlock in database migration engine
Fixed a deadlock in the Migrator where Pending() and Migrate() methods
called CurrentVersion() while holding the lock, but CurrentVersion()
also tried to acquire the same lock. Created internal currentVersionLocked()
method for lock-safe access.

Also fixed unused imports in migrations.go and fixed NOT NULL constraint
in TestMigrateFromV1 test.

All tests pass including:
- TestMigrateIdempotent: running migrations on already-migrated DB is no-op
- TestMigrateFromV1: migrating from v1 to current version
- TestMigrationRollback: failed migrations roll back cleanly
- TestPreMigrationBackup: backups created before schema changes
- TestPendingMigrations: pending migrations correctly identified
- TestCurrentVersion: version queries work for all DB states
- TestBackupPruning: old backups pruned correctly
- TestOpenDBFullSequence: full 7-phase startup sequence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 11:05:02 -04:00

292 lines
7.7 KiB
Go

// Package db provides a versioned schema migration framework for SQLite databases.
// It supports pre-migration backups, transactional migration execution, and backup pruning.
package db
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
// Migration represents a single schema migration.
type Migration struct {
Version int
Description string
Up func(*sql.Tx) error
}
// Migrator handles schema migrations for a database.
type Migrator struct {
db *sql.DB
migrations []Migration
dataDir string
backupDir string
backupRetention time.Duration
mu sync.Mutex
}
// Config holds migrator configuration.
type Config struct {
DataDir string // Base data directory
BackupDir string // Backup directory (default: DataDir/backups)
BackupRetention time.Duration // Backup retention (default: 90 days)
}
// NewMigrator creates a new migrator for the database at dbPath.
func NewMigrator(dbPath string, cfg Config) (*Migrator, error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
backupDir := cfg.BackupDir
if backupDir == "" {
backupDir = filepath.Join(cfg.DataDir, "backups")
}
if err := os.MkdirAll(backupDir, 0755); err != nil {
return nil, fmt.Errorf("create backup dir: %w", err)
}
retention := cfg.BackupRetention
if retention == 0 {
retention = 90 * 24 * time.Hour // 90 days default
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1) // SQLite is single-writer
return &Migrator{
db: db,
dataDir: cfg.DataDir,
backupDir: backupDir,
backupRetention: retention,
}, nil
}
// Register registers migrations. Migrations must be registered in order by version.
func (m *Migrator) Register(migrations ...Migration) {
m.mu.Lock()
defer m.mu.Unlock()
m.migrations = migrations
}
// CurrentVersion returns the current schema version from the database.
func (m *Migrator) CurrentVersion(ctx context.Context) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.currentVersionLocked(ctx)
}
// currentVersionLocked returns the current schema version without acquiring the lock.
// The caller must hold m.mu.
func (m *Migrator) currentVersionLocked(ctx context.Context) (int, error) {
// Check if schema_migrations table exists
var tableName string
err := m.db.QueryRowContext(ctx,
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableName)
if err == sql.ErrNoRows {
// Table doesn't exist, no migrations applied yet
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("check schema_migrations table: %w", err)
}
// Get the max version applied
var version int
err = m.db.QueryRowContext(ctx,
"SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
).Scan(&version)
if err != nil {
return 0, fmt.Errorf("get current version: %w", err)
}
return version, nil
}
// Pending returns the list of pending migrations that haven't been applied yet.
func (m *Migrator) Pending(ctx context.Context) ([]Migration, error) {
m.mu.Lock()
defer m.mu.Unlock()
current, err := m.currentVersionLocked(ctx)
if err != nil {
return nil, err
}
var pending []Migration
for _, m := range m.migrations {
if m.Version > current {
pending = append(pending, m)
}
}
return pending, nil
}
// Migrate runs all pending migrations. Each migration runs in its own transaction.
// A pre-migration backup is created before any schema changes.
func (m *Migrator) Migrate(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
current, err := m.currentVersionLocked(ctx)
if err != nil {
return fmt.Errorf("get current version: %w", err)
}
var pending []Migration
for _, mig := range m.migrations {
if mig.Version > current {
pending = append(pending, mig)
}
}
if len(pending) == 0 {
log.Printf("[INFO] Database schema is up to date (version %d)", current)
return nil
}
// Create pre-migration backup
backupPath, err := m.createBackup(ctx, current, pending[len(pending)-1].Version)
if err != nil {
return fmt.Errorf("create pre-migration backup: %w", err)
}
log.Printf("[INFO] Pre-migration backup created: %s", backupPath)
// Apply each pending migration
for _, mig := range pending {
log.Printf("[INFO] Applying migration %d: %s", mig.Version, mig.Description)
if err := m.applyMigration(ctx, mig); err != nil {
// Migration failed - backup is preserved for recovery
return fmt.Errorf("migration %d failed: %w (backup preserved at %s)", mig.Version, err, backupPath)
}
log.Printf("[INFO] Migration %d applied successfully", mig.Version)
}
log.Printf("[INFO] All migrations applied successfully (version %d → %d)",
current, pending[len(pending)-1].Version)
// Prune old backups
go m.pruneOldBackups()
return nil
}
// applyMigration applies a single migration within a transaction.
func (m *Migrator) applyMigration(ctx context.Context, mig Migration) error {
tx, err := m.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if tx != nil {
tx.Rollback()
}
}()
// Run the migration Up function
if err := mig.Up(tx); err != nil {
return fmt.Errorf("execute migration: %w", err)
}
// Record the migration
now := time.Now().UnixMilli()
if _, err := tx.ExecContext(ctx,
"INSERT INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)",
mig.Version, now, mig.Description,
); err != nil {
return fmt.Errorf("record migration: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
tx = nil // Mark as committed
return nil
}
// createBackup creates a backup of the database before migration.
// Uses SQLite Online Backup API via SQL commands.
func (m *Migrator) createBackup(ctx context.Context, oldVersion, newVersion int) (string, error) {
timestamp := time.Now().Format("20060102-150405")
backupPath := filepath.Join(m.backupDir,
fmt.Sprintf("pre-upgrade-v%d-to-v%d-%s.sqlite", oldVersion, newVersion, timestamp))
// Use the backup API via SQL backup command
if _, err := m.db.ExecContext(ctx,
fmt.Sprintf("VACUUM INTO '%s'", backupPath)); err != nil {
return "", fmt.Errorf("vacuum into backup: %w", err)
}
return backupPath, nil
}
// pruneOldBackups removes backups older than the retention period.
// Runs in the background after successful migration.
func (m *Migrator) pruneOldBackups() {
// Find and delete old backup files
cutoff := time.Now().Add(-m.backupRetention)
entries, err := os.ReadDir(m.backupDir)
if err != nil {
log.Printf("[WARN] Failed to read backup directory for pruning: %v", err)
return
}
pruned := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
// Parse backup filename format: pre-upgrade-vX-to-vY-TIMESTAMP.sqlite
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
path := filepath.Join(m.backupDir, entry.Name())
if err := os.Remove(path); err != nil {
log.Printf("[WARN] Failed to delete old backup %s: %v", path, err)
} else {
pruned++
}
}
}
if pruned > 0 {
log.Printf("[INFO] Pruned %d old backups (older than %s)", pruned, m.backupRetention)
}
}
// PruneOldBackups manually triggers backup pruning.
func (m *Migrator) PruneOldBackups() {
m.pruneOldBackups()
}
// Close closes the database connection.
func (m *Migrator) Close() error {
return m.db.Close()
}
// DB returns the underlying database connection.
// This allows the migrator to be used as the database handle after migrations.
func (m *Migrator) DB() *sql.DB {
return m.db
}