Merge pull request #947 from go-admin-team/fix/915-scheduler-lease

fix🐛: make the job scheduler single-writer with a database lease
This commit is contained in:
wenjianzhang
2026-09-22 22:57:21 +08:00
committed by GitHub
11 changed files with 1069 additions and 32 deletions
+27
View File
@@ -68,6 +68,28 @@ jobs:
--health-retries 20
--health-start-period 20s
# The dialect most installations actually run, and until the
# scheduler lease (#915) the only one with no service here. The lease
# reads the database's clock, and the first implementation read it as
# a timestamp: over go-admin's own `parseTime=True&loc=Local` DSN,
# MySQL's UTC_TIMESTAMP comes back relabelled as local time, so on any
# host that is not UTC every lease was one zone offset out - and every
# assertion that compared the lease only against itself still passed.
# The three dialects that were here could not see it.
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: GoAdmin_Test1
MYSQL_DATABASE: goadmin_test
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pGoAdmin_Test1"
--health-interval 10s
--health-timeout 5s
--health-retries 20
--health-start-period 20s
env:
GO_ADMIN_TEST_REDIS_ADDR: 127.0.0.1:6379
# The soft-delete conversion drops an index, and gorm's PostgreSQL driver
@@ -77,6 +99,11 @@ jobs:
# See go-admin#919.
GO_ADMIN_TEST_POSTGRES_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=goadmin_test sslmode=disable"
GO_ADMIN_TEST_SQLSERVER_DSN: "sqlserver://sa:GoAdmin_Test1@127.0.0.1:1433?database=goadmin_test"
# loc=Local on purpose: it is what config/settings.yml ships and what
# made the timezone defect above reachable. A DSN here that quietly
# differed from the one installations use would test a configuration
# nobody runs.
GO_ADMIN_TEST_MYSQL_DSN: "root:GoAdmin_Test1@tcp(127.0.0.1:3306)/goadmin_test?charset=utf8mb4&parseTime=True&loc=Local"
steps:
+35 -16
View File
@@ -97,13 +97,20 @@ LOOP:
}
// Setup 初始化
// Setup gives every tenant a scheduler and a supervisor to decide whether
// this instance is the one that fills it.
//
// One owner id for the whole process, not one per tenant: the thing holding
// the leases is this process, and a log line naming it should name the same
// thing in every database it appears in.
func Setup(dbs map[string]*gorm.DB) {
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
owner := newOwnerID()
for k, db := range dbs {
sdk.Runtime.SetCrontabByTenant(k, cronjob.NewWithSeconds())
setup(k, db)
newSupervisor(k, db, owner).start()
}
}
@@ -149,7 +156,7 @@ func setup(key string, db *gorm.DB) {
startCrontab(crontab)
}
// startCrontab starts c and arranges for it to be stopped on the way out.
// startCrontab starts c.
//
// The stop used to be `defer crontab.Stop()` followed by `select {}`. The
// select never returned, so the defer never ran and the scheduler was never
@@ -158,24 +165,36 @@ func setup(key string, db *gorm.DB) {
// got a scheduler at all. cron.Start is itself `go c.run()`, so the select was
// blocking for nothing.
//
// cron.Stop returns a context that closes once the jobs already running have
// finished. That is the wait the shutdown budget exists to bound: giving up on
// it leaves those jobs running until the process exits, which is better than
// holding the whole shutdown open for one job that will not end.
// Stopping is no longer arranged here. A scheduler now stops for two
// different reasons - the process is going down, or this instance lost the
// lease (#915) - and only the supervisor knows which. Registering a shutdown
// callback per start, when a start happens every time the lease is taken,
// would also add one callback per leadership change for the life of the
// process: SetShutdown appends.
func startCrontab(c *cron.Cron) {
c.Start()
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore start success.")
}
// 关闭任务
sdk.Runtime.SetShutdown(func(ctx context.Context) {
stopped := c.Stop()
select {
case <-stopped.Done():
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
case <-ctx.Done():
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
}
})
// stopCrontab stops one tenant's scheduler and waits for the jobs already
// running to finish, bounded by ctx.
//
// cron.Stop returns a context that closes once those jobs have finished.
// That is the wait the shutdown budget exists to bound: giving up on it
// leaves them running until the process exits, which is better than holding
// the whole shutdown open for one job that will not end.
func stopCrontab(ctx context.Context, key string) {
c := sdk.Runtime.GetCrontabByTenant(key)
if c == nil {
return
}
stopped := c.Stop()
select {
case <-stopped.Done():
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
case <-ctx.Done():
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
}
}
// AddJob 添加任务 AddJob(invokeTarget string, jobId int, jobName string, cronExpression string)
+52 -4
View File
@@ -6,26 +6,45 @@ import (
"testing"
"time"
glebarez "github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
"gorm.io/gorm"
models2 "go-admin/app/jobs/models"
)
// The scheduler had never been stopped. `defer crontab.Stop()` sat directly
// above a `select {}` that never returned, so the deferred call was
// unreachable for the life of the process.
//
// It now goes through the supervisor, which is what production does and what
// owns the shutdown callback since the lease landed (#915): a scheduler stops
// either because the process is going down or because this instance lost the
// lease, and only the supervisor can tell those apart.
//
// There is one test rather than several because BeforeExit closes to further
// registration once it has run: a second RunShutdown in this binary would find
// an empty registry and pass while proving nothing.
func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
// registration once it has run: a second RunShutdown in this binary would
// find an empty registry and pass while proving nothing. The lease-release
// assertion is folded in here for the same reason.
func TestTheSchedulerIsStoppedAndTheLeaseHandedBackOnTheWayOut(t *testing.T) {
const tenant = "*"
db := leaseDB(t)
var ticks atomic.Int64
c := cronjob.NewWithSeconds()
if _, err := c.AddFunc("* * * * * *", func() { ticks.Add(1) }); err != nil {
t.Fatalf("AddFunc: %v", err)
}
sdk.Runtime.SetCrontabByTenant(tenant, c)
startCrontab(c)
s := newSupervisor(tenant, db, "instance-under-test")
s.start()
if !s.holdsLease() {
t.Fatal("the supervisor did not take a free lease, so this test would prove nothing about giving it back")
}
// It has to be running before stopping it can mean anything.
deadline := time.Now().Add(5 * time.Second)
@@ -49,4 +68,33 @@ func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
if n := ticks.Load() - at; n > 0 {
t.Errorf("the job fired %d more times after shutdown: the scheduler is still running", n)
}
// And the lease is free, so a successor takes it immediately instead of
// waiting out a TTL held by a process that has exited.
var row models2.SysJobLease
if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil {
t.Fatalf("reading the lease row: %v", err)
}
if row.Owner != "" {
t.Errorf("the lease is still owned by %q after shutdown; a successor would wait out the TTL", row.Owner)
}
}
// leaseDB is a database with the two tables jobs.setup touches and one free
// lease row, which is the shape migration 1786700009000 leaves behind.
func leaseDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("opening sqlite: %v", err)
}
if err := db.AutoMigrate(&models2.SysJob{}, &models2.SysJobLease{}); err != nil {
t.Fatalf("migrating: %v", err)
}
row := models2.SysJobLease{Name: models2.SchedulerLeaseName}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seeding the lease row: %v", err)
}
return db
}
+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"
}
+212
View File
@@ -0,0 +1,212 @@
package jobs
import (
"context"
"sync"
"time"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
"gorm.io/gorm"
)
// leaseTTL is how long a lease stays valid without being renewed, and
// leaseHeartbeat is how often the holder renews it.
//
// The gap between them is the point: at a third of the TTL, two consecutive
// renewals can fail - a restarting database, a paused container - and the
// third still lands before anything else may take the lease. Making them
// equal would hand the scheduler to another instance on the first missed
// beat.
//
// The TTL is also the longest the jobs can be stopped everywhere: an
// instance killed without running its shutdown leaves its lease behind, and
// the successor waits this long before taking it.
const (
leaseTTL = 30 * time.Second
leaseHeartbeat = 10 * time.Second
)
// supervisor keeps one tenant's scheduler in step with one lease.
//
// It exists because holding the lease is not a decision made once at
// startup. An instance that never gets the lease has to keep asking, or the
// death of the current holder would stop the jobs until somebody restarted a
// process by hand; and an instance that holds it has to stop scheduling the
// moment it can no longer prove it still does, or a network partition turns
// into the two-schedulers-at-once defect (#915) that the lease exists to
// prevent.
type supervisor struct {
key string
db *gorm.DB
lease *lease
mu sync.Mutex
running bool
// lastRenew is when this instance last proved it holds the lease. It
// is compared only against this process's own later readings, never
// against another instance's, so the monotonic clock is the right one
// here - the reason the lease itself reads the database's clock does
// not apply to measuring how long ago something happened locally.
lastRenew time.Time
stop chan struct{}
stopOnce sync.Once
done chan struct{}
}
func newSupervisor(key string, db *gorm.DB, owner string) *supervisor {
return &supervisor{
key: key,
db: db,
lease: &lease{db: db, owner: owner, ttl: leaseTTL},
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// start takes the lease if it is free, schedules this tenant's jobs if it
// got it, and then keeps both facts true for the life of the process.
//
// The first attempt is synchronous so that a single-instance deployment -
// which is nearly all of them - has its jobs registered by the time Setup
// returns, exactly as it did before there was a lease.
func (s *supervisor) start() {
s.tick()
go s.heartbeat()
sdk.Runtime.SetShutdown(func(ctx context.Context) {
s.shutdown(ctx)
})
}
func (s *supervisor) heartbeat() {
defer close(s.done)
t := time.NewTicker(leaseHeartbeat)
defer t.Stop()
for {
select {
case <-s.stop:
return
case <-t.C:
s.tick()
}
}
}
// tick asks for the lease and makes the scheduler match the answer.
func (s *supervisor) tick() {
held, err := s.lease.acquire()
if err != nil {
// Not knowing is not the same as having lost it. The lease is
// still ours until it expires, so the scheduler keeps running
// and this instance keeps trying - a database that is briefly
// unreachable must not stop the jobs, and must not hand them to
// anyone else either, because nobody else can reach it to take
// the lease.
log.Errorf("[Job] scheduler lease for %s: %v", s.key, err)
if s.heldFor() > leaseTTL {
log.Errorf("[Job] scheduler lease for %s has not been renewed in %v; stopping the scheduler before anything else takes it",
s.key, leaseTTL)
s.stopScheduling()
}
return
}
if !held {
s.stopScheduling()
return
}
s.mu.Lock()
s.lastRenew = time.Now()
already := s.running
s.mu.Unlock()
if !already {
s.startScheduling()
}
}
// heldFor reports how long it has been since this instance last proved it
// holds the lease. A zero lastRenew means it never has, which is not a lease
// that has gone stale.
func (s *supervisor) heldFor() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
if s.lastRenew.IsZero() {
return 0
}
return time.Since(s.lastRenew)
}
func (s *supervisor) startScheduling() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
log.Infof("[Job] holding the scheduler lease for %s; registering its jobs", s.key)
setup(s.key, s.db)
}
// stopScheduling stops this tenant's scheduler and puts a fresh one in its
// place.
//
// Fresh, rather than reusing the stopped one, because taking the lease back
// runs setup again and setup adds every enabled job to whatever scheduler is
// registered. Reusing it would leave the previous registration in place and
// fire every job twice - the symptom this whole change is here to remove,
// reintroduced one layer down.
func (s *supervisor) stopScheduling() {
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
}
s.running = false
s.mu.Unlock()
log.Infof("[Job] no longer holding the scheduler lease for %s; stopping its jobs", s.key)
ctx, cancel := context.WithTimeout(context.Background(), leaseHeartbeat)
defer cancel()
stopCrontab(ctx, s.key)
sdk.Runtime.SetCrontabByTenant(s.key, cronjob.NewWithSeconds())
}
// shutdown stops the heartbeat, stops the scheduler and hands the lease back
// so a successor can take it now rather than waiting out the TTL.
func (s *supervisor) shutdown(ctx context.Context) {
s.stopOnce.Do(func() { close(s.stop) })
select {
case <-s.done:
case <-ctx.Done():
}
s.mu.Lock()
wasRunning := s.running
s.running = false
s.mu.Unlock()
if wasRunning {
stopCrontab(ctx, s.key)
if err := s.lease.release(); err != nil {
log.Errorf("[Job] releasing the scheduler lease for %s: %v", s.key, err)
}
}
}
// holdsLease reports whether this instance is currently scheduling. It exists
// for the tests: everything else acts on the answer inside tick.
func (s *supervisor) holdsLease() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
+94
View File
@@ -0,0 +1,94 @@
package jobs
import (
"testing"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
models2 "go-admin/app/jobs/models"
)
// An instance that starts while another one holds the lease must not
// register the jobs. This is the whole point: every instance registering the
// whole enabled list into its own scheduler is what made one job fire once
// per instance (#915).
func TestASecondInstanceDoesNotScheduleWhileTheFirstHoldsTheLease(t *testing.T) {
const tenant = "second-instance"
db := leaseDB(t)
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
first := newSupervisor(tenant, db, "instance-a")
first.tick()
if !first.holdsLease() {
t.Fatal("the first instance did not take a free lease")
}
second := newSupervisor(tenant, db, "instance-b")
second.tick()
if second.holdsLease() {
t.Error("a second instance scheduled while the first holds the lease: the job would fire twice per tick")
}
}
// Losing the lease has to stop the scheduler, not merely stop it from being
// taken again. A holder that keeps scheduling after its lease has gone to
// somebody else is two schedulers at once - the defect the lease exists to
// prevent, reached from the other direction.
func TestTheSupervisorStopsSchedulingWhenItLosesTheLease(t *testing.T) {
const tenant = "loses-lease"
db := leaseDB(t)
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
holder := newSupervisor(tenant, db, "instance-a")
holder.tick()
if !holder.holdsLease() {
t.Fatal("the supervisor did not take a free lease, so losing it cannot be observed")
}
// What a partition looks like from the database's side: the lease
// lapsed and somebody else took it while this instance was away.
expire(t, db)
successor := &lease{db: db, owner: "instance-b", ttl: time.Minute}
if held, err := successor.acquire(); err != nil || !held {
t.Fatalf("the successor could not take the expired lease: held=%v err=%v", held, err)
}
holder.tick()
if holder.holdsLease() {
t.Error("the supervisor kept scheduling after the lease went to another instance")
}
if got := readLease(t, db).Owner; got != "instance-b" {
t.Errorf("owner is %q; the instance that lost the lease wrote over its successor", got)
}
}
// A database that cannot be reached is not the same as a lease that has been
// lost. Stopping on the first failed renewal would stop the jobs every time
// the database blinked - and hand them to nobody, because no other instance
// can reach it to take the lease either.
func TestABrieflyUnreachableDatabaseDoesNotStopTheScheduler(t *testing.T) {
const tenant = "db-blip"
db := leaseDB(t)
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
s := newSupervisor(tenant, db, "instance-a")
s.tick()
if !s.holdsLease() {
t.Fatal("the supervisor did not take a free lease")
}
// The table going missing is how an unreachable database presents to
// acquire: every statement against it returns an error.
if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil {
t.Fatalf("dropping the lease table: %v", err)
}
s.tick()
if !s.holdsLease() {
t.Error("one failed renewal stopped the scheduler; the lease had not expired yet and nobody else could have taken it")
}
}
@@ -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)
}
}
+10 -12
View File
@@ -23,20 +23,18 @@ metadata:
version: v1
spec:
# One replica, and the drain window below buys nothing at one replica: there
# is nowhere to send the traffic this pod stops taking. Raising it needs two
# changes that are not this number:
# is nowhere to send the traffic this pod stops taking.
#
# The volume below is shared by every replica, and the log path in
# settings.yml lives on it, so a second pod would append to the same
# rotating file.
# The scheduler no longer stands in the way of raising this. Every pod takes
# a lease row in its own database (sys_job_lease) and only the holder
# registers the jobs, so one enabled job fires once however many pods there
# are; a pod that loses the lease stops scheduling, and one that exits hands
# it back so a successor starts without waiting out the lease. See #915.
#
# The job scheduler is per process while its handle on a job is one shared
# column. Startup runs `UPDATE sys_job SET entry_id = 0 WHERE entry_id > 0`
# across the whole table (app/jobs/jobbase.go), so a second pod erases the
# first pod's ids and writes its own, and every pod registers the whole
# enabled list in its own scheduler. Neither symptom logs anything: an
# enabled job fires once per pod, and stopping one from the UI removes an
# entry from the wrong process and still answers 200. See #915.
# What still does stand in the way: the volume below is shared by every
# replica, and the log path in settings.yml lives on it, so a second pod
# appends to the same rotating file. Give each replica its own log
# destination before raising this.
replicas: 1
selector:
matchLabels: