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

214 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 校验迁移记录并禁止外键,不执行 DDL;不代表已核验其他手工结构漂移。
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")
}
}
var foreignKeys int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,6) IN ('orbit_','pulse_','weave_')`).Scan(&foreignKeys); err != nil {
return err
}
if foreignKeys != 0 {
return errors.New("foreign keys are forbidden on Orbit tables; apply the supported schema before starting")
}
return nil
}