feat: a database lease the schedulers compete for

One row per database, taken and renewed by single UPDATE statements whose
RowsAffected the database decides. Nothing uses it yet; the scheduler is
wired to it next.

The two timestamps are epoch milliseconds in a BIGINT rather than timestamp
columns, which is the one decision here worth explaining. A timestamp does
not survive the trip through a driver unchanged: read over go-admin's own
`parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP arrives relabelled as
local time, and on a UTC+8 host every lease is eight hours out. It is
invisible to any test that compares the lease against itself, because each
instance's own arithmetic stays self-consistent - only the comparison
between two instances is wrong, which is the only comparison that matters.
An integer has no timezone for a driver to apply.

The migration seeds the row free. There is no insert path at runtime, so two
instances starting together cannot race to create the row they are both
trying to claim, and neither has to tell a duplicate-key error apart from a
real one in whichever driver it is running against. A missing row is
therefore reported rather than recovered from: silently never scheduling
anywhere is the worse failure.

The current-time expression differs per dialect and all four are covered:
MySQL, PostgreSQL and SQL Server against real servers, SQLite by default.
This commit is contained in:
zhangwenjian
2026-09-20 18:31:44 +08:00
parent 27ad988fd7
commit b4b5bc5b3a
5 changed files with 639 additions and 0 deletions
+177
View File
@@ -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
}
+268
View File
@@ -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)
}
}
+58
View File
@@ -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"
}
@@ -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
})
}
@@ -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)
}
}