Files
go-admin/cmd/migrate/migration/init.go
T
zhangwenjian 9c68bc25a5 refactor♻️: report a failed migration instead of ending the process
run() called log.Fatalf on the first migration that failed, which ended the
process from inside the migration engine. Nothing above it could record what
happened - an installer needs to write down which version an attempt stopped
on - and no test could exercise a failing migration at all without taking the
test binary with it, which is why the one test that covers a failed migration
drove the registered function directly and left the scheduler uncovered.

run(), Migrate() and MigrateApp() now return an error, and the exit moved to
the command layer where the exit code is the command's business.

Two of those errors say more than "it failed". A migration that fails comes
back as a *VersionFailure naming the version, because an installer records
that as a diagnostic snapshot - the authoritative answer to where a retry
resumes is always recomputed from sys_migration, never read back, and asking
the database what is still pending answers a different question that merely
has the same answer most of the time. An app code nothing registered under is
now an error rather than a log line, so an installer asking for one app by
name cannot be told that installing an app that does not exist succeeded; the
command layer still rejects a typo before any database work.

exitOnError is what makes the command exit non-zero, and it covers more than
it replaces. Every path out of migrateModel used to return without an exit
code: an unreachable tenant database or a failed AutoMigrate printed a line
and exited 0, so a caller that migrates before starting a server - the deploy
workflow does exactly that - carried on onto a schema that had not been
brought forward. A failing migration function was the only failure reported,
and only as a side effect of the log.Fatalf this commit removes.

Each of these was checked by degrading it and watching the named assertion
go red: returning nil instead of the failure, naming the first version rather
than the one that failed, accepting an unregistered app code, and not exiting.

One gap is left open deliberately. Go allows a call whose only result is an
error to stand as a statement, so `migration.Migrate.Migrate()` still compiles
while dropping what it returns - `go build` passed while migrateModel was
doing exactly that during this change. Both call sites now return the value,
which the compiler does check, but nothing guards against the statement form
coming back. A checksilent rule was considered and dropped: that tool parses
without type information, so it could only match the method name, and a guard
that fires on any type with a Migrate method is noise.
2026-09-09 12:14:12 +08:00

428 lines
15 KiB
Go

package migration
import (
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
common "go-admin/common/models"
)
var Migrate = newMigration()
// contractSnapshot is contractmigration.Snapshot, indirected through a
// package-level variable so tests can substitute an isolated
// *contractmigration.Registry's Snapshot instead of reaching into
// go-admin-core's single process-wide registry, which every *Migration in
// this process - test-local or the package-level Migrate - reads through the
// same call. See mergedEntries.
var contractSnapshot = contractmigration.Snapshot
func newMigration() *Migration {
return &Migration{version: make(map[string]versionEntry)}
}
// versionEntry is one registered migration plus the app it belongs to. The
// empty app code means the framework itself, which is also what the
// sys_migration.app_code column defaults to, so history written before this
// field existed reads back correctly with no backfill.
type versionEntry struct {
appCode string
fn func(db *gorm.DB, version string) error
}
type Migration struct {
db *gorm.DB
version map[string]versionEntry
mutex sync.Mutex
}
func (e *Migration) GetDb() *gorm.DB {
return e.db
}
func (e *Migration) SetDb(db *gorm.DB) {
e.db = db
}
// SetVersion registers a migration owned by the framework. Signature and
// behaviour are unchanged: every existing call site in version/*.go keeps
// compiling and keeps writing common.Migration{Version: version} with no app
// code, which is the correct meaning of "framework".
func (e *Migration) SetVersion(k string, f func(db *gorm.DB, version string) error) {
e.setVersion(k, "", f)
}
func (e *Migration) setVersion(k, appCode string, f func(db *gorm.DB, version string) error) {
e.mutex.Lock()
defer e.mutex.Unlock()
e.version[k] = versionEntry{appCode: appCode, fn: f}
}
// AppMigrationFunc is the signature of a migration registered through ForApp.
//
// It receives appCode explicitly because the migration - not the framework -
// writes its own completion row, normally as the last statement inside its own
// transaction. That is what makes "the schema change and the record of it
// commit together" true, and the framework cannot insert the row on the
// migration's behalf without giving that up. Handing the code to the function
// is what stops an app's migrations from silently recording themselves as the
// framework's.
type AppMigrationFunc func(db *gorm.DB, version, appCode string) error
// AppRegistrar is a per-app view over a registry.
type AppRegistrar struct {
m *Migration
appCode string
}
// FrameworkAppCode is the name migrate status prints for migrations that belong
// to the framework rather than to an app, and the name --app accepts to select
// them. The stored app code for those is the empty string; this is only the
// spelling humans use. It is reserved - ForApp rejects it - so that every group
// heading status prints is also a value --app understands.
const FrameworkAppCode = "core"
// ForApp returns a registrar that records migrations under code.
//
// The code is lower-cased: sys_migration.version sorts as ASCII, so mixed case
// would order MyApp before crm for no reason a reader could guess, and the two
// spellings would group as two different apps in migrate status.
//
// An empty or reserved code panics rather than falling back to the framework.
// Registration happens in init(), so this fires the first time the binary runs
// anywhere, which is the point: an app whose migrations quietly file themselves
// under the framework is exactly the class of silent failure this work is meant
// to remove. Framework migrations call Migrate.SetVersion directly.
func ForApp(code string) *AppRegistrar { return Migrate.ForApp(code) }
// ForApp is the same on an explicit registry, which is what tests use.
func (e *Migration) ForApp(code string) *AppRegistrar {
code = NormalizeAppCode(code)
switch code {
case "":
panic("migration.ForApp: empty app code; framework migrations use Migrate.SetVersion")
case FrameworkAppCode:
panic("migration.ForApp: app code " + FrameworkAppCode + " is reserved for the framework")
}
return &AppRegistrar{m: e, appCode: code}
}
// AppCode reports the code this registrar files migrations under, after
// normalisation.
func (r *AppRegistrar) AppCode() string { return r.appCode }
// SetVersion registers an app-owned migration under k, which is the bare
// timestamp taken from the file name exactly as framework migrations do.
//
// What reaches sys_migration.version is the namespaced form; the version string
// handed to f is that same namespaced string, so a migration that writes
// common.Migration{Version: version, AppCode: appCode} records the key the
// registry will look for next time.
func (r *AppRegistrar) SetVersion(k string, f AppMigrationFunc) {
key := namespacedKey(r.appCode, k)
r.m.setVersion(key, r.appCode, func(db *gorm.DB, version string) error {
return f(db, version, r.appCode)
})
}
// namespacedKey scopes k to appCode so two apps cannot collide on the
// sys_migration.version primary key by minting the same millisecond timestamp.
// Framework migrations (appCode == "") stay bare, matching every version string
// already in production.
func namespacedKey(appCode, k string) string {
if appCode == "" {
return k
}
return appCode + "-" + k
}
// mergedEntries returns every migration this process knows about: the
// host's own registry (e.version, filled by version/*.go and
// version-local/*.go through SetVersion/ForApp) plus whatever a third-party
// application registered through go-admin-core's sdk/contract/migration
// package (PRD 006, F9's host wiring).
//
// That package keeps its own process-wide registry, entirely separate from
// e.version, because a third-party application cannot reach into this
// process to call an unexported method on *Migration - contract/migration's
// package-level ForApp/Snapshot are the only door open to it. Without this
// merge, migrate/status/--dry-run would only ever see the host's own
// migrations: an application's ForApp("crm").SetVersion(...) would compile,
// register successfully into contract/migration's registry, and then never
// run, with no error anywhere - the exact silent gap this method closes.
//
// Entry and versionEntry are structurally identical (an app code plus a
// func(db, version) error); the conversion below exists only because they
// are two distinct named types, one per package, not because the data
// differs.
func (e *Migration) mergedEntries() map[string]versionEntry {
e.mutex.Lock()
out := make(map[string]versionEntry, len(e.version))
for k, v := range e.version {
out[k] = v
}
e.mutex.Unlock()
for k, entry := range contractSnapshot() {
if _, exists := out[k]; exists {
// contract/migration.ForApp namespaces every app-owned key as
// appCode + "-" + k, and appCode is reserved from ""/"core", so
// this should never collide with a host-registered key. If it
// somehow does, the host's own registration wins rather than
// silently overwriting it.
continue
}
out[k] = versionEntry{appCode: entry.AppCode, fn: entry.Fn}
}
return out
}
// RegisteredVersions returns every migration version this binary registers,
// sorted, without touching a database.
//
// Status answers a richer question - what is registered, what is applied, and
// what is applied while nothing registers it - and needs a database to do it.
// This is the half that can be asked of the process alone, which is what a
// readiness check needs: the check holds the databases it is asking about, and
// reusing Status would mean calling SetDb from a request handler, writing this
// package's shared state from a request path.
func (e *Migration) RegisteredVersions() []string {
all := e.mergedEntries()
out := make([]string, 0, len(all))
for k := range all {
out = append(out, k)
}
sort.Strings(out)
return out
}
// RegisteredVersions reports what the process-wide registry holds.
func RegisteredVersions() []string { return Migrate.RegisteredVersions() }
// StatusEntry is one row of migrate status.
type StatusEntry struct {
AppCode string
Version string
Registered bool
Applied bool
ApplyTime *time.Time
}
// Status merges the in-process registry with sys_migration, so it reports all
// three shapes at once: registered but not applied, registered and applied, and
// applied while nothing registers it any more - a row left behind by a
// migration file that was deleted, or by an app that was uninstalled.
//
// It only reads. Nothing here creates or alters a table, which is what lets
// both `status` and `--dry-run` run against a database without touching it.
func (e *Migration) Status() ([]StatusEntry, error) {
if e.db == nil {
return nil, fmt.Errorf("migration: no database configured")
}
all := e.mergedEntries()
registered := make(map[string]string, len(all))
for k, v := range all {
registered[k] = v.appCode
}
applied := make(map[string]common.Migration)
// A database that has never been migrated has no sys_migration table.
// Reporting everything as pending is the honest answer there; erroring out
// would make status useless in exactly the case it is most wanted.
if e.db.Migrator().HasTable(&common.Migration{}) {
var rows []common.Migration
if err := e.db.Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
applied[r.Version] = r
}
}
versions := make(map[string]struct{}, len(registered)+len(applied))
for k := range registered {
versions[k] = struct{}{}
}
for k := range applied {
versions[k] = struct{}{}
}
list := make([]string, 0, len(versions))
for k := range versions {
list = append(list, k)
}
sort.Strings(list)
out := make([]StatusEntry, 0, len(list))
for _, v := range list {
entry := StatusEntry{Version: v}
if code, ok := registered[v]; ok {
entry.Registered = true
entry.AppCode = code
}
if row, ok := applied[v]; ok {
entry.Applied = true
t := row.ApplyTime
entry.ApplyTime = &t
if !entry.Registered {
// Nothing registers this version any more, so the database is
// the only source left for what it belonged to.
entry.AppCode = row.AppCode
}
}
out = append(out, entry)
}
return out, nil
}
// Migrate applies every registered migration that has not been applied yet,
// across all apps.
func (e *Migration) Migrate() error { return e.run(allApps) }
// MigrateApp applies only the migrations registered under appCode. Pass
// FrameworkAppCode for the framework's own migrations.
func (e *Migration) MigrateApp(appCode string) error { return e.run(AppFilter(appCode)) }
// VersionFailure names the migration that failed.
//
// The caller that needs this is an installer recording which version an
// install got stuck on. That is a diagnostic snapshot and nothing more: the
// authoritative answer to "where does a retry resume" is always recomputed
// by subtracting sys_migration's applied rows from what is registered, never
// read back from anywhere it was stored. Which is exactly why this carries
// the version rather than leaving the caller to infer it - inferring it
// would produce "what is pending now", a different question that happens to
// have the same answer most of the time.
type VersionFailure struct {
Version string
Err error
}
func (e *VersionFailure) Error() string {
return fmt.Sprintf("migration %s failed: %v", e.Version, e.Err)
}
func (e *VersionFailure) Unwrap() error { return e.Err }
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
// command line matches one written in an init().
func NormalizeAppCode(code string) string {
return strings.ToLower(strings.TrimSpace(code))
}
// AppFilter turns a code as typed into the code stored in the registry, so
// "core" selects the framework's migrations, whose stored code is empty.
func AppFilter(code string) string {
code = NormalizeAppCode(code)
if code == FrameworkAppCode {
return ""
}
return code
}
// DisplayAppCode is the inverse: what to print for a stored code.
func DisplayAppCode(code string) string {
if code == "" {
return FrameworkAppCode
}
return code
}
// AppCodes lists the app codes with at least one registered migration, framework
// included under its display name, sorted.
func (e *Migration) AppCodes() []string {
all := e.mergedEntries()
seen := map[string]struct{}{}
for _, v := range all {
seen[DisplayAppCode(v.appCode)] = struct{}{}
}
out := make([]string, 0, len(seen))
for code := range seen {
out = append(out, code)
}
sort.Strings(out)
return out
}
// run applies the pending migrations selected by appCode.
//
// It reports failure instead of ending the process. It used to call
// log.Fatalf, which took the whole process down at the first failing
// migration - so a caller had nowhere to record what happened, and a test
// could not exercise a failing migration at all without killing the test
// binary. The exit now lives at the command layer, where the exit code is
// the command's business (see initDB in cmd/migrate/server.go).
func (e *Migration) run(appCode string) error {
all := e.mergedEntries()
versions := make([]string, 0, len(all))
entries := make(map[string]versionEntry, len(all))
for k, v := range all {
if appCode != allApps && v.appCode != appCode {
continue
}
versions = append(versions, k)
entries[k] = v
}
sort.Strings(versions)
// A mistyped --app would otherwise select nothing and report "no
// migrations to apply", which reads exactly like "already up to date".
//
// The command layer rejects an unregistered code before any database
// work (exitUnlessAppRegistered), so on that path this is unreachable.
// It is reachable from an installer, which asks for one app by name and
// must not be told that installing an app nothing registered succeeded.
if appCode != allApps && len(versions) == 0 {
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
}
var err error
var count int64
applied := 0
for _, v := range versions {
err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error
if err != nil {
return fmt.Errorf("checking whether migration %s was applied: %w", v, err)
}
if count > 0 {
// Already applied. This used to print the bare count, so a mature
// database wrote a screen of "1" at every start.
count = 0
continue
}
log.Printf("applying migration %s", v)
if err = entries[v].fn(e.db.Debug(), v); err != nil {
return &VersionFailure{Version: v, Err: err}
}
applied++
}
if applied == 0 {
log.Println("no migrations to apply")
} else {
log.Printf("applied %d migration(s)", applied)
}
return nil
}
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
// from the empty app code, which selects the framework's own migrations.
const allApps = "\x00all"
// GetFilename derives a migration's version from its file name. The rule
// lives in contract/migration, because an application registering through
// that package names its files by the same convention and must land on the
// same version string; a second copy here is a second thing to keep in step.
func GetFilename(s string) string {
return contractmigration.GetFilename(s)
}