Files

423 lines
13 KiB
Go

package common
import (
"context"
"runtime/debug"
"sync"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/util/guid"
"github.com/robfig/cron/v3"
)
// CronJob defines a scheduled job.
type CronJob struct {
Name string
DisplayName string // 中文展示名,供管理后台列表展示;空则前端回退到 Name
Schedule string // cron expression, e.g. "0 0 * * *" for daily at midnight
Handler func(ctx context.Context) error
}
// JobInfo contains info about a registered job for listing.
type JobInfo struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Schedule string `json:"schedule"`
IsRunning bool `json:"is_running"`
}
// CronScheduler manages scheduled tasks with distributed locking.
type CronScheduler struct {
jobs []*CronJob
mu sync.RWMutex
running map[string]bool
runMu sync.Mutex
cron *cron.Cron
// entryIDs job 名 → robfig/cron 条目 ID,供 Reschedule 摘除旧条目。
// 仅在 StartBackground 之后有值。
entryIDs map[string]cron.EntryID
entryMu sync.Mutex
}
var (
cronScheduler *CronScheduler
cronSchedulerOnce sync.Once
)
// InitCronScheduler initializes the global cron scheduler singleton.
func InitCronScheduler() {
cronSchedulerOnce.Do(func() {
cronScheduler = NewCronScheduler()
})
}
// GetCronScheduler returns the global cron scheduler.
func GetCronScheduler() *CronScheduler {
if cronScheduler == nil {
panic("cron scheduler not initialized, call InitCronScheduler first")
}
return cronScheduler
}
// NewCronScheduler creates a new CronScheduler.
func NewCronScheduler() *CronScheduler {
return &CronScheduler{
running: make(map[string]bool),
entryIDs: make(map[string]cron.EntryID),
cron: cron.New(),
}
}
// Register adds a scheduled job.
// name 为全局唯一标识(用作分布式锁 key 和 sys_cron_jobs 主键,必须稳定,禁止中文);
// displayName 为管理后台展示用的中文名,与 name 就近声明,避免散落到前端映射表。
func (cs *CronScheduler) Register(name, displayName, schedule string, handler func(ctx context.Context) error) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.jobs = append(cs.jobs, &CronJob{
Name: name,
DisplayName: displayName,
Schedule: schedule,
Handler: handler,
})
}
// ListJobs returns all registered jobs with their running status.
func (cs *CronScheduler) ListJobs() []JobInfo {
cs.mu.RLock()
defer cs.mu.RUnlock()
jobs := make([]JobInfo, 0, len(cs.jobs))
for _, j := range cs.jobs {
cs.runMu.Lock()
running := cs.running[j.Name]
cs.runMu.Unlock()
jobs = append(jobs, JobInfo{
Name: j.Name,
DisplayName: j.DisplayName,
Schedule: j.Schedule,
IsRunning: running,
})
}
return jobs
}
// RunJob executes a named job with distributed locking to prevent concurrent execution.
func (cs *CronScheduler) RunJob(ctx context.Context, name string) error {
return cs.runJobInternal(ctx, name, "auto")
}
// TriggerJob triggers a job asynchronously (for manual execution).
func (cs *CronScheduler) TriggerJob(ctx context.Context, name string) error {
cs.mu.RLock()
var found bool
for _, j := range cs.jobs {
if j.Name == name {
found = true
break
}
}
cs.mu.RUnlock()
if !found {
return gerror.Newf("job %s not registered", name)
}
cs.runMu.Lock()
if cs.running[name] {
cs.runMu.Unlock()
return gerror.Newf("job %s is already running", name)
}
cs.runMu.Unlock()
go func() {
bgCtx := gctx.New()
if err := cs.runJobInternal(bgCtx, name, "manual"); err != nil {
g.Log().Errorf(bgCtx, "manual trigger failed: %s: %v", name, err)
}
}()
return nil
}
// runJobInternal executes a named job with execution tracking.
func (cs *CronScheduler) runJobInternal(ctx context.Context, name, triggeredBy string) error {
cs.runMu.Lock()
if cs.running[name] {
cs.runMu.Unlock()
return gerror.Newf("job %s is already running", name)
}
cs.running[name] = true
cs.runMu.Unlock()
defer func() {
cs.runMu.Lock()
delete(cs.running, name)
cs.runMu.Unlock()
}()
// Find job
cs.mu.RLock()
var job *CronJob
for _, j := range cs.jobs {
if j.Name == name {
job = j
break
}
}
cs.mu.RUnlock()
if job == nil {
return gerror.Newf("job %s not registered", name)
}
// 分布式锁(C2):多实例部署下,同名 job 只允许一个实例执行,避免日结算/告警检测/任务领取等
// 重复触发。进程内 running map 仅单实例互斥,跨实例无效。auto 与 manual 均加锁,防止手动触发与
// 另一实例的定时执行重叠。未获取到锁说明别的实例正在执行,本次跳过。
lockToken, locked := acquireCronLock(ctx, name)
if !locked {
g.Log().Infof(ctx, "cron job %s skipped: distributed lock held by another instance", name)
return nil
}
defer releaseCronLock(ctx, name, lockToken)
// Execute job
startTime := time.Now()
handlerErr := runHandlerSafely(ctx, job)
duration := time.Since(startTime)
status := "succeeded"
var errMsg string
if handlerErr != nil {
status = "failed"
errMsg = handlerErr.Error()
g.Log().Errorf(ctx, "cron job failed: %s, duration=%v, error=%v", name, duration, handlerErr)
}
// Persist execution record (best-effort)
cs.recordExecution(ctx, job.Name, status, startTime, duration, errMsg, triggeredBy)
return handlerErr
}
// runHandlerSafely 执行 job handler 并兜底 panic。
//
// robfig/cron v3 的 cron.New() 默认不带 Recover 链,其 startJob 是裸的
// `go func() { j.Run() }()`——任意一个 job panic 都会直接崩掉整个进程,而不只是
// 让该任务失败。此处把 panic 转成普通 error:进程存活,且失败会经 recordExecution
// 落进 sys_cron_jobs,管理后台的任务列表能看到,不会变成一次无声的重启。
func runHandlerSafely(ctx context.Context, job *CronJob) (err error) {
defer func() {
if r := recover(); r != nil {
// 堆栈只进日志:last_error 会入库并在管理后台展示,塞完整堆栈会撑爆展示
g.Log().Errorf(ctx, "cron job panicked: %s: %v\n%s", job.Name, r, debug.Stack())
err = gerror.Newf("任务执行 panic: %v", r)
}
}()
return job.Handler(ctx)
}
// recordExecution persists an execution record to the database.
// Always UPSERT sys_cron_jobs with latest status; no separate execution log table.
func (cs *CronScheduler) recordExecution(ctx context.Context, jobName, status string, startedAt time.Time, duration time.Duration, errMsg, triggeredBy string) {
finishedAt := startedAt.Add(duration)
durationMs := duration.Milliseconds()
if status == "succeeded" {
_, err := g.DB().Ctx(ctx).Exec(ctx,
`INSERT INTO sys_cron_jobs (job_name, last_status, last_started_at, last_finished_at, last_duration_ms, last_triggered_by, total_runs)
VALUES ($1, $2, $3, $4, $5, $6, 1)
ON CONFLICT (job_name) DO UPDATE SET
last_status = $2,
last_started_at = $3,
last_finished_at = $4,
last_duration_ms = $5,
last_triggered_by = $6,
last_error_message = NULL,
total_runs = sys_cron_jobs.total_runs + 1,
updated_at = now()`,
jobName, status, startedAt, finishedAt, durationMs, triggeredBy,
)
if err != nil {
g.Log().Warningf(ctx, "failed to upsert cron job %s: %v", jobName, err)
}
} else {
// failed
_, err := g.DB().Ctx(ctx).Exec(ctx,
`INSERT INTO sys_cron_jobs (job_name, last_status, last_started_at, last_finished_at, last_duration_ms, last_error_message, last_triggered_by, total_runs, total_failures)
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, 1)
ON CONFLICT (job_name) DO UPDATE SET
last_status = $2,
last_started_at = $3,
last_finished_at = $4,
last_duration_ms = $5,
last_error_message = $6,
last_triggered_by = $7,
total_runs = sys_cron_jobs.total_runs + 1,
total_failures = sys_cron_jobs.total_failures + 1,
updated_at = now()`,
jobName, status, startedAt, finishedAt, durationMs, errMsg, triggeredBy,
)
if err != nil {
g.Log().Warningf(ctx, "failed to upsert cron job %s: %v", jobName, err)
}
}
}
// RunAll runs all registered jobs sequentially.
// Used for startup initialization or testing.
func (cs *CronScheduler) RunAll(ctx context.Context) {
cs.mu.RLock()
jobs := make([]*CronJob, len(cs.jobs))
copy(jobs, cs.jobs)
cs.mu.RUnlock()
for _, job := range jobs {
if err := cs.RunJob(ctx, job.Name); err != nil {
g.Log().Errorf(ctx, "cron run all: job %s failed: %v", job.Name, err)
}
}
}
// StartBackground runs the scheduler in a goroutine using robfig/cron for proper cron expression parsing.
func (cs *CronScheduler) StartBackground(ctx context.Context) {
cs.mu.RLock()
jobs := make([]*CronJob, len(cs.jobs))
copy(jobs, cs.jobs)
cs.mu.RUnlock()
for _, job := range jobs {
j := job
entryID, err := cs.cron.AddFunc(j.Schedule, func() {
bgCtx := gctx.New()
if err := cs.RunJob(bgCtx, j.Name); err != nil {
g.Log().Errorf(bgCtx, "background cron error: %s: %v", j.Name, err)
}
})
if err != nil {
g.Log().Errorf(ctx, "failed to register cron job %s with schedule %s: %v", j.Name, j.Schedule, err)
continue
}
cs.entryMu.Lock()
cs.entryIDs[j.Name] = entryID
cs.entryMu.Unlock()
}
cs.cron.Start()
go func() {
<-ctx.Done()
stopCtx := cs.cron.Stop()
<-stopCtx.Done()
g.Log().Info(ctx, "cron scheduler stopped")
}()
g.Log().Info(ctx, "cron scheduler started in background")
}
// Reschedule 更新已注册 job 的调度表达式并即时生效(配置热更新用)。
//
// 先添加新条目、成功后再摘除旧条目:表达式非法时保持原调度不变,
// 避免一次错误配置把任务从调度器里彻底摘掉。
// StartBackground 之前调用无意义(尚无条目),此时只更新 job.Schedule 记录。
func (cs *CronScheduler) Reschedule(ctx context.Context, name, schedule string) error {
cs.mu.RLock()
var job *CronJob
for _, j := range cs.jobs {
if j.Name == name {
job = j
break
}
}
cs.mu.RUnlock()
if job == nil {
return gerror.Newf("job %s not registered", name)
}
cs.entryMu.Lock()
defer cs.entryMu.Unlock()
oldID, started := cs.entryIDs[name]
if !started {
cs.mu.Lock()
job.Schedule = schedule
cs.mu.Unlock()
return nil
}
newID, err := cs.cron.AddFunc(schedule, func() {
bgCtx := gctx.New()
if err := cs.RunJob(bgCtx, name); err != nil {
g.Log().Errorf(bgCtx, "background cron error: %s: %v", name, err)
}
})
if err != nil {
return gerror.Wrapf(err, "invalid schedule %q for job %s", schedule, name)
}
cs.cron.Remove(oldID)
cs.entryIDs[name] = newID
cs.mu.Lock()
oldSchedule := job.Schedule
job.Schedule = schedule
cs.mu.Unlock()
g.Log().Infof(ctx, "cron job %s rescheduled: %s → %s", name, oldSchedule, schedule)
return nil
}
// ScheduleInterval 解析 cron 表达式,估算两次触发之间的正常间隔。
// 取「接下来两次触发时刻之差」,对分钟/小时/日级表达式及 @every 描述符均适用
// (@every 的触发时刻相对计算起点平移,间隔不受影响)。解析失败(非法表达式)返回错误,
// 由调用方决定兜底策略——工作台的停摆判定靠它把「固定 1 小时」换成「按任务自身周期」。
func ScheduleInterval(schedule string) (time.Duration, error) {
sched, err := cron.ParseStandard(schedule)
if err != nil {
return 0, gerror.Wrapf(err, "invalid cron schedule %q", schedule)
}
now := time.Now()
next1 := sched.Next(now)
next2 := sched.Next(next1)
return next2.Sub(next1), nil
}
// cronLockTTL 分布式锁过期时间:仅用于实例崩溃时的兜底自动释放(正常完成会主动释放)。
// 取值需大于绝大多数 job 的执行时长;过长则崩溃后会阻塞该 job 至多这么久。
const cronLockTTL = 10 * time.Minute
// cronLockKey 返回某 job 的分布式锁 Redis key。
func cronLockKey(name string) string {
return "cron:lock:" + name
}
// acquireCronLock 通过 Redis SET NX EX 原子获取 job 分布式锁。
// 返回 (token, true) 表示获取成功;("", false) 表示锁被其他实例持有。
// Redis 不可用时降级为「获取成功但无 token」——退回进程内互斥语义(可能重复执行),
// 优于因 Redis 抖动导致所有实例都不执行(关键 job 如结算被整体跳过)。
func acquireCronLock(ctx context.Context, name string) (string, bool) {
token := guid.S()
res, err := g.Redis().Do(ctx, "SET", cronLockKey(name), token, "NX", "EX", int64(cronLockTTL.Seconds()))
if err != nil {
g.Log().Warningf(ctx, "cron lock acquire failed for %s, running without distributed lock: %v", name, err)
return "", true
}
if res.IsNil() {
return "", false
}
return token, true
}
// releaseCronLock 释放分布式锁:仅当锁仍属于本次持有的 token 时才删除(Lua CAS),
// 避免锁已过期被别的实例重新持有后被误删。token 为空(Redis 降级)时不操作。
func releaseCronLock(ctx context.Context, name, token string) {
if token == "" {
return
}
const lua = `if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end`
_, _ = g.Redis().Do(ctx, "EVAL", lua, 1, cronLockKey(name), token)
}