Files
orbit/internal/storage/mysql/migrate.go

208 lines
6.2 KiB
Go

package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"database/sql/driver"
"embed"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
"time"
)
//go:embed migrations/*.up.sql
var migrationFiles embed.FS
type Migration struct {
Version uint64
Name string
Checksum string
SQL string
}
type MigrationStatus struct {
Version uint64 `json:"version"`
Name string `json:"name"`
Checksum string `json:"checksum"`
Dirty bool `json:"dirty"`
}
func Migrations() ([]Migration, error) {
names, err := fs.Glob(migrationFiles, "migrations/*.up.sql")
if err != nil {
return nil, err
}
result := make([]Migration, 0, len(names))
seen := map[uint64]bool{}
for _, name := range names {
base := strings.TrimPrefix(name, "migrations/")
version, err := strconv.ParseUint(strings.SplitN(base, "_", 2)[0], 10, 64)
if err != nil || version == 0 || seen[version] {
return nil, fmt.Errorf("invalid or duplicate migration version: %s", base)
}
seen[version] = true
b, err := migrationFiles.ReadFile(name)
if err != nil {
return nil, err
}
digest := sha256.Sum256(b)
result = append(result, Migration{version, base, hex.EncodeToString(digest[:]), string(b)})
}
sort.Slice(result, func(i, j int) bool { return result[i].Version < result[j].Version })
return result, nil
}
// Migrate applies explicit forward migrations while holding one connection-scoped
// advisory lock. MySQL DDL commits implicitly: interruption leaves a dirty record
// and stops further migrations until an operator reconciles the partial schema.
func Migrate(ctx context.Context, db *sql.DB) error {
migrations, err := Migrations()
if err != nil {
return err
}
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
var dbName, version string
if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), VERSION()").Scan(&dbName, &version); err != nil {
return err
}
var major, minor int
if _, err := fmt.Sscanf(version, "%d.%d", &major, &minor); err != nil || strings.Contains(strings.ToLower(version), "mariadb") || major < 8 || major == 8 && minor < 4 {
return errors.New("migrations require MySQL 8.4 or newer")
}
digest := sha256.Sum256([]byte(dbName))
lockName := "orbit_migrate_" + hex.EncodeToString(digest[:20])
var acquired sql.NullInt64
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 10)", lockName).Scan(&acquired); err != nil {
return err
}
if !acquired.Valid || acquired.Int64 != 1 {
return errors.New("migration lock unavailable")
}
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var released sql.NullInt64
if err := conn.QueryRowContext(cleanup, "SELECT RELEASE_LOCK(?)", lockName).Scan(&released); err != nil || !released.Valid || released.Int64 != 1 {
_ = conn.Raw(func(any) error { return driver.ErrBadConn })
}
}()
if _, err := conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS orbit_schema_migrations (
version BIGINT UNSIGNED PRIMARY KEY,
name VARCHAR(191) NOT NULL,
checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
dirty BOOLEAN NOT NULL,
applied_at DATETIME(6) NULL
) ENGINE=InnoDB`); err != nil {
return err
}
rows, err := conn.QueryContext(ctx, "SELECT version, name, checksum, dirty FROM orbit_schema_migrations ORDER BY version")
if err != nil {
return err
}
applied := map[uint64]MigrationStatus{}
for rows.Next() {
var m MigrationStatus
if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil {
rows.Close()
return err
}
applied[m.Version] = m
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
known := map[uint64]Migration{}
for _, m := range migrations {
known[m.Version] = m
}
for v, a := range applied {
m, ok := known[v]
if !ok {
return fmt.Errorf("database migration %d is newer than this binary", v)
}
if a.Dirty {
return fmt.Errorf("migration %d is dirty; reconcile partial DDL before proceeding", v)
}
if a.Checksum != m.Checksum || a.Name != m.Name {
return fmt.Errorf("migration %d checksum/name mismatch", v)
}
}
for _, m := range migrations {
if _, ok := applied[m.Version]; ok {
continue
}
for v := range applied {
if v > m.Version {
return errors.New("out-of-order migration requires operator review")
}
}
statements, err := splitSQL(m.SQL)
if err != nil {
return fmt.Errorf("migration %d: %w", m.Version, err)
}
if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil {
return err
}
for i, statement := range statements {
if _, err := conn.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("migration %d statement %d failed (marked dirty): %w", m.Version, i+1, err)
}
}
if _, err := conn.ExecContext(ctx, "UPDATE orbit_schema_migrations SET dirty=FALSE, applied_at=UTC_TIMESTAMP(6) WHERE version=? AND checksum=?", m.Version, m.Checksum); err != nil {
return err
}
}
return nil
}
func Status(ctx context.Context, db *sql.DB) ([]MigrationStatus, error) {
rows, err := db.QueryContext(ctx, "SELECT version,name,checksum,dirty FROM orbit_schema_migrations ORDER BY version")
if err != nil {
return nil, err
}
defer rows.Close()
result := []MigrationStatus{}
for rows.Next() {
var m MigrationStatus
if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil {
return nil, err
}
result = append(result, m)
}
return result, rows.Err()
}
// CheckSchema rejects missing, modified, dirty and unexpected versions. It does
// not execute DDL. A matched migration ledger is not proof against manual DDL drift.
func CheckSchema(ctx context.Context, db *sql.DB) error {
wanted, err := Migrations()
if err != nil {
return err
}
actual, err := Status(ctx, db)
if err != nil {
return errors.New("schema unavailable; run orbit-migrate up with migration credentials")
}
if len(actual) != len(wanted) {
return errors.New("schema version mismatch")
}
for i, m := range wanted {
a := actual[i]
if a.Version != m.Version || a.Name != m.Name || a.Checksum != m.Checksum || a.Dirty {
return errors.New("schema is dirty or does not match this binary")
}
}
return nil
}