diff --git a/app/jobs/lease.go b/app/jobs/lease.go new file mode 100644 index 00000000..3f896914 --- /dev/null +++ b/app/jobs/lease.go @@ -0,0 +1,177 @@ +package jobs + +import ( + "fmt" + "os" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + models2 "go-admin/app/jobs/models" +) + +// nowExprMs is the dialect's expression for the current time as +// milliseconds since the Unix epoch. +// +// The lease compares one instance's idea of "expired" against another +// instance's idea of "still mine", so both have to come from the same clock. +// Two processes whose wall clocks differ by more than the lease TTL would +// otherwise both hold it and both schedule - the exact situation the lease +// exists to prevent, and it would look like it was working, because each +// instance's own arithmetic is self-consistent. +// +// Milliseconds rather than a timestamp, because a timestamp does not survive +// the trip through a driver unchanged. MySQL's UTC_TIMESTAMP read over +// go-admin's own `parseTime=True&loc=Local` DSN arrives labelled as local +// time: on a UTC+8 host every lease is eight hours out, and a test that only +// checked the lease logic against itself passes anyway. An epoch integer has +// no timezone for a driver to apply. +func nowExprMs(dialect string) (string, error) { + switch dialect { + case "mysql": + // UNIX_TIMESTAMP reads its argument in the session timezone and + // NOW(3) is in the session timezone, so the two cancel and the + // result is the absolute epoch regardless of what that zone is. + return "CAST(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000) AS SIGNED)", nil + case "postgres": + return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)", nil + case "sqlite": + // julianday is the portable millisecond clock here: strftime('%s') + // truncates to the second, and unixepoch('now','subsec') needs + // SQLite 3.42. + return "CAST((julianday('now') - 2440587.5) * 86400000.0 AS INTEGER)", nil + case "sqlserver": + return "DATEDIFF_BIG(millisecond, '1970-01-01T00:00:00', SYSUTCDATETIME())", nil + } + return "", fmt.Errorf("no epoch-milliseconds expression for dialect %q", dialect) +} + +// dbNowMs reads the clock from the database rather than from this process. +// +// The read and the UPDATE that uses it are two statements, so the value is +// already slightly stale by the time it is compared - and that is the safe +// direction in both places it is used: +// +// - as the expiry cutoff, a stale-old now makes this instance *less* +// likely to decide another instance's lease has expired; +// - as the basis for a new expiry, it makes this instance's own lease +// expire sooner, so it renews sooner. +// +// Neither error makes two instances hold the lease at once. +// +// Zero is rejected rather than returned. It is what a failed conversion +// looks like, it is before every expiry there will ever be, and an +// implementation that passed it on would read every lease as expired, hand +// it to every instance, and restore the defect this lease fixes with a lease +// table sitting on top of it. +func dbNowMs(db *gorm.DB) (int64, error) { + expr, err := nowExprMs(db.Dialector.Name()) + if err != nil { + return 0, err + } + + var ms int64 + if err := db.Raw("SELECT " + expr).Row().Scan(&ms); err != nil { + return 0, fmt.Errorf("reading the database clock: %w", err) + } + if ms <= 0 { + return 0, fmt.Errorf("the database clock read as %d from %q", ms, expr) + } + return ms, nil +} + +// newOwnerID identifies this process in the lease row. +// +// Hostname and pid make a log line answer "which one is it" without a lookup; +// the random suffix is what actually makes it unique, because a container +// restarted under the same name can come back with the same hostname and the +// same pid 1. +func newOwnerID() string { + host, err := os.Hostname() + if err != nil || host == "" { + host = "unknown" + } + return fmt.Sprintf("%s-%d-%s", host, os.Getpid(), uuid.New().String()[:8]) +} + +// lease is one instance's claim on scheduling one database's jobs. +type lease struct { + db *gorm.DB + owner string + ttl time.Duration +} + +// acquire takes the lease or renews one this instance already holds, and +// reports whether this instance holds it when it returns. +// +// Renewal is tried first and is scoped to this owner, so it cannot take a +// lease another instance has meanwhile claimed. Only if that matches nothing +// does it try to take an expired one. Both are single UPDATE statements +// decided by RowsAffected: the database, not this process, arbitrates +// between two instances running this at the same moment. +// +// There is no insert path. The migration seeds the row, so a missing row is +// a broken installation rather than a state to recover from - and it is +// reported as one, instead of being papered over by an insert that two +// instances would race to win. +func (l *lease) acquire() (bool, error) { + nowMs, err := dbNowMs(l.db) + if err != nil { + return false, err + } + expiresMs := nowMs + l.ttl.Milliseconds() + + renewed := l.db.Model(&models2.SysJobLease{}). + Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner). + Update("expires_at_ms", expiresMs) + if renewed.Error != nil { + return false, fmt.Errorf("renewing the scheduler lease: %w", renewed.Error) + } + if renewed.RowsAffected > 0 { + return true, nil + } + + taken := l.db.Model(&models2.SysJobLease{}). + Where("name = ? AND expires_at_ms <= ?", models2.SchedulerLeaseName, nowMs). + Updates(map[string]any{ + "owner": l.owner, + "acquired_at_ms": nowMs, + "expires_at_ms": expiresMs, + }) + if taken.Error != nil { + return false, fmt.Errorf("taking the scheduler lease: %w", taken.Error) + } + if taken.RowsAffected > 0 { + return true, nil + } + + // Neither statement matched. Either another instance holds an + // unexpired lease - the ordinary case, and not an error - or the row + // the migration seeds is gone, which is, and which would otherwise + // present as jobs silently never running anywhere. + var rows int64 + if err := l.db.Model(&models2.SysJobLease{}). + Where("name = ?", models2.SchedulerLeaseName). + Count(&rows).Error; err != nil { + return false, fmt.Errorf("checking for the scheduler lease row: %w", err) + } + if rows == 0 { + return false, fmt.Errorf("the %q lease row is missing from %s; run the migrations", + models2.SchedulerLeaseName, (&models2.SysJobLease{}).TableName()) + } + return false, nil +} + +// release hands the lease back so a successor can take it now instead of +// waiting out the TTL. It is scoped to this owner: an instance that already +// lost the lease must not clear the row its successor is holding. +func (l *lease) release() error { + res := l.db.Model(&models2.SysJobLease{}). + Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner). + Updates(map[string]any{"owner": "", "expires_at_ms": 0}) + if res.Error != nil { + return fmt.Errorf("releasing the scheduler lease: %w", res.Error) + } + return nil +} diff --git a/app/jobs/lease_test.go b/app/jobs/lease_test.go new file mode 100644 index 00000000..e90770b4 --- /dev/null +++ b/app/jobs/lease_test.go @@ -0,0 +1,268 @@ +package jobs + +import ( + "os" + "testing" + "time" + + glebarez "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlserver" + "gorm.io/gorm" + + models2 "go-admin/app/jobs/models" +) + +// The lease is the one thing in this package whose correctness is a property +// of the database rather than of this process, so these run against every +// dialect that can be reached. SQLite always; the others when their DSN is +// set, and they must be set in CI - a suite that quietly skipped them would +// report success for a lease that cannot be taken at all on the dialect most +// installations actually run. +const ( + mysqlDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN" + postgresDSNEnv = "GO_ADMIN_TEST_POSTGRES_DSN" + sqlserverDSNEnv = "GO_ADMIN_TEST_SQLSERVER_DSN" +) + +type dialectDB struct { + name string + open func(string) gorm.Dialector + env string +} + +var optionalDialects = []dialectDB{ + {"mysql", func(dsn string) gorm.Dialector { return mysql.Open(dsn) }, mysqlDSNEnv}, + {"postgres", func(dsn string) gorm.Dialector { return postgres.Open(dsn) }, postgresDSNEnv}, + {"sqlserver", func(dsn string) gorm.Dialector { return sqlserver.Open(dsn) }, sqlserverDSNEnv}, +} + +// eachDialect runs body against SQLite and against every optional dialect +// whose DSN is set. +func eachDialect(t *testing.T, body func(t *testing.T, db *gorm.DB)) { + t.Helper() + + t.Run("sqlite", func(t *testing.T) { + db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("opening sqlite: %v", err) + } + body(t, seedLeaseTable(t, db)) + }) + + for _, d := range optionalDialects { + t.Run(d.name, func(t *testing.T) { + dsn := os.Getenv(d.env) + if dsn == "" { + if os.Getenv("CI") != "" { + t.Fatalf("%s is not set while CI is: the lease must not go untested on %s", d.env, d.name) + } + t.Skipf("%s is not set; skipping %s", d.env, d.name) + } + db, err := gorm.Open(d.open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("connecting to %s: %v", d.env, err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("sql.DB: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + body(t, seedLeaseTable(t, db)) + }) + } +} + +// seedLeaseTable builds the shape 1786700009000 leaves behind: the table, +// and exactly one free row. +func seedLeaseTable(t *testing.T, db *gorm.DB) *gorm.DB { + t.Helper() + + if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil { + t.Fatalf("dropping sys_job_lease: %v", err) + } + if err := db.AutoMigrate(&models2.SysJobLease{}); err != nil { + t.Fatalf("creating sys_job_lease: %v", err) + } + row := models2.SysJobLease{Name: models2.SchedulerLeaseName, AcquiredAtMs: 0, ExpiresAtMs: 0} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seeding the lease row: %v", err) + } + return db +} + +func TestTheDatabaseClockIsReadableAndIsNotTheZeroTime(t *testing.T) { + eachDialect(t, func(t *testing.T, db *gorm.DB) { + nowMs, err := dbNowMs(db) + if err != nil { + t.Fatalf("dbNowMs: %v", err) + } + if nowMs <= 0 { + t.Fatal("the database clock read as zero, which would read every lease as expired") + } + // Not an assertion about either clock's accuracy - a container's + // clock and this one can drift. An hour is far wider than drift + // and far narrower than a timezone offset, which is the mistake + // this catches: reading MySQL's UTC_TIMESTAMP over a loc=Local + // DSN lands exactly one zone offset away and is invisible to + // every assertion that only compares the lease against itself. + drift := time.Duration(time.Now().UnixMilli()-nowMs) * time.Millisecond + if drift > time.Hour || drift < -time.Hour { + t.Errorf("the database clock is %v away from this process's; a timezone mistake looks exactly like this", drift) + } + }) +} + +func TestOnlyOneOfTwoInstancesTakesTheLease(t *testing.T) { + eachDialect(t, func(t *testing.T, db *gorm.DB) { + a := &lease{db: db, owner: "instance-a", ttl: time.Minute} + b := &lease{db: db, owner: "instance-b", ttl: time.Minute} + + heldA, err := a.acquire() + if err != nil { + t.Fatalf("a.acquire: %v", err) + } + if !heldA { + t.Fatal("the first instance did not take a free lease") + } + + heldB, err := b.acquire() + if err != nil { + t.Fatalf("b.acquire: %v", err) + } + if heldB { + t.Error("the second instance took a lease the first one holds: both would schedule") + } + }) +} + +func TestTheHolderRenewsAndTheOtherStillCannotTakeIt(t *testing.T) { + eachDialect(t, func(t *testing.T, db *gorm.DB) { + a := &lease{db: db, owner: "instance-a", ttl: time.Minute} + b := &lease{db: db, owner: "instance-b", ttl: time.Minute} + + if held, err := a.acquire(); err != nil || !held { + t.Fatalf("a.acquire: held=%v err=%v", held, err) + } + before := readLease(t, db) + + if held, err := a.acquire(); err != nil || !held { + t.Fatalf("a renewing: held=%v err=%v", held, err) + } + after := readLease(t, db) + + if after.ExpiresAtMs < before.ExpiresAtMs { + t.Errorf("renewal moved the expiry backwards: %d then %d", before.ExpiresAtMs, after.ExpiresAtMs) + } + if after.AcquiredAtMs != before.AcquiredAtMs { + t.Errorf("renewal moved acquired_at_ms (%d then %d); it must say when the lease was taken, not when it was last renewed", + before.AcquiredAtMs, after.AcquiredAtMs) + } + if held, err := b.acquire(); err != nil || held { + t.Errorf("the other instance took a renewed lease: held=%v err=%v", held, err) + } + }) +} + +func TestAnExpiredLeaseIsTakenOver(t *testing.T) { + eachDialect(t, func(t *testing.T, db *gorm.DB) { + a := &lease{db: db, owner: "instance-a", ttl: time.Minute} + b := &lease{db: db, owner: "instance-b", ttl: time.Minute} + + if held, err := a.acquire(); err != nil || !held { + t.Fatalf("a.acquire: held=%v err=%v", held, err) + } + + // What a dead leader leaves behind: its row, unrenewed, past its + // expiry. Forced rather than waited out, so the test does not + // trade a second of sleep for the same assertion. + expire(t, db) + + if held, err := b.acquire(); err != nil || !held { + t.Fatalf("the successor did not take an expired lease: held=%v err=%v", held, err) + } + if got := readLease(t, db).Owner; got != "instance-b" { + t.Errorf("owner is %q after takeover, want instance-b", got) + } + + // And the instance that lost it must not get it back by renewing: + // renewal is scoped to the owner column it no longer matches. + if held, err := a.acquire(); err != nil || held { + t.Errorf("the dead leader renewed a lease it had lost: held=%v err=%v", held, err) + } + }) +} + +func TestReleaseHandsTheLeaseOnWithoutWaitingOutTheTTL(t *testing.T) { + eachDialect(t, func(t *testing.T, db *gorm.DB) { + a := &lease{db: db, owner: "instance-a", ttl: time.Hour} + b := &lease{db: db, owner: "instance-b", ttl: time.Minute} + + if held, err := a.acquire(); err != nil || !held { + t.Fatalf("a.acquire: held=%v err=%v", held, err) + } + if held, err := b.acquire(); err != nil || held { + t.Fatalf("precondition: b must not hold it yet (held=%v err=%v)", held, err) + } + + if err := a.release(); err != nil { + t.Fatalf("a.release: %v", err) + } + if held, err := b.acquire(); err != nil || !held { + t.Errorf("a released a lease with an hour left and the successor still could not take it: held=%v err=%v", held, err) + } + }) +} + +func TestReleasingALeaseSomebodyElseHoldsDoesNothing(t *testing.T) { + eachDialect(t, func(t *testing.T, db *gorm.DB) { + a := &lease{db: db, owner: "instance-a", ttl: time.Minute} + stale := &lease{db: db, owner: "instance-gone", ttl: time.Minute} + + if held, err := a.acquire(); err != nil || !held { + t.Fatalf("a.acquire: held=%v err=%v", held, err) + } + if err := stale.release(); err != nil { + t.Fatalf("stale.release: %v", err) + } + if got := readLease(t, db).Owner; got != "instance-a" { + t.Errorf("owner is %q; an instance that already lost the lease cleared its successor's row", got) + } + }) +} + +func TestAMissingLeaseRowIsReportedRatherThanSilentlyNeverScheduling(t *testing.T) { + eachDialect(t, func(t *testing.T, db *gorm.DB) { + if err := db.Where("name = ?", models2.SchedulerLeaseName). + Delete(&models2.SysJobLease{}).Error; err != nil { + t.Fatalf("deleting the lease row: %v", err) + } + a := &lease{db: db, owner: "instance-a", ttl: time.Minute} + held, err := a.acquire() + if held { + t.Fatal("acquire reported the lease held with no row to hold") + } + if err == nil { + t.Error("a missing lease row was reported as an ordinary 'someone else holds it': jobs would never run anywhere and nothing would say why") + } + }) +} + +func readLease(t *testing.T, db *gorm.DB) models2.SysJobLease { + t.Helper() + var row models2.SysJobLease + if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil { + t.Fatalf("reading the lease row: %v", err) + } + return row +} + +func expire(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.Model(&models2.SysJobLease{}). + Where("name = ?", models2.SchedulerLeaseName). + Update("expires_at_ms", 0).Error; err != nil { + t.Fatalf("expiring the lease: %v", err) + } +} diff --git a/app/jobs/models/sys_job_lease.go b/app/jobs/models/sys_job_lease.go new file mode 100644 index 00000000..b8cfd9ac --- /dev/null +++ b/app/jobs/models/sys_job_lease.go @@ -0,0 +1,58 @@ +package models + +// SchedulerLeaseName is the name of the one lease row per database. +// +// One row, not one per tenant: a tenant is a separate database with its own +// sys_job table and its own scheduler, so the row that decides who schedules +// it lives in that database alongside the jobs it governs. +const SchedulerLeaseName = "scheduler" + +// SysJobLease is the scheduler's single-writer lease over one database. +// +// app/jobs registers every enabled job into an in-process cron.Cron and keeps +// each job's scheduler handle in sys_job.entry_id. The scheduler is per +// process and entry_id is one shared column, so a second instance pointed at +// the same database does not divide the work - it overwrites it, and nothing +// logs that it did (issue #915). Only the holder of this lease calls +// jobs.Setup, which keeps the scheduler single-writer while the HTTP side +// still scales. +// +// It deliberately embeds neither models.ModelTime nor models.ControlBy. A +// lease is machine state, not a record a person creates, edits or +// soft-deletes: there is no author to attribute it to, and a deleted-but- +// present lease row would be a row that both does and does not hold the +// scheduler. +type SysJobLease struct { + // Name is the lease being held. The migration seeds exactly one row, + // SchedulerLeaseName, and the runtime only ever updates it - there is + // no insert path, so two instances starting at once cannot race to + // create the row they are both trying to claim. + Name string `json:"name" gorm:"type:varchar(64);primaryKey"` + + // Owner identifies the process that holds the lease. Empty means the + // lease is free, which is what the migration seeds. + Owner string `json:"owner" gorm:"type:varchar(191);not null"` + + // AcquiredAtMs is when the current owner took the lease, not when it + // last renewed: a leader that has held it for an hour and one that took + // over a second ago are different situations, and only this column + // tells them apart. Renewal moves ExpiresAtMs and leaves this alone. + AcquiredAtMs int64 `json:"acquiredAtMs" gorm:"column:acquired_at_ms;not null"` + + // ExpiresAtMs is when another instance may take the lease. + // + // Milliseconds since the Unix epoch, in a BIGINT, rather than a + // timestamp column. A timestamp crossing the driver boundary carries + // timezone semantics that the driver applies on the way through: with + // go-admin's own `parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP + // comes back labelled as local time, and a lease written in Asia/ + // Shanghai is then eight hours out - in whichever direction makes every + // other instance's lease look expired. An integer has no timezone for + // anything to apply, and the comparison that decides who schedules + // becomes integer arithmetic that no DSN setting can reinterpret. + ExpiresAtMs int64 `json:"expiresAtMs" gorm:"column:expires_at_ms;not null"` +} + +func (*SysJobLease) TableName() string { + return "sys_job_lease" +} diff --git a/cmd/migrate/migration/version/1786700009000_job_scheduler_lease.go b/cmd/migrate/migration/version/1786700009000_job_scheduler_lease.go new file mode 100644 index 00000000..24669f82 --- /dev/null +++ b/cmd/migrate/migration/version/1786700009000_job_scheduler_lease.go @@ -0,0 +1,61 @@ +package version + +import ( + "runtime" + + "gorm.io/gorm" + + jobmodels "go-admin/app/jobs/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" +) + +// Create sys_job_lease and seed the one row the scheduler competes for +// (issue #915). +// +// The row is seeded here rather than created on demand at startup. Two +// instances starting together would otherwise race to insert the very row +// they are each trying to claim, and the loser would have to tell a +// duplicate-key error apart from a real one in whichever driver it is +// running against. Seeding it makes the runtime path two UPDATE statements +// and nothing else. +// +// It is seeded free - no owner, and an expiry far enough in the past that +// the first instance to ask takes it - so that installing this migration +// does not leave the scheduler waiting out a TTL that nobody is holding. +// +// Ordered after 1786700003000 (the soft-delete conversion), so importing +// cmd/migrate/migration/models is banned here - see +// schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels. +// sys_job_lease is AutoMigrate'd from its runtime model under +// app/jobs/models directly, and it is absent from 1786700003000's frozen +// softDeleteTables list because it embeds no common.ModelTime: a lease that +// could be soft-deleted would be a row that both does and does not hold the +// scheduler. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700009000JobSchedulerLease) +} + +func _1786700009000JobSchedulerLease(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Migrator().AutoMigrate(new(jobmodels.SysJobLease)); err != nil { + return err + } + + // Seeded free: no owner, and an expiry of 0 - before every clock + // reading there will ever be - so the first instance to ask takes + // it rather than waiting out a TTL nobody is holding. + lease := jobmodels.SysJobLease{ + Name: jobmodels.SchedulerLeaseName, + Owner: "", + AcquiredAtMs: 0, + ExpiresAtMs: 0, + } + if err := tx.Create(&lease).Error; err != nil { + return err + } + + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/cmd/migrate/migration/version/1786700009000_job_scheduler_lease_test.go b/cmd/migrate/migration/version/1786700009000_job_scheduler_lease_test.go new file mode 100644 index 00000000..eea0aed3 --- /dev/null +++ b/cmd/migrate/migration/version/1786700009000_job_scheduler_lease_test.go @@ -0,0 +1,75 @@ +package version + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + jobmodels "go-admin/app/jobs/models" + common "go-admin/common/models" +) + +func openJobLeaseDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + return db +} + +// The runtime has no insert path - two instances starting together would +// race to create the row they are both trying to claim - so the row has to +// exist when the migration finishes or nothing ever schedules anything. +func TestTheSchedulerLeaseMigrationLeavesExactlyOneFreeRow(t *testing.T) { + db := openJobLeaseDB(t) + + if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil { + t.Fatalf("migrate: %v", err) + } + + if !db.Migrator().HasTable(&jobmodels.SysJobLease{}) { + t.Fatal("sys_job_lease was not created") + } + + var rows []jobmodels.SysJobLease + if err := db.Find(&rows).Error; err != nil { + t.Fatalf("reading sys_job_lease: %v", err) + } + if len(rows) != 1 { + t.Fatalf("sys_job_lease holds %d rows, want exactly 1", len(rows)) + } + + row := rows[0] + if row.Name != jobmodels.SchedulerLeaseName { + t.Errorf("the seeded row is named %q, want %q; acquire looks the row up by this name and would find nothing", + row.Name, jobmodels.SchedulerLeaseName) + } + if row.Owner != "" { + t.Errorf("the seeded lease is owned by %q; a fresh install would wait out a TTL held by nobody", row.Owner) + } + // Zero, not "now": the take is `expires_at_ms <= now`, so a seeded + // expiry in the future is a scheduler that does not start until it + // passes. + if row.ExpiresAtMs != 0 { + t.Errorf("the seeded lease expires at %d, want 0", row.ExpiresAtMs) + } +} + +func TestTheSchedulerLeaseMigrationRecordsItsVersion(t *testing.T) { + db := openJobLeaseDB(t) + + if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil { + t.Fatalf("migrate: %v", err) + } + + var got common.Migration + if err := db.Where("version = ?", "1786700009000").First(&got).Error; err != nil { + t.Fatalf("the migration did not record its version, so it would run again on every start: %v", err) + } +}