From 449a46b3b5a77d72c95507890c2bbf7a023500dd Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sat, 10 Jan 2026 17:13:46 +0800 Subject: [PATCH 01/44] =?UTF-8?q?feat(scheuler):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E5=99=A8=E7=9A=84=E7=9B=B8=E5=85=B3=E6=96=B9?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 5 +- go.sum | 16 +++-- pkg/scheduler/init.go | 1 + pkg/scheduler/meta.go | 62 ++++++++++++++++ pkg/scheduler/scheduler.go | 142 +++++++++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 pkg/scheduler/init.go create mode 100644 pkg/scheduler/meta.go create mode 100644 pkg/scheduler/scheduler.go diff --git a/go.mod b/go.mod index 82a778d02..ebd89df0c 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/foxxorcat/weiyun-sdk-go v0.1.4 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 + github.com/go-co-op/gocron/v2 v2.19.0 github.com/go-resty/resty/v2 v2.16.5 github.com/go-webauthn/webauthn v0.13.4 github.com/golang-jwt/jwt/v4 v4.5.2 @@ -64,7 +65,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.14.0 github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 github.com/tchap/go-patricia/v2 v2.3.3 github.com/u2takey/ffmpeg-go v0.5.0 @@ -111,6 +112,7 @@ require ( github.com/jcmturner/goidentity/v6 v6.0.1 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/lanrat/extsort v1.0.2 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.0 // indirect @@ -118,6 +120,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/relvacode/iso8601 v1.6.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect diff --git a/go.sum b/go.sum index 029e5d514..db02aab0c 100644 --- a/go.sum +++ b/go.sum @@ -302,6 +302,8 @@ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-co-op/gocron/v2 v2.19.0 h1:OKf2y6LXPs/BgBI2fl8PxUpNAI1DA9Mg+hSeGOS38OU= +github.com/go-co-op/gocron/v2 v2.19.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -398,8 +400,6 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/halalcloud/golang-sdk-lite v0.0.0-20251006164234-3c629727c499 h1:4ovnBdiGDFi8putQGxhipuuhXItAgh4/YnzufPYkZkQ= -github.com/halalcloud/golang-sdk-lite v0.0.0-20251006164234-3c629727c499/go.mod h1:8x1h4rm3s8xMcTyJrq848sQ6BJnKzl57mDY4CNshdPM= github.com/halalcloud/golang-sdk-lite v0.0.0-20251105081800-78cbb6786c38 h1:lsK2GVgI2Ox0NkRpQnN09GBOH7jtsjFK5tcIgxXlLr0= github.com/halalcloud/golang-sdk-lite v0.0.0-20251105081800-78cbb6786c38/go.mod h1:8x1h4rm3s8xMcTyJrq848sQ6BJnKzl57mDY4CNshdPM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -468,6 +468,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -636,6 +638,8 @@ github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Ny github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -680,8 +684,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 h1:Sa+sR8aaAMFwxhXWENEnE6ZpqhZ9d7u1RT2722Rw6hc= github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5/go.mod h1:UdZiFUFu6e2WjjtjxivwXWcwc1N/8zgbkBR9QNucUOY= github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 h1:6Y51mutOvRGRx6KqyMNo//xk8B8o6zW9/RVmy1VamOs= @@ -742,8 +746,8 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= -go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= diff --git a/pkg/scheduler/init.go b/pkg/scheduler/init.go new file mode 100644 index 000000000..6990da0fd --- /dev/null +++ b/pkg/scheduler/init.go @@ -0,0 +1 @@ +package scheduler diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go new file mode 100644 index 000000000..6717303b0 --- /dev/null +++ b/pkg/scheduler/meta.go @@ -0,0 +1,62 @@ +package scheduler + +import ( + "context" + "sync" + + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" +) + +type JobRunner func(ctx context.Context, params ...any) error + +type SchedulerFactory func(ctx context.Context) (gocron.Scheduler, error) + +type taskFactory func() gocron.Task + +type jobCancelMap = *SafeMap[uuid.UUID, context.CancelFunc] + +func NewJobCancelMap() jobCancelMap { + return NewSafeMap[uuid.UUID, context.CancelFunc]() +} + +// 泛型的读写锁map +type SafeMap[K comparable, V any] struct { + lock sync.RWMutex + data map[K]V +} + +func NewSafeMap[K comparable, V any]() *SafeMap[K, V] { + return &SafeMap[K, V]{ + data: make(map[K]V), + } +} + +func (sm *SafeMap[K, V]) Get(key K) (V, bool) { + sm.lock.RLock() + defer sm.lock.RUnlock() + value, exists := sm.data[key] + return value, exists +} + +func (sm *SafeMap[K, V]) Set(key K, value V) { + sm.lock.Lock() + defer sm.lock.Unlock() + sm.data[key] = value +} + +func (sm *SafeMap[K, V]) Delete(key K) { + sm.lock.Lock() + defer sm.lock.Unlock() + delete(sm.data, key) +} + +func (sm *SafeMap[K, V]) GetAll() map[K]V { + sm.lock.RLock() + defer sm.lock.RUnlock() + result := make(map[K]V) + for k, v := range sm.data { + result[k] = v + } + return result +} diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go new file mode 100644 index 000000000..47674b958 --- /dev/null +++ b/pkg/scheduler/scheduler.go @@ -0,0 +1,142 @@ +package scheduler + +import ( + "context" + "errors" + + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" +) + +var schedulerMap = NewSafeMap[string, gocron.Scheduler]() +var schedulerJobCancelMap = NewSafeMap[string, jobCancelMap]() + +func RegsiterScheduler(name string, ctx context.Context, factory SchedulerFactory) error { + scheduler, err := factory(ctx) + if err != nil { + return err + } + if _, exists := schedulerMap.Get(name); exists { + return errors.New("scheduler already exists: " + name) + } + schedulerMap.Set(name, scheduler) + return nil +} + +func GetScheduler(name string) (gocron.Scheduler, bool) { + scheduler, exists := schedulerMap.Get(name) + return scheduler, exists +} + +func GetAllSchedulers() map[string]gocron.Scheduler { + return schedulerMap.GetAll() +} + +func RemoveScheduler(name string) error { + if scheduler, exists := schedulerMap.Get(name); exists { + err := scheduler.Shutdown() + if err != nil { + return err + } + schedulerMap.Delete(name) + } + return nil +} + +func RegsiterJob( + ctx context.Context, schedulername string, + jobName string, tags []string, + cron gocron.JobDefinition, runner JobRunner, pararms ...any) (gocron.Job, error) { + scheduler, exists := GetScheduler(schedulername) + if !exists { + return nil, errors.New("scheduler not found: " + schedulername) + } + jobCtx, cancel := context.WithCancel(ctx) + var finnalParams []any + if len(pararms) == 0 { + finnalParams = []any{jobCtx} + } else { + finnalParams = make([]any, 0, len(pararms)+1) + finnalParams = append(finnalParams, jobCtx) + finnalParams = append(finnalParams, pararms...) + } + task := gocron.NewTask(func(ctx context.Context, params ...any) error { + return runner(ctx, params...) + }, finnalParams...) + + job, err := scheduler.NewJob(cron, task, gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...)) + if err != nil { + cancel() + return nil, err + } + // 保存取消函数 + schedulerJobsCancel, exists := schedulerJobCancelMap.Get(schedulername) + if !exists { + schedulerJobsCancel = NewJobCancelMap() + schedulerJobsCancel.Set(job.ID(), cancel) + schedulerJobCancelMap.Set(schedulername, schedulerJobsCancel) + } else { + schedulerJobsCancel.Set(job.ID(), cancel) + } + return job, nil +} + +func StopJobs(schedulerName string, jobUUID ...uuid.UUID) error { + schedulerJobsCancel, exists := schedulerJobCancelMap.Get(schedulerName) + if !exists { + return errors.New("scheduler not found: " + schedulerName) + } + for _, jobID := range jobUUID { + cancelFunc, exists := schedulerJobsCancel.Get(jobID) + if !exists { + return errors.New("job not found: " + jobID.String()) + } + cancelFunc() + } + return nil +} + +func RemoveJobs(schedulername string, jobUUID ...uuid.UUID) error { + scheduler, exists := GetScheduler(schedulername) + if !exists { + return errors.New("scheduler not found: " + schedulername) + } + for _, jobID := range jobUUID { + err := scheduler.RemoveJob(jobID) + if err != nil { + return err + } + } + return nil +} + +func RemoveJobByName(schedulername string, jobName string) error { + scheduler, exists := GetScheduler(schedulername) + if !exists { + return errors.New("scheduler not found: " + schedulername) + } + jobs := scheduler.Jobs() + for _, job := range jobs { + if job.Name() == jobName { + scheduler.RemoveJob(job.ID()) + } + } + return nil +} + +// RemoveJobByTags removes all jobs that have at least one of the provided tags. +func RemoveJobByTags(schedulername string, tags ...string) error { + scheduler, exists := GetScheduler(schedulername) + if !exists { + return errors.New("scheduler not found: " + schedulername) + } + scheduler.RemoveByTags(tags...) + return nil +} + +func StopAndRemoveJobs(schedulername string, jobUUID ...uuid.UUID) { + for _, jobID := range jobUUID { + _ = StopJobs(schedulername, jobID) + _ = RemoveJobs(schedulername, jobID) + } +} From 00d442f2d0edc4df00e423865ca44d4074a1d939 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:29:01 +0800 Subject: [PATCH 02/44] =?UTF-8?q?refactor(scheduler):=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=A4=9A=E4=BE=8B=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 68 +++++++- pkg/scheduler/scheduler.go | 341 +++++++++++++++++++++++++++++-------- pkg/scheduler/util.go | 8 + 3 files changed, 347 insertions(+), 70 deletions(-) create mode 100644 pkg/scheduler/util.go diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 6717303b0..363e6bbb9 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -2,7 +2,9 @@ package scheduler import ( "context" + "strings" "sync" + "time" "github.com/go-co-op/gocron/v2" "github.com/google/uuid" @@ -10,12 +12,12 @@ import ( type JobRunner func(ctx context.Context, params ...any) error -type SchedulerFactory func(ctx context.Context) (gocron.Scheduler, error) - type taskFactory func() gocron.Task type jobCancelMap = *SafeMap[uuid.UUID, context.CancelFunc] +type JobLabels = map[string]string + func NewJobCancelMap() jobCancelMap { return NewSafeMap[uuid.UUID, context.CancelFunc]() } @@ -60,3 +62,65 @@ func (sm *SafeMap[K, V]) GetAll() map[K]V { } return result } + +func (sm *SafeMap[K, V]) Clear() { + sm.lock.Lock() + defer sm.lock.Unlock() + // 移除所有元素,但保持底层map不变 + for k := range sm.data { + delete(sm.data, k) + } +} + +type OpJob struct { + job gocron.Job + labels JobLabels + disabled bool +} + +func (o *OpJob) ID() uuid.UUID { + return o.job.ID() +} + +func (o *OpJob) Name() string { + return o.job.Name() +} + +func (o *OpJob) Labels() JobLabels { + return o.labels +} + +func (o *OpJob) Label(key string) (string, bool) { + value, exists := o.labels[key] + return value, exists +} + +func (o *OpJob) Disabled() bool { + return o.disabled +} +func (o *OpJob) LastRun() (time.Time, error) { + return o.job.LastRun() +} + +func (o *OpJob) NextRun() (time.Time, error) { + return o.job.NextRun() +} + +func (o *OpJob) NextRuns(n int) ([]time.Time, error) { + return o.job.NextRuns(n) +} + +func newOpJob(job gocron.Job, disabled bool) *OpJob { + labels := make(JobLabels) + for _, tag := range job.Tags() { + parts := strings.SplitN(tag, labelSep, 1) + if len(parts) == 2 { + labels[parts[0]] = parts[1] + } + } + return &OpJob{ + job: job, + labels: labels, + disabled: disabled, + } +} diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 47674b958..41479c140 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -8,48 +8,99 @@ import ( "github.com/google/uuid" ) -var schedulerMap = NewSafeMap[string, gocron.Scheduler]() -var schedulerJobCancelMap = NewSafeMap[string, jobCancelMap]() +// label的连接符 +const labelSep = "=" -func RegsiterScheduler(name string, ctx context.Context, factory SchedulerFactory) error { - scheduler, err := factory(ctx) - if err != nil { - return err - } - if _, exists := schedulerMap.Get(name); exists { - return errors.New("scheduler already exists: " + name) - } - schedulerMap.Set(name, scheduler) - return nil -} - -func GetScheduler(name string) (gocron.Scheduler, bool) { - scheduler, exists := schedulerMap.Get(name) - return scheduler, exists +type OpScheduler struct { + Name string + scheduler gocron.Scheduler + jobCancelMap jobCancelMap + jobDisabledMap *SafeMap[uuid.UUID, bool] } -func GetAllSchedulers() map[string]gocron.Scheduler { - return schedulerMap.GetAll() +func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, error) { + scheduler, err := gocron.NewScheduler(opts...) + if err != nil { + return nil, err + } + return &OpScheduler{ + scheduler: scheduler, + Name: name, + jobCancelMap: NewJobCancelMap(), + }, nil } -func RemoveScheduler(name string) error { - if scheduler, exists := schedulerMap.Get(name); exists { - err := scheduler.Shutdown() - if err != nil { - return err +func (o *OpScheduler) NewJob( + ctx context.Context, + jobName string, + cron gocron.JobDefinition, + labels []JobLabels, + runner JobRunner, pararms ...any) (*OpJob, error) { + jobCtx, cancel := context.WithCancel(ctx) + var finnalParams []any + if len(pararms) == 0 { + finnalParams = []any{jobCtx} + } else { + finnalParams = make([]any, 0, len(pararms)+1) + finnalParams = append(finnalParams, jobCtx) + finnalParams = append(finnalParams, pararms...) + } + jobUUID := uuid.New() + task := gocron.NewTask(func(ctx context.Context, params ...any) error { + // 判断是否被禁用 + if disabled, exists := o.jobDisabledMap.Get(jobUUID); exists && disabled { + return nil + } + return runner(ctx, params...) + }, finnalParams...) + var tags []string + if len(labels) > 0 { + for _, label := range labels { + for k, v := range label { + tags = append(tags, k+labelSep+v) + } } - schedulerMap.Delete(name) } - return nil + job, err := o.scheduler.NewJob(cron, task, gocron.WithIdentifier(jobUUID), gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...)) + if err != nil { + cancel() + return nil, err + } + // 保存取消函数 + o.jobCancelMap.Set(jobUUID, cancel) + disabled, exists := o.jobDisabledMap.Get(jobUUID) + if !exists { + disabled = false + } + return newOpJob(job, disabled), nil } -func RegsiterJob( - ctx context.Context, schedulername string, - jobName string, tags []string, - cron gocron.JobDefinition, runner JobRunner, pararms ...any) (gocron.Job, error) { - scheduler, exists := GetScheduler(schedulername) +// RunNow runs a job immediately by its UUID. +func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { + opJob, exists := o.GetJob(jobUUID) if !exists { - return nil, errors.New("scheduler not found: " + schedulername) + return errors.New("job not found: " + jobUUID.String()) + } + err := opJob.job.RunNow() + return err +} + +// UpdateJob updates an existing job by its UUID. +func (o *OpScheduler) UpdateJob( + ctx context.Context, + jobUUID uuid.UUID, + jobName string, + cron gocron.JobDefinition, + disabled bool, + labels []JobLabels, + runner JobRunner, pararms ...any) error { + if _, exists := o.GetJob(jobUUID); !exists { + return errors.New("job not found: " + jobUUID.String()) + } + // Stop and remove the existing job + err := o.scheduler.RemoveJob(jobUUID) + if err != nil { + return err } jobCtx, cancel := context.WithCancel(ctx) var finnalParams []any @@ -61,33 +112,109 @@ func RegsiterJob( finnalParams = append(finnalParams, pararms...) } task := gocron.NewTask(func(ctx context.Context, params ...any) error { + // 判断是否被禁用 + if disabled, exists := o.jobDisabledMap.Get(jobUUID); exists && disabled { + return nil + } return runner(ctx, params...) }, finnalParams...) - - job, err := scheduler.NewJob(cron, task, gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...)) + var tags []string + if len(labels) > 0 { + for _, label := range labels { + for k, v := range label { + tags = append(tags, k+labelSep+v) + } + } + } + _, err = o.scheduler.Update( + jobUUID, cron, task, + gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...), + ) if err != nil { cancel() - return nil, err + return err } // 保存取消函数 - schedulerJobsCancel, exists := schedulerJobCancelMap.Get(schedulername) + o.jobCancelMap.Set(jobUUID, cancel) + // 更新禁用状态 + o.jobDisabledMap.Set(jobUUID, disabled) + return nil +} + +// GetJob retrieves a job by its UUID. +func (o *OpScheduler) GetJob(jobUUID uuid.UUID) (*OpJob, bool) { + jobs := o.scheduler.Jobs() + for _, job := range jobs { + if job.ID() == jobUUID { + disabled, exists := o.jobDisabledMap.Get(jobUUID) + if !exists { + disabled = false + } + return newOpJob(job, disabled), true + } + } + return nil, false +} + +// GetJobsByLabels retrieves jobs that have all of the provided labels. +func (o *OpScheduler) GetJobsByLabels(labels ...JobLabels) []*OpJob { + jobs := o.scheduler.Jobs() + result := make([]*OpJob, 0) + for _, job := range jobs { + matched := true + for _, label := range labels { + for k, v := range label { + exists := sliceHasItem(job.Tags(), k+labelSep+v) + if !exists { + matched = false + break + } + } + } + if matched { + disabled, exists := o.jobDisabledMap.Get(job.ID()) + if !exists { + disabled = false + } + result = append(result, newOpJob(job, disabled)) + } + } + return result +} + +// DisableJob disables a job by its UUID. +func (o *OpScheduler) DisableJob(jobUUID uuid.UUID) error { + _, exists := o.GetJob(jobUUID) if !exists { - schedulerJobsCancel = NewJobCancelMap() - schedulerJobsCancel.Set(job.ID(), cancel) - schedulerJobCancelMap.Set(schedulername, schedulerJobsCancel) - } else { - schedulerJobsCancel.Set(job.ID(), cancel) + return errors.New("job not found: " + jobUUID.String()) } - return job, nil + o.jobDisabledMap.Set(jobUUID, true) + return nil } -func StopJobs(schedulerName string, jobUUID ...uuid.UUID) error { - schedulerJobsCancel, exists := schedulerJobCancelMap.Get(schedulerName) +// EnableJob enables a job by its UUID. +func (o *OpScheduler) EnableJob(jobUUID uuid.UUID) error { + _, exists := o.GetJob(jobUUID) if !exists { - return errors.New("scheduler not found: " + schedulerName) + return errors.New("job not found: " + jobUUID.String()) } + o.jobDisabledMap.Set(jobUUID, false) + return nil +} + +// StopAndDisableJob stops and disables a job by its UUID. +func (o *OpScheduler) StopAndDisableJob(jobUUID uuid.UUID) error { + err := o.StopJobs(jobUUID) + if err != nil { + return err + } + return o.DisableJob(jobUUID) +} + +// StopJobs stops jobs by their UUIDs. +func (o *OpScheduler) StopJobs(jobUUID ...uuid.UUID) error { for _, jobID := range jobUUID { - cancelFunc, exists := schedulerJobsCancel.Get(jobID) + cancelFunc, exists := o.jobCancelMap.Get(jobID) if !exists { return errors.New("job not found: " + jobID.String()) } @@ -96,47 +223,125 @@ func StopJobs(schedulerName string, jobUUID ...uuid.UUID) error { return nil } -func RemoveJobs(schedulername string, jobUUID ...uuid.UUID) error { - scheduler, exists := GetScheduler(schedulername) - if !exists { - return errors.New("scheduler not found: " + schedulername) - } +// RemoveJobs removes jobs by their UUIDs. +func (o *OpScheduler) RemoveJobs(jobUUID ...uuid.UUID) error { for _, jobID := range jobUUID { - err := scheduler.RemoveJob(jobID) + err := o.scheduler.RemoveJob(jobID) if err != nil { return err } + // remove cancel func + o.jobCancelMap.Delete(jobID) + // remove disabled mark + o.jobDisabledMap.Delete(jobID) } return nil } -func RemoveJobByName(schedulername string, jobName string) error { - scheduler, exists := GetScheduler(schedulername) - if !exists { - return errors.New("scheduler not found: " + schedulername) +// RemoveJobByTags removes all jobs that have all of the provided labels. +func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { + jobs := o.scheduler.Jobs() + if len(labels) == 0 { + return nil } - jobs := scheduler.Jobs() + needRemovedJobsUUID := make([]uuid.UUID, 0) for _, job := range jobs { - if job.Name() == jobName { - scheduler.RemoveJob(job.ID()) + matched := true + for _, label := range labels { + for k, v := range label { + exists := sliceHasItem(job.Tags(), k+labelSep+v) + if !exists { + matched = false + break + } + } + if !matched { + break + } + } + if matched { + needRemovedJobsUUID = append(needRemovedJobsUUID, job.ID()) } } + if len(needRemovedJobsUUID) > 0 { + return o.RemoveJobs(needRemovedJobsUUID...) + } return nil } -// RemoveJobByTags removes all jobs that have at least one of the provided tags. -func RemoveJobByTags(schedulername string, tags ...string) error { - scheduler, exists := GetScheduler(schedulername) - if !exists { - return errors.New("scheduler not found: " + schedulername) +// StopJobByLabels stops all jobs that have all of the provided labels. +func (o *OpScheduler) StopJobByLabels(labels ...JobLabels) error { + jobs := o.scheduler.Jobs() + if len(labels) == 0 { + return nil + } + needStopJobsUUID := make([]uuid.UUID, 0) + for _, job := range jobs { + matched := true + for _, label := range labels { + for k, v := range label { + exists := sliceHasItem(job.Tags(), k+labelSep+v) + if !exists { + matched = false + break + } + } + if !matched { + break + } + } + if matched { + needStopJobsUUID = append(needStopJobsUUID, job.ID()) + } + } + if len(needStopJobsUUID) > 0 { + return o.StopJobs(needStopJobsUUID...) } - scheduler.RemoveByTags(tags...) return nil } -func StopAndRemoveJobs(schedulername string, jobUUID ...uuid.UUID) { +// StopAndRemoveJobs stops and removes jobs by their UUIDs. +func (o *OpScheduler) StopAndRemoveJobs(jobUUID ...uuid.UUID) { for _, jobID := range jobUUID { - _ = StopJobs(schedulername, jobID) - _ = RemoveJobs(schedulername, jobID) + _ = o.StopJobs(jobID) + _ = o.RemoveJobs(jobID) + } +} + +// StopAndRemoveJobByLabels stops and removes jobs by their labels. +func (o *OpScheduler) StopAndRemoveJobByLabels(labels ...JobLabels) { + _ = o.StopJobByLabels(labels...) + _ = o.RemoveJobByLabels(labels...) +} + +// Start starts the scheduler. +func (o *OpScheduler) Start() error { + o.scheduler.Start() + return nil +} + +// Close is an alias for Shutdown. +func (o *OpScheduler) Close() error { + return o.Shutdown() +} + +// Shutdown stops the scheduler. +func (o *OpScheduler) Shutdown() error { + o.scheduler.Shutdown() + return nil +} + +func (o *OpScheduler) StopAllJobs() error { + o.scheduler.StopJobs() + return nil +} + +func (o *OpScheduler) RemoveAllJobs() error { + o.scheduler.StopJobs() + for _, job := range o.scheduler.Jobs() { + o.scheduler.RemoveJob(job.ID()) } + o.jobCancelMap.Clear() + o.jobDisabledMap.Clear() + return nil } diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go new file mode 100644 index 000000000..85b0ea242 --- /dev/null +++ b/pkg/scheduler/util.go @@ -0,0 +1,8 @@ +package scheduler + +import "slices" + +// sliceHasItem checks if a string exists in a slice of strings. +func sliceHasItem(slice []string, item string) bool { + return slices.Contains(slice, item) +} From 513d7258388ca51c4806a5b3e69a51e3221925aa Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:30:10 +0800 Subject: [PATCH 03/44] =?UTF-8?q?refactor(scheduler):=20=E5=A4=9A=E4=BE=8B?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=87=AA=E8=BA=AB=E4=B8=8D=E5=81=9A=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/init.go | 1 - 1 file changed, 1 deletion(-) delete mode 100644 pkg/scheduler/init.go diff --git a/pkg/scheduler/init.go b/pkg/scheduler/init.go deleted file mode 100644 index 6990da0fd..000000000 --- a/pkg/scheduler/init.go +++ /dev/null @@ -1 +0,0 @@ -package scheduler From c0ab702855eb4a570311150581b14876cb0564ca Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:37:52 +0800 Subject: [PATCH 04/44] =?UTF-8?q?refactor(scheduler):=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复了初始化 bug 添加了 jobsMap 缓存 修复了拼写错误 扩展了 SafeMap 缩小公开的方法和结构体 --- pkg/scheduler/meta.go | 32 ++++++++------ pkg/scheduler/scheduler.go | 85 +++++++++++++++++++------------------- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 363e6bbb9..d0435421b 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -12,48 +12,46 @@ import ( type JobRunner func(ctx context.Context, params ...any) error -type taskFactory func() gocron.Task - -type jobCancelMap = *SafeMap[uuid.UUID, context.CancelFunc] +type jobCancelMap = *safeMap[uuid.UUID, context.CancelFunc] type JobLabels = map[string]string -func NewJobCancelMap() jobCancelMap { - return NewSafeMap[uuid.UUID, context.CancelFunc]() +func newJobCancelMap() jobCancelMap { + return newSafeMap[uuid.UUID, context.CancelFunc]() } // 泛型的读写锁map -type SafeMap[K comparable, V any] struct { +type safeMap[K comparable, V any] struct { lock sync.RWMutex data map[K]V } -func NewSafeMap[K comparable, V any]() *SafeMap[K, V] { - return &SafeMap[K, V]{ +func newSafeMap[K comparable, V any]() *safeMap[K, V] { + return &safeMap[K, V]{ data: make(map[K]V), } } -func (sm *SafeMap[K, V]) Get(key K) (V, bool) { +func (sm *safeMap[K, V]) Get(key K) (V, bool) { sm.lock.RLock() defer sm.lock.RUnlock() value, exists := sm.data[key] return value, exists } -func (sm *SafeMap[K, V]) Set(key K, value V) { +func (sm *safeMap[K, V]) Set(key K, value V) { sm.lock.Lock() defer sm.lock.Unlock() sm.data[key] = value } -func (sm *SafeMap[K, V]) Delete(key K) { +func (sm *safeMap[K, V]) Delete(key K) { sm.lock.Lock() defer sm.lock.Unlock() delete(sm.data, key) } -func (sm *SafeMap[K, V]) GetAll() map[K]V { +func (sm *safeMap[K, V]) GetAll() map[K]V { sm.lock.RLock() defer sm.lock.RUnlock() result := make(map[K]V) @@ -63,7 +61,7 @@ func (sm *SafeMap[K, V]) GetAll() map[K]V { return result } -func (sm *SafeMap[K, V]) Clear() { +func (sm *safeMap[K, V]) Clear() { sm.lock.Lock() defer sm.lock.Unlock() // 移除所有元素,但保持底层map不变 @@ -72,6 +70,14 @@ func (sm *SafeMap[K, V]) Clear() { } } +func (sm *safeMap[K, V]) ForEach(fn func(K, V)) { + sm.lock.RLock() + defer sm.lock.RUnlock() + for k, v := range sm.data { + fn(k, v) + } +} + type OpJob struct { job gocron.Job labels JobLabels diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 41479c140..421623aa8 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -15,7 +15,8 @@ type OpScheduler struct { Name string scheduler gocron.Scheduler jobCancelMap jobCancelMap - jobDisabledMap *SafeMap[uuid.UUID, bool] + jobDisabledMap *safeMap[uuid.UUID, bool] + jobsMap *safeMap[uuid.UUID, *OpJob] } func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, error) { @@ -24,9 +25,11 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, return nil, err } return &OpScheduler{ - scheduler: scheduler, - Name: name, - jobCancelMap: NewJobCancelMap(), + scheduler: scheduler, + Name: name, + jobCancelMap: newJobCancelMap(), + jobDisabledMap: newSafeMap[uuid.UUID, bool](), + jobsMap: newSafeMap[uuid.UUID, *OpJob](), }, nil } @@ -35,15 +38,15 @@ func (o *OpScheduler) NewJob( jobName string, cron gocron.JobDefinition, labels []JobLabels, - runner JobRunner, pararms ...any) (*OpJob, error) { + runner JobRunner, params ...any) (*OpJob, error) { jobCtx, cancel := context.WithCancel(ctx) - var finnalParams []any - if len(pararms) == 0 { - finnalParams = []any{jobCtx} + var finalParams []any + if len(params) == 0 { + finalParams = []any{jobCtx} } else { - finnalParams = make([]any, 0, len(pararms)+1) - finnalParams = append(finnalParams, jobCtx) - finnalParams = append(finnalParams, pararms...) + finalParams = make([]any, 0, len(params)+1) + finalParams = append(finalParams, jobCtx) + finalParams = append(finalParams, params...) } jobUUID := uuid.New() task := gocron.NewTask(func(ctx context.Context, params ...any) error { @@ -52,7 +55,7 @@ func (o *OpScheduler) NewJob( return nil } return runner(ctx, params...) - }, finnalParams...) + }, finalParams...) var tags []string if len(labels) > 0 { for _, label := range labels { @@ -72,7 +75,9 @@ func (o *OpScheduler) NewJob( if !exists { disabled = false } - return newOpJob(job, disabled), nil + opJob := newOpJob(job, disabled) + o.jobsMap.Set(jobUUID, opJob) + return opJob, nil } // RunNow runs a job immediately by its UUID. @@ -93,7 +98,7 @@ func (o *OpScheduler) UpdateJob( cron gocron.JobDefinition, disabled bool, labels []JobLabels, - runner JobRunner, pararms ...any) error { + runner JobRunner, params ...any) error { if _, exists := o.GetJob(jobUUID); !exists { return errors.New("job not found: " + jobUUID.String()) } @@ -102,14 +107,15 @@ func (o *OpScheduler) UpdateJob( if err != nil { return err } + o.jobsMap.Delete(jobUUID) jobCtx, cancel := context.WithCancel(ctx) - var finnalParams []any - if len(pararms) == 0 { - finnalParams = []any{jobCtx} + var finalParams []any + if len(params) == 0 { + finalParams = []any{jobCtx} } else { - finnalParams = make([]any, 0, len(pararms)+1) - finnalParams = append(finnalParams, jobCtx) - finnalParams = append(finnalParams, pararms...) + finalParams = make([]any, 0, len(params)+1) + finalParams = append(finalParams, jobCtx) + finalParams = append(finalParams, params...) } task := gocron.NewTask(func(ctx context.Context, params ...any) error { // 判断是否被禁用 @@ -117,7 +123,7 @@ func (o *OpScheduler) UpdateJob( return nil } return runner(ctx, params...) - }, finnalParams...) + }, finalParams...) var tags []string if len(labels) > 0 { for _, label := range labels { @@ -126,7 +132,7 @@ func (o *OpScheduler) UpdateJob( } } } - _, err = o.scheduler.Update( + job, err := o.scheduler.Update( jobUUID, cron, task, gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...), ) @@ -138,47 +144,37 @@ func (o *OpScheduler) UpdateJob( o.jobCancelMap.Set(jobUUID, cancel) // 更新禁用状态 o.jobDisabledMap.Set(jobUUID, disabled) + // 更新 jobsMap + opJob := newOpJob(job, disabled) + o.jobsMap.Set(jobUUID, opJob) return nil } // GetJob retrieves a job by its UUID. func (o *OpScheduler) GetJob(jobUUID uuid.UUID) (*OpJob, bool) { - jobs := o.scheduler.Jobs() - for _, job := range jobs { - if job.ID() == jobUUID { - disabled, exists := o.jobDisabledMap.Get(jobUUID) - if !exists { - disabled = false - } - return newOpJob(job, disabled), true - } - } - return nil, false + return o.jobsMap.Get(jobUUID) } // GetJobsByLabels retrieves jobs that have all of the provided labels. func (o *OpScheduler) GetJobsByLabels(labels ...JobLabels) []*OpJob { - jobs := o.scheduler.Jobs() result := make([]*OpJob, 0) - for _, job := range jobs { + o.jobsMap.ForEach(func(_ uuid.UUID, opJob *OpJob) { matched := true for _, label := range labels { for k, v := range label { - exists := sliceHasItem(job.Tags(), k+labelSep+v) - if !exists { + if val, exists := opJob.Label(k); !exists || val != v { matched = false break } } + if !matched { + break + } } if matched { - disabled, exists := o.jobDisabledMap.Get(job.ID()) - if !exists { - disabled = false - } - result = append(result, newOpJob(job, disabled)) + result = append(result, opJob) } - } + }) return result } @@ -234,6 +230,8 @@ func (o *OpScheduler) RemoveJobs(jobUUID ...uuid.UUID) error { o.jobCancelMap.Delete(jobID) // remove disabled mark o.jobDisabledMap.Delete(jobID) + // remove from jobsMap + o.jobsMap.Delete(jobID) } return nil } @@ -343,5 +341,6 @@ func (o *OpScheduler) RemoveAllJobs() error { } o.jobCancelMap.Clear() o.jobDisabledMap.Clear() + o.jobsMap.Clear() return nil } From 31016fb5952b732f2303aa515a159bd2d54c9abb Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:50:37 +0800 Subject: [PATCH 05/44] =?UTF-8?q?fix(scheduler):=20=E7=A7=BB=E9=99=A4disab?= =?UTF-8?q?ledMarkMap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 62 ++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 421623aa8..ebd3a5980 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -12,11 +12,10 @@ import ( const labelSep = "=" type OpScheduler struct { - Name string - scheduler gocron.Scheduler - jobCancelMap jobCancelMap - jobDisabledMap *safeMap[uuid.UUID, bool] - jobsMap *safeMap[uuid.UUID, *OpJob] + Name string + scheduler gocron.Scheduler + jobCancelMap jobCancelMap + jobsMap *safeMap[uuid.UUID, *OpJob] } func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, error) { @@ -25,11 +24,10 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, return nil, err } return &OpScheduler{ - scheduler: scheduler, - Name: name, - jobCancelMap: newJobCancelMap(), - jobDisabledMap: newSafeMap[uuid.UUID, bool](), - jobsMap: newSafeMap[uuid.UUID, *OpJob](), + scheduler: scheduler, + Name: name, + jobCancelMap: newJobCancelMap(), + jobsMap: newSafeMap[uuid.UUID, *OpJob](), }, nil } @@ -51,7 +49,13 @@ func (o *OpScheduler) NewJob( jobUUID := uuid.New() task := gocron.NewTask(func(ctx context.Context, params ...any) error { // 判断是否被禁用 - if disabled, exists := o.jobDisabledMap.Get(jobUUID); exists && disabled { + j, exists := o.jobsMap.Get(jobUUID) + // 理论上不会不存在,但为了保险起见加个判断 + if !exists { + return nil + } + // 被禁用则不执行 + if j.disabled { return nil } return runner(ctx, params...) @@ -71,11 +75,8 @@ func (o *OpScheduler) NewJob( } // 保存取消函数 o.jobCancelMap.Set(jobUUID, cancel) - disabled, exists := o.jobDisabledMap.Get(jobUUID) - if !exists { - disabled = false - } - opJob := newOpJob(job, disabled) + // 保存 job + opJob := newOpJob(job, false) o.jobsMap.Set(jobUUID, opJob) return opJob, nil } @@ -119,7 +120,13 @@ func (o *OpScheduler) UpdateJob( } task := gocron.NewTask(func(ctx context.Context, params ...any) error { // 判断是否被禁用 - if disabled, exists := o.jobDisabledMap.Get(jobUUID); exists && disabled { + j, exists := o.jobsMap.Get(jobUUID) + // 理论上不会不存在,但为了保险起见加个判断 + if !exists { + return nil + } + // 被禁用则不执行 + if j.disabled { return nil } return runner(ctx, params...) @@ -142,8 +149,6 @@ func (o *OpScheduler) UpdateJob( } // 保存取消函数 o.jobCancelMap.Set(jobUUID, cancel) - // 更新禁用状态 - o.jobDisabledMap.Set(jobUUID, disabled) // 更新 jobsMap opJob := newOpJob(job, disabled) o.jobsMap.Set(jobUUID, opJob) @@ -178,23 +183,23 @@ func (o *OpScheduler) GetJobsByLabels(labels ...JobLabels) []*OpJob { return result } -// DisableJob disables a job by its UUID. -func (o *OpScheduler) DisableJob(jobUUID uuid.UUID) error { - _, exists := o.GetJob(jobUUID) +// EnableJob enables a job by its UUID. +func (o *OpScheduler) EnableJob(jobUUID uuid.UUID) error { + opJob, exists := o.GetJob(jobUUID) if !exists { return errors.New("job not found: " + jobUUID.String()) } - o.jobDisabledMap.Set(jobUUID, true) + opJob.disabled = false return nil } -// EnableJob enables a job by its UUID. -func (o *OpScheduler) EnableJob(jobUUID uuid.UUID) error { - _, exists := o.GetJob(jobUUID) +// DisableJob disables a job by its UUID. +func (o *OpScheduler) DisableJob(jobUUID uuid.UUID) error { + opJob, exists := o.GetJob(jobUUID) if !exists { return errors.New("job not found: " + jobUUID.String()) } - o.jobDisabledMap.Set(jobUUID, false) + opJob.disabled = true return nil } @@ -228,8 +233,6 @@ func (o *OpScheduler) RemoveJobs(jobUUID ...uuid.UUID) error { } // remove cancel func o.jobCancelMap.Delete(jobID) - // remove disabled mark - o.jobDisabledMap.Delete(jobID) // remove from jobsMap o.jobsMap.Delete(jobID) } @@ -340,7 +343,6 @@ func (o *OpScheduler) RemoveAllJobs() error { o.scheduler.RemoveJob(job.ID()) } o.jobCancelMap.Clear() - o.jobDisabledMap.Clear() o.jobsMap.Clear() return nil } From 1823c305cb34b89c1cfecdb83e086ae3419aa7aa Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:48:34 +0800 Subject: [PATCH 06/44] =?UTF-8?q?fix(scheduler):=20=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=B0=81=E8=A3=85=EF=BC=8C=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E9=97=AE=E9=A2=98=EF=BC=8C=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=B3=A8=E8=A7=A3=E7=9A=84=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 34 ++++++-- pkg/scheduler/scheduler.go | 169 +++++++++++++++++++------------------ 2 files changed, 114 insertions(+), 89 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index d0435421b..912d5edf2 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -10,17 +10,21 @@ import ( "github.com/google/uuid" ) +// JobRunner defines the function signature for job runners type JobRunner func(ctx context.Context, params ...any) error +// jobCancelMap is a thread-safe map for storing job cancel functions type jobCancelMap = *safeMap[uuid.UUID, context.CancelFunc] -type JobLabels = map[string]string - +// newJobCancelMap creates a new jobCancelMap instance func newJobCancelMap() jobCancelMap { return newSafeMap[uuid.UUID, context.CancelFunc]() } -// 泛型的读写锁map +// JobLabels the type for job labels +type JobLabels = map[string]string + +// safeMap is a thread-safe map implementation type safeMap[K comparable, V any] struct { lock sync.RWMutex data map[K]V @@ -32,6 +36,7 @@ func newSafeMap[K comparable, V any]() *safeMap[K, V] { } } +// Get retrieves a value by key from the safeMap. func (sm *safeMap[K, V]) Get(key K) (V, bool) { sm.lock.RLock() defer sm.lock.RUnlock() @@ -39,18 +44,21 @@ func (sm *safeMap[K, V]) Get(key K) (V, bool) { return value, exists } +// Set sets a key-value pair in the safeMap. func (sm *safeMap[K, V]) Set(key K, value V) { sm.lock.Lock() defer sm.lock.Unlock() sm.data[key] = value } +// Delete removes a key-value pair from the safeMap by key. func (sm *safeMap[K, V]) Delete(key K) { sm.lock.Lock() defer sm.lock.Unlock() delete(sm.data, key) } +// GetAll retrieves all key-value pairs from the safeMap. func (sm *safeMap[K, V]) GetAll() map[K]V { sm.lock.RLock() defer sm.lock.RUnlock() @@ -61,6 +69,7 @@ func (sm *safeMap[K, V]) GetAll() map[K]V { return result } +// Clear removes all key-value pairs from the safeMap. func (sm *safeMap[K, V]) Clear() { sm.lock.Lock() defer sm.lock.Unlock() @@ -70,6 +79,7 @@ func (sm *safeMap[K, V]) Clear() { } } +// ForEach iterates over all key-value pairs in the safeMap and applies the provided function. func (sm *safeMap[K, V]) ForEach(fn func(K, V)) { sm.lock.RLock() defer sm.lock.RUnlock() @@ -78,44 +88,56 @@ func (sm *safeMap[K, V]) ForEach(fn func(K, V)) { } } +// OpJob represents an operational job with its metadata. type OpJob struct { - job gocron.Job - labels JobLabels - disabled bool + job gocron.Job + labels JobLabels + disableRWMutex sync.RWMutex + disabled bool } +// ID returns the UUID of the job. func (o *OpJob) ID() uuid.UUID { return o.job.ID() } +// Name returns the name of the job. func (o *OpJob) Name() string { return o.job.Name() } +// Labels returns the labels of the job. func (o *OpJob) Labels() JobLabels { return o.labels } +// Label retrieves the value of a specific label by its key. func (o *OpJob) Label(key string) (string, bool) { value, exists := o.labels[key] return value, exists } +// Disabled indicates whether the job is disabled. func (o *OpJob) Disabled() bool { return o.disabled } + +// LastRun returns the last run time of the job. func (o *OpJob) LastRun() (time.Time, error) { return o.job.LastRun() } +// NextRun returns the next run time of the job. func (o *OpJob) NextRun() (time.Time, error) { return o.job.NextRun() } +// NextRuns returns the next n run times of the job. func (o *OpJob) NextRuns(n int) ([]time.Time, error) { return o.job.NextRuns(n) } +// newOpJob creates a new OpJob instance from a gocron.Job and its disabled status. func newOpJob(job gocron.Job, disabled bool) *OpJob { labels := make(JobLabels) for _, tag := range job.Tags() { diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index ebd3a5980..985149132 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -3,6 +3,7 @@ package scheduler import ( "context" "errors" + "strings" "github.com/go-co-op/gocron/v2" "github.com/google/uuid" @@ -11,6 +12,7 @@ import ( // label的连接符 const labelSep = "=" +// OpScheduler is the main scheduler struct that manages jobs. type OpScheduler struct { Name string scheduler gocron.Scheduler @@ -18,6 +20,7 @@ type OpScheduler struct { jobsMap *safeMap[uuid.UUID, *OpJob] } +// NewOpScheduler creates a new OpScheduler instance. func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, error) { scheduler, err := gocron.NewScheduler(opts...) if err != nil { @@ -31,12 +34,36 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, }, nil } -func (o *OpScheduler) NewJob( - ctx context.Context, - jobName string, - cron gocron.JobDefinition, - labels []JobLabels, - runner JobRunner, params ...any) (*OpJob, error) { +// RunNow runs a job immediately by its UUID. +func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { + opJob, exists := o.GetJob(jobUUID) + if !exists { + return errors.New("job not found: " + jobUUID.String()) + } + err := opJob.job.RunNow() + return err +} + +func (o *OpScheduler) jobLabels2Tags(labels JobLabels) []string { + tags := make([]string, 0, len(labels)) + for k, v := range labels { + tags = append(tags, k+labelSep+v) + } + return tags +} + +func (o *OpScheduler) tags2JobLabels(tags []string) JobLabels { + labels := make(JobLabels) + for _, tag := range tags { + parts := strings.SplitN(tag, labelSep, 2) + if len(parts) == 2 { + labels[parts[0]] = parts[1] + } + } + return labels +} + +func (o *OpScheduler) buildJobParams(ctx context.Context, jobUUID uuid.UUID, runner JobRunner, params ...any) (gocron.Task, context.Context, context.CancelFunc) { jobCtx, cancel := context.WithCancel(ctx) var finalParams []any if len(params) == 0 { @@ -46,7 +73,6 @@ func (o *OpScheduler) NewJob( finalParams = append(finalParams, jobCtx) finalParams = append(finalParams, params...) } - jobUUID := uuid.New() task := gocron.NewTask(func(ctx context.Context, params ...any) error { // 判断是否被禁用 j, exists := o.jobsMap.Get(jobUUID) @@ -55,19 +81,27 @@ func (o *OpScheduler) NewJob( return nil } // 被禁用则不执行 - if j.disabled { + j.disableRWMutex.RLock() + disabled := j.disabled + j.disableRWMutex.RUnlock() + if disabled { return nil } return runner(ctx, params...) }, finalParams...) - var tags []string - if len(labels) > 0 { - for _, label := range labels { - for k, v := range label { - tags = append(tags, k+labelSep+v) - } - } - } + return task, jobCtx, cancel +} + +// NewJob creates and schedules a new job. +func (o *OpScheduler) NewJob( + ctx context.Context, + jobName string, + cron gocron.JobDefinition, + labels JobLabels, + runner JobRunner, params ...any) (*OpJob, error) { + jobUUID := uuid.New() + tags := o.jobLabels2Tags(labels) + task, jobCtx, cancel := o.buildJobParams(ctx, jobUUID, runner, params...) job, err := o.scheduler.NewJob(cron, task, gocron.WithIdentifier(jobUUID), gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...)) if err != nil { cancel() @@ -81,16 +115,6 @@ func (o *OpScheduler) NewJob( return opJob, nil } -// RunNow runs a job immediately by its UUID. -func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { - opJob, exists := o.GetJob(jobUUID) - if !exists { - return errors.New("job not found: " + jobUUID.String()) - } - err := opJob.job.RunNow() - return err -} - // UpdateJob updates an existing job by its UUID. func (o *OpScheduler) UpdateJob( ctx context.Context, @@ -98,47 +122,15 @@ func (o *OpScheduler) UpdateJob( jobName string, cron gocron.JobDefinition, disabled bool, - labels []JobLabels, + labels JobLabels, runner JobRunner, params ...any) error { - if _, exists := o.GetJob(jobUUID); !exists { - return errors.New("job not found: " + jobUUID.String()) - } // Stop and remove the existing job - err := o.scheduler.RemoveJob(jobUUID) + err := o.RemoveJobs(jobUUID) if err != nil { return err } - o.jobsMap.Delete(jobUUID) - jobCtx, cancel := context.WithCancel(ctx) - var finalParams []any - if len(params) == 0 { - finalParams = []any{jobCtx} - } else { - finalParams = make([]any, 0, len(params)+1) - finalParams = append(finalParams, jobCtx) - finalParams = append(finalParams, params...) - } - task := gocron.NewTask(func(ctx context.Context, params ...any) error { - // 判断是否被禁用 - j, exists := o.jobsMap.Get(jobUUID) - // 理论上不会不存在,但为了保险起见加个判断 - if !exists { - return nil - } - // 被禁用则不执行 - if j.disabled { - return nil - } - return runner(ctx, params...) - }, finalParams...) - var tags []string - if len(labels) > 0 { - for _, label := range labels { - for k, v := range label { - tags = append(tags, k+labelSep+v) - } - } - } + task, jobCtx, cancel := o.buildJobParams(ctx, jobUUID, runner, params...) + tags := o.jobLabels2Tags(labels) job, err := o.scheduler.Update( jobUUID, cron, task, gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...), @@ -147,9 +139,9 @@ func (o *OpScheduler) UpdateJob( cancel() return err } - // 保存取消函数 + // save cancel func o.jobCancelMap.Set(jobUUID, cancel) - // 更新 jobsMap + // save job opJob := newOpJob(job, disabled) o.jobsMap.Set(jobUUID, opJob) return nil @@ -189,7 +181,9 @@ func (o *OpScheduler) EnableJob(jobUUID uuid.UUID) error { if !exists { return errors.New("job not found: " + jobUUID.String()) } + opJob.disableRWMutex.Lock() opJob.disabled = false + opJob.disableRWMutex.Unlock() return nil } @@ -199,7 +193,9 @@ func (o *OpScheduler) DisableJob(jobUUID uuid.UUID) error { if !exists { return errors.New("job not found: " + jobUUID.String()) } + opJob.disableRWMutex.Lock() opJob.disabled = true + opJob.disableRWMutex.Unlock() return nil } @@ -241,17 +237,15 @@ func (o *OpScheduler) RemoveJobs(jobUUID ...uuid.UUID) error { // RemoveJobByTags removes all jobs that have all of the provided labels. func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { - jobs := o.scheduler.Jobs() if len(labels) == 0 { return nil } needRemovedJobsUUID := make([]uuid.UUID, 0) - for _, job := range jobs { + o.jobsMap.ForEach(func(jobUUID uuid.UUID, opJob *OpJob) { matched := true for _, label := range labels { for k, v := range label { - exists := sliceHasItem(job.Tags(), k+labelSep+v) - if !exists { + if val, exists := opJob.Label(k); !exists || val != v { matched = false break } @@ -261,9 +255,9 @@ func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { } } if matched { - needRemovedJobsUUID = append(needRemovedJobsUUID, job.ID()) + needRemovedJobsUUID = append(needRemovedJobsUUID, jobUUID) } - } + }) if len(needRemovedJobsUUID) > 0 { return o.RemoveJobs(needRemovedJobsUUID...) } @@ -272,17 +266,15 @@ func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { // StopJobByLabels stops all jobs that have all of the provided labels. func (o *OpScheduler) StopJobByLabels(labels ...JobLabels) error { - jobs := o.scheduler.Jobs() if len(labels) == 0 { return nil } needStopJobsUUID := make([]uuid.UUID, 0) - for _, job := range jobs { + o.jobsMap.ForEach(func(jobUUID uuid.UUID, opJob *OpJob) { matched := true for _, label := range labels { for k, v := range label { - exists := sliceHasItem(job.Tags(), k+labelSep+v) - if !exists { + if val, exists := opJob.Label(k); !exists || val != v { matched = false break } @@ -292,9 +284,9 @@ func (o *OpScheduler) StopJobByLabels(labels ...JobLabels) error { } } if matched { - needStopJobsUUID = append(needStopJobsUUID, job.ID()) + needStopJobsUUID = append(needStopJobsUUID, jobUUID) } - } + }) if len(needStopJobsUUID) > 0 { return o.StopJobs(needStopJobsUUID...) } @@ -302,17 +294,24 @@ func (o *OpScheduler) StopJobByLabels(labels ...JobLabels) error { } // StopAndRemoveJobs stops and removes jobs by their UUIDs. -func (o *OpScheduler) StopAndRemoveJobs(jobUUID ...uuid.UUID) { +func (o *OpScheduler) StopAndRemoveJobs(jobUUID ...uuid.UUID) error { for _, jobID := range jobUUID { - _ = o.StopJobs(jobID) - _ = o.RemoveJobs(jobID) + if err := o.StopJobs(jobID); err != nil { + return err + } + if err := o.RemoveJobs(jobID); err != nil { + return err + } } + return nil } // StopAndRemoveJobByLabels stops and removes jobs by their labels. -func (o *OpScheduler) StopAndRemoveJobByLabels(labels ...JobLabels) { - _ = o.StopJobByLabels(labels...) - _ = o.RemoveJobByLabels(labels...) +func (o *OpScheduler) StopAndRemoveJobByLabels(labels ...JobLabels) error { + if err := o.StopJobByLabels(labels...); err != nil { + return err + } + return o.RemoveJobByLabels(labels...) } // Start starts the scheduler. @@ -332,11 +331,15 @@ func (o *OpScheduler) Shutdown() error { return nil } +// StopAllJobs stops all jobs in the scheduler. func (o *OpScheduler) StopAllJobs() error { - o.scheduler.StopJobs() + o.jobCancelMap.ForEach(func(u uuid.UUID, cf context.CancelFunc) { + cf() + }) return nil } +// RemoveAllJobs removes all jobs from the scheduler. func (o *OpScheduler) RemoveAllJobs() error { o.scheduler.StopJobs() for _, job := range o.scheduler.Jobs() { From 1225563d4ae5e09971195b88ef81b3cf1675f37d Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:49:25 +0800 Subject: [PATCH 07/44] =?UTF-8?q?fix(scheduler):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=B3=A8=E8=A7=A3=E7=9A=84=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 985149132..1b1e580ec 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" ) -// label的连接符 +// labelSep is the separator used in job tags for labels. const labelSep = "=" // OpScheduler is the main scheduler struct that manages jobs. From 55d65f4ad7328d202763c2cdfa79d774462c3628 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:59:38 +0800 Subject: [PATCH 08/44] =?UTF-8?q?fix(scheduler):=20=E5=AF=B9tags=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=BD=AC=E4=B9=89=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 4 ++-- pkg/scheduler/scheduler.go | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 912d5edf2..12e310c95 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -141,9 +141,9 @@ func (o *OpJob) NextRuns(n int) ([]time.Time, error) { func newOpJob(job gocron.Job, disabled bool) *OpJob { labels := make(JobLabels) for _, tag := range job.Tags() { - parts := strings.SplitN(tag, labelSep, 1) + parts := strings.SplitN(tag, ":", 2) if len(parts) == 2 { - labels[parts[0]] = parts[1] + labels[unescape(parts[0])] = unescape(parts[1]) } } return &OpJob{ diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 1b1e580ec..527d99729 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -9,8 +9,19 @@ import ( "github.com/google/uuid" ) -// labelSep is the separator used in job tags for labels. -const labelSep = "=" +// escape escapes backslashes and colons in a string. +func escape(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, ":", "\\:") + return s +} + +// unescape unescapes backslashes and colons in a string. +func unescape(s string) string { + s = strings.ReplaceAll(s, "\\\\", "\\") + s = strings.ReplaceAll(s, "\\:", ":") + return s +} // OpScheduler is the main scheduler struct that manages jobs. type OpScheduler struct { @@ -47,7 +58,7 @@ func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { func (o *OpScheduler) jobLabels2Tags(labels JobLabels) []string { tags := make([]string, 0, len(labels)) for k, v := range labels { - tags = append(tags, k+labelSep+v) + tags = append(tags, escape(k)+":"+escape(v)) } return tags } @@ -55,9 +66,9 @@ func (o *OpScheduler) jobLabels2Tags(labels JobLabels) []string { func (o *OpScheduler) tags2JobLabels(tags []string) JobLabels { labels := make(JobLabels) for _, tag := range tags { - parts := strings.SplitN(tag, labelSep, 2) + parts := strings.SplitN(tag, ":", 2) if len(parts) == 2 { - labels[parts[0]] = parts[1] + labels[unescape(parts[0])] = unescape(parts[1]) } } return labels From a04f567db13cb8f3029cbe619940f07048e5fa76 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:00:33 +0800 Subject: [PATCH 09/44] =?UTF-8?q?refactor(scheduler):=20=E5=B0=86=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=B1=BB=E6=96=B9=E6=B3=95=E9=9B=86=E4=B8=AD=E5=9C=A8?= =?UTF-8?q?=E6=96=87=E4=BB=B6util=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 14 -------------- pkg/scheduler/util.go | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 527d99729..96d67f41f 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -9,20 +9,6 @@ import ( "github.com/google/uuid" ) -// escape escapes backslashes and colons in a string. -func escape(s string) string { - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, ":", "\\:") - return s -} - -// unescape unescapes backslashes and colons in a string. -func unescape(s string) string { - s = strings.ReplaceAll(s, "\\\\", "\\") - s = strings.ReplaceAll(s, "\\:", ":") - return s -} - // OpScheduler is the main scheduler struct that manages jobs. type OpScheduler struct { Name string diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index 85b0ea242..8d4650ba1 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -1,8 +1,25 @@ package scheduler -import "slices" +import ( + "slices" + "strings" +) // sliceHasItem checks if a string exists in a slice of strings. func sliceHasItem(slice []string, item string) bool { return slices.Contains(slice, item) } + +// escape escapes backslashes and colons in a string. +func escape(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, ":", "\\:") + return s +} + +// unescape unescapes backslashes and colons in a string. +func unescape(s string) string { + s = strings.ReplaceAll(s, "\\\\", "\\") + s = strings.ReplaceAll(s, "\\:", ":") + return s +} From 8177e8273e0683ef51267365d66a4240a1a48364 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:15:01 +0800 Subject: [PATCH 10/44] =?UTF-8?q?refactor(scheduler):=20=E5=AF=B9=E5=8F=AF?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E7=9A=84=E4=BB=A3=E7=A0=81=E8=BF=9B=E8=A1=8C?= =?UTF-8?q?=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 6 +-- pkg/scheduler/scheduler.go | 95 ++++++++++++++------------------------ pkg/scheduler/util.go | 22 +++++++++ 3 files changed, 58 insertions(+), 65 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 12e310c95..b262eafbd 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -73,10 +73,8 @@ func (sm *safeMap[K, V]) GetAll() map[K]V { func (sm *safeMap[K, V]) Clear() { sm.lock.Lock() defer sm.lock.Unlock() - // 移除所有元素,但保持底层map不变 - for k := range sm.data { - delete(sm.data, k) - } + // reinitialize the map to clear all entries + sm.data = make(map[K]V) } // ForEach iterates over all key-value pairs in the safeMap and applies the provided function. diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 96d67f41f..d55dee909 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -14,9 +14,11 @@ type OpScheduler struct { Name string scheduler gocron.Scheduler jobCancelMap jobCancelMap - jobsMap *safeMap[uuid.UUID, *OpJob] + jobsMap jobsMapType } +type jobsMapType = *safeMap[uuid.UUID, *OpJob] + // NewOpScheduler creates a new OpScheduler instance. func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, error) { scheduler, err := gocron.NewScheduler(opts...) @@ -37,8 +39,7 @@ func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { if !exists { return errors.New("job not found: " + jobUUID.String()) } - err := opJob.job.RunNow() - return err + return opJob.job.RunNow() } func (o *OpScheduler) jobLabels2Tags(labels JobLabels) []string { @@ -151,24 +152,10 @@ func (o *OpScheduler) GetJob(jobUUID uuid.UUID) (*OpJob, bool) { // GetJobsByLabels retrieves jobs that have all of the provided labels. func (o *OpScheduler) GetJobsByLabels(labels ...JobLabels) []*OpJob { - result := make([]*OpJob, 0) - o.jobsMap.ForEach(func(_ uuid.UUID, opJob *OpJob) { - matched := true - for _, label := range labels { - for k, v := range label { - if val, exists := opJob.Label(k); !exists || val != v { - matched = false - break - } - } - if !matched { - break - } - } - if matched { - result = append(result, opJob) - } - }) + var result []*OpJob + filterLabels(o.jobsMap, func(j *OpJob) { + result = append(result, j) + }, labels...) return result } @@ -238,23 +225,13 @@ func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { return nil } needRemovedJobsUUID := make([]uuid.UUID, 0) - o.jobsMap.ForEach(func(jobUUID uuid.UUID, opJob *OpJob) { - matched := true - for _, label := range labels { - for k, v := range label { - if val, exists := opJob.Label(k); !exists || val != v { - matched = false - break - } - } - if !matched { - break - } - } - if matched { - needRemovedJobsUUID = append(needRemovedJobsUUID, jobUUID) - } - }) + filterLabels( + o.jobsMap, + func(j *OpJob) { + needRemovedJobsUUID = append(needRemovedJobsUUID, j.ID()) + }, + labels..., + ) if len(needRemovedJobsUUID) > 0 { return o.RemoveJobs(needRemovedJobsUUID...) } @@ -267,23 +244,13 @@ func (o *OpScheduler) StopJobByLabels(labels ...JobLabels) error { return nil } needStopJobsUUID := make([]uuid.UUID, 0) - o.jobsMap.ForEach(func(jobUUID uuid.UUID, opJob *OpJob) { - matched := true - for _, label := range labels { - for k, v := range label { - if val, exists := opJob.Label(k); !exists || val != v { - matched = false - break - } - } - if !matched { - break - } - } - if matched { - needStopJobsUUID = append(needStopJobsUUID, jobUUID) - } - }) + filterLabels( + o.jobsMap, + func(j *OpJob) { + needStopJobsUUID = append(needStopJobsUUID, j.ID()) + }, + labels..., + ) if len(needStopJobsUUID) > 0 { return o.StopJobs(needStopJobsUUID...) } @@ -312,9 +279,8 @@ func (o *OpScheduler) StopAndRemoveJobByLabels(labels ...JobLabels) error { } // Start starts the scheduler. -func (o *OpScheduler) Start() error { +func (o *OpScheduler) Start() { o.scheduler.Start() - return nil } // Close is an alias for Shutdown. @@ -324,8 +290,7 @@ func (o *OpScheduler) Close() error { // Shutdown stops the scheduler. func (o *OpScheduler) Shutdown() error { - o.scheduler.Shutdown() - return nil + return o.scheduler.Shutdown() } // StopAllJobs stops all jobs in the scheduler. @@ -338,11 +303,19 @@ func (o *OpScheduler) StopAllJobs() error { // RemoveAllJobs removes all jobs from the scheduler. func (o *OpScheduler) RemoveAllJobs() error { - o.scheduler.StopJobs() + var errs []error + if err := o.scheduler.StopJobs(); err != nil { + errs = append(errs, err) + } for _, job := range o.scheduler.Jobs() { - o.scheduler.RemoveJob(job.ID()) + if err := o.scheduler.RemoveJob(job.ID()); err != nil { + errs = append(errs, err) + } } o.jobCancelMap.Clear() o.jobsMap.Clear() + if len(errs) > 0 { + return errors.Join(errs...) + } return nil } diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index 8d4650ba1..d07114b63 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -3,8 +3,30 @@ package scheduler import ( "slices" "strings" + + "github.com/google/uuid" ) +func filterLabels(j jobsMapType, call func(j *OpJob), labels ...JobLabels) { + j.ForEach(func(_ uuid.UUID, opJob *OpJob) { + matched := true + for _, label := range labels { + for k, v := range label { + if val, exists := opJob.Label(k); !exists || val != v { + matched = false + break + } + } + if !matched { + break + } + } + if matched { + call(opJob) + } + }) +} + // sliceHasItem checks if a string exists in a slice of strings. func sliceHasItem(slice []string, item string) bool { return slices.Contains(slice, item) From edfd2e3036488551343b3625b62119145f8a309b Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:32:10 +0800 Subject: [PATCH 11/44] =?UTF-8?q?fix(scheduler):=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 18 +++++++++--------- pkg/scheduler/util.go | 13 ++++--------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index d55dee909..f303bb12c 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -151,11 +151,11 @@ func (o *OpScheduler) GetJob(jobUUID uuid.UUID) (*OpJob, bool) { } // GetJobsByLabels retrieves jobs that have all of the provided labels. -func (o *OpScheduler) GetJobsByLabels(labels ...JobLabels) []*OpJob { +func (o *OpScheduler) GetJobsByLabels(labels JobLabels) []*OpJob { var result []*OpJob filterLabels(o.jobsMap, func(j *OpJob) { result = append(result, j) - }, labels...) + }, labels) return result } @@ -220,7 +220,7 @@ func (o *OpScheduler) RemoveJobs(jobUUID ...uuid.UUID) error { } // RemoveJobByTags removes all jobs that have all of the provided labels. -func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { +func (o *OpScheduler) RemoveJobByLabels(labels JobLabels) error { if len(labels) == 0 { return nil } @@ -230,7 +230,7 @@ func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { func(j *OpJob) { needRemovedJobsUUID = append(needRemovedJobsUUID, j.ID()) }, - labels..., + labels, ) if len(needRemovedJobsUUID) > 0 { return o.RemoveJobs(needRemovedJobsUUID...) @@ -239,7 +239,7 @@ func (o *OpScheduler) RemoveJobByLabels(labels ...JobLabels) error { } // StopJobByLabels stops all jobs that have all of the provided labels. -func (o *OpScheduler) StopJobByLabels(labels ...JobLabels) error { +func (o *OpScheduler) StopJobByLabels(labels JobLabels) error { if len(labels) == 0 { return nil } @@ -249,7 +249,7 @@ func (o *OpScheduler) StopJobByLabels(labels ...JobLabels) error { func(j *OpJob) { needStopJobsUUID = append(needStopJobsUUID, j.ID()) }, - labels..., + labels, ) if len(needStopJobsUUID) > 0 { return o.StopJobs(needStopJobsUUID...) @@ -271,11 +271,11 @@ func (o *OpScheduler) StopAndRemoveJobs(jobUUID ...uuid.UUID) error { } // StopAndRemoveJobByLabels stops and removes jobs by their labels. -func (o *OpScheduler) StopAndRemoveJobByLabels(labels ...JobLabels) error { - if err := o.StopJobByLabels(labels...); err != nil { +func (o *OpScheduler) StopAndRemoveJobByLabels(labels JobLabels) error { + if err := o.StopJobByLabels(labels); err != nil { return err } - return o.RemoveJobByLabels(labels...) + return o.RemoveJobByLabels(labels) } // Start starts the scheduler. diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index d07114b63..ea1890b39 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -7,17 +7,12 @@ import ( "github.com/google/uuid" ) -func filterLabels(j jobsMapType, call func(j *OpJob), labels ...JobLabels) { +func filterLabels(j jobsMapType, call func(j *OpJob), labels JobLabels) { j.ForEach(func(_ uuid.UUID, opJob *OpJob) { matched := true - for _, label := range labels { - for k, v := range label { - if val, exists := opJob.Label(k); !exists || val != v { - matched = false - break - } - } - if !matched { + for k, v := range labels { + if val, exists := opJob.Label(k); !exists || val != v { + matched = false break } } From f8cb2f2778e763e4f72b70a02e96654dbb9f4619 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:36:38 +0800 Subject: [PATCH 12/44] =?UTF-8?q?fix(scheduler):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=AF=BB=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 2 ++ pkg/scheduler/scheduler.go | 28 +++++++++++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index b262eafbd..f618e3302 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -117,6 +117,8 @@ func (o *OpJob) Label(key string) (string, bool) { // Disabled indicates whether the job is disabled. func (o *OpJob) Disabled() bool { + o.disableRWMutex.RLock() + defer o.disableRWMutex.RUnlock() return o.disabled } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index f303bb12c..f9ed729c2 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -72,13 +72,13 @@ func (o *OpScheduler) buildJobParams(ctx context.Context, jobUUID uuid.UUID, run finalParams = append(finalParams, params...) } task := gocron.NewTask(func(ctx context.Context, params ...any) error { - // 判断是否被禁用 + // check if job is exists and not disabled j, exists := o.jobsMap.Get(jobUUID) - // 理论上不会不存在,但为了保险起见加个判断 + // In theory the job should always exist, but check just in case if !exists { return nil } - // 被禁用则不执行 + // check disabled status j.disableRWMutex.RLock() disabled := j.disabled j.disableRWMutex.RUnlock() @@ -105,9 +105,9 @@ func (o *OpScheduler) NewJob( cancel() return nil, err } - // 保存取消函数 + // save the cancel func o.jobCancelMap.Set(jobUUID, cancel) - // 保存 job + // save the job opJob := newOpJob(job, false) o.jobsMap.Set(jobUUID, opJob) return opJob, nil @@ -193,8 +193,11 @@ func (o *OpScheduler) StopAndDisableJob(jobUUID uuid.UUID) error { } // StopJobs stops jobs by their UUIDs. -func (o *OpScheduler) StopJobs(jobUUID ...uuid.UUID) error { - for _, jobID := range jobUUID { +func (o *OpScheduler) StopJobs(jobUUIDs ...uuid.UUID) error { + if len(jobUUIDs) == 0 { + return nil + } + for _, jobID := range jobUUIDs { cancelFunc, exists := o.jobCancelMap.Get(jobID) if !exists { return errors.New("job not found: " + jobID.String()) @@ -205,15 +208,18 @@ func (o *OpScheduler) StopJobs(jobUUID ...uuid.UUID) error { } // RemoveJobs removes jobs by their UUIDs. -func (o *OpScheduler) RemoveJobs(jobUUID ...uuid.UUID) error { - for _, jobID := range jobUUID { +func (o *OpScheduler) RemoveJobs(jobUUIDs ...uuid.UUID) error { + if len(jobUUIDs) == 0 { + return nil + } + for _, jobID := range jobUUIDs { err := o.scheduler.RemoveJob(jobID) if err != nil { return err } - // remove cancel func + // Remove the cancel func o.jobCancelMap.Delete(jobID) - // remove from jobsMap + // Remove from jobsMap o.jobsMap.Delete(jobID) } return nil From 56866c7a1aab578baa7aae6cdc30e23230562bb9 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:40:13 +0800 Subject: [PATCH 13/44] =?UTF-8?q?fix(scheduler):=20=E4=BF=AE=E5=A4=8Duneas?= =?UTF-8?q?cape=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 8 ++++---- pkg/scheduler/util.go | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index f9ed729c2..8f91f940e 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -105,9 +105,9 @@ func (o *OpScheduler) NewJob( cancel() return nil, err } - // save the cancel func + // Save the cancel func o.jobCancelMap.Set(jobUUID, cancel) - // save the job + // Save the job opJob := newOpJob(job, false) o.jobsMap.Set(jobUUID, opJob) return opJob, nil @@ -137,9 +137,9 @@ func (o *OpScheduler) UpdateJob( cancel() return err } - // save cancel func + // Save cancel func o.jobCancelMap.Set(jobUUID, cancel) - // save job + // Save job opJob := newOpJob(job, disabled) o.jobsMap.Set(jobUUID, opJob) return nil diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index ea1890b39..4b489745b 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -36,7 +36,19 @@ func escape(s string) string { // unescape unescapes backslashes and colons in a string. func unescape(s string) string { - s = strings.ReplaceAll(s, "\\\\", "\\") - s = strings.ReplaceAll(s, "\\:", ":") - return s + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + next := s[i+1] + if next == '\\' || next == ':' { + // Valid escaped sequence produced by escape(): unescape it. + b.WriteByte(next) + i++ + continue + } + } + b.WriteByte(s[i]) + } + return b.String() } From 83e1a65860e7f53a5a581a105e6cd7fabf629dce Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:44:16 +0800 Subject: [PATCH 14/44] =?UTF-8?q?fix(scheduler):=20=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=A4=9A=E4=BD=99=E7=9A=84=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/util.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index 4b489745b..cd86a889e 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -1,7 +1,6 @@ package scheduler import ( - "slices" "strings" "github.com/google/uuid" @@ -22,11 +21,6 @@ func filterLabels(j jobsMapType, call func(j *OpJob), labels JobLabels) { }) } -// sliceHasItem checks if a string exists in a slice of strings. -func sliceHasItem(slice []string, item string) bool { - return slices.Contains(slice, item) -} - // escape escapes backslashes and colons in a string. func escape(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") From 14a6db6ec227ba34e0f10f5a9cdfc3ecc04b4139 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:31:29 +0800 Subject: [PATCH 15/44] =?UTF-8?q?refactor(sheduler):cancel=E4=BA=A4?= =?UTF-8?q?=E7=BB=99=E5=A4=96=E9=83=A8=E7=AE=A1=E7=90=86=EF=BC=8C=E4=BC=A0?= =?UTF-8?q?=E5=87=BA=E5=8F=AA=E8=AF=BB=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 50 ++++---- pkg/scheduler/scheduler.go | 227 ++++++++++++++++--------------------- pkg/scheduler/util.go | 17 --- 3 files changed, 125 insertions(+), 169 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index f618e3302..60566f06c 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -13,14 +13,6 @@ import ( // JobRunner defines the function signature for job runners type JobRunner func(ctx context.Context, params ...any) error -// jobCancelMap is a thread-safe map for storing job cancel functions -type jobCancelMap = *safeMap[uuid.UUID, context.CancelFunc] - -// newJobCancelMap creates a new jobCancelMap instance -func newJobCancelMap() jobCancelMap { - return newSafeMap[uuid.UUID, context.CancelFunc]() -} - // JobLabels the type for job labels type JobLabels = map[string]string @@ -88,20 +80,23 @@ func (sm *safeMap[K, V]) ForEach(fn func(K, V)) { // OpJob represents an operational job with its metadata. type OpJob struct { - job gocron.Job - labels JobLabels - disableRWMutex sync.RWMutex - disabled bool + id uuid.UUID + name string + lastRun time.Time + lastRunErr error + nextRun10 []time.Time + labels JobLabels + disabled bool } // ID returns the UUID of the job. func (o *OpJob) ID() uuid.UUID { - return o.job.ID() + return o.id } // Name returns the name of the job. func (o *OpJob) Name() string { - return o.job.Name() + return o.name } // Labels returns the labels of the job. @@ -117,24 +112,22 @@ func (o *OpJob) Label(key string) (string, bool) { // Disabled indicates whether the job is disabled. func (o *OpJob) Disabled() bool { - o.disableRWMutex.RLock() - defer o.disableRWMutex.RUnlock() return o.disabled } // LastRun returns the last run time of the job. func (o *OpJob) LastRun() (time.Time, error) { - return o.job.LastRun() + return o.lastRun, o.lastRunErr } // NextRun returns the next run time of the job. -func (o *OpJob) NextRun() (time.Time, error) { - return o.job.NextRun() +func (o *OpJob) NextRun() time.Time { + return o.nextRun10[0] } // NextRuns returns the next n run times of the job. -func (o *OpJob) NextRuns(n int) ([]time.Time, error) { - return o.job.NextRuns(n) +func (o *OpJob) NextRuns10() []time.Time { + return o.nextRun10 } // newOpJob creates a new OpJob instance from a gocron.Job and its disabled status. @@ -146,9 +139,18 @@ func newOpJob(job gocron.Job, disabled bool) *OpJob { labels[unescape(parts[0])] = unescape(parts[1]) } } + lastRun, lastRunErr := job.LastRun() + nextRun10, err := job.NextRuns(10) + if err != nil { + nextRun10 = []time.Time{} + } return &OpJob{ - job: job, - labels: labels, - disabled: disabled, + id: job.ID(), + name: job.Name(), + lastRun: lastRun, + lastRunErr: lastRunErr, + nextRun10: nextRun10, + labels: labels, + disabled: disabled, } } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 8f91f940e..7de0f954e 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -9,16 +9,17 @@ import ( "github.com/google/uuid" ) +type jobsMapType = *safeMap[uuid.UUID, gocron.Job] +type boolMapType = *safeMap[uuid.UUID, bool] + // OpScheduler is the main scheduler struct that manages jobs. type OpScheduler struct { - Name string - scheduler gocron.Scheduler - jobCancelMap jobCancelMap - jobsMap jobsMapType + Name string + scheduler gocron.Scheduler + jobsMap jobsMapType + jobDisabledMap boolMapType } -type jobsMapType = *safeMap[uuid.UUID, *OpJob] - // NewOpScheduler creates a new OpScheduler instance. func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, error) { scheduler, err := gocron.NewScheduler(opts...) @@ -26,20 +27,20 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, return nil, err } return &OpScheduler{ - scheduler: scheduler, - Name: name, - jobCancelMap: newJobCancelMap(), - jobsMap: newSafeMap[uuid.UUID, *OpJob](), + scheduler: scheduler, + Name: name, + jobDisabledMap: newSafeMap[uuid.UUID, bool](), + jobsMap: newSafeMap[uuid.UUID, gocron.Job](), }, nil } // RunNow runs a job immediately by its UUID. func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { - opJob, exists := o.GetJob(jobUUID) + job, exists := o.getCronJob(jobUUID) if !exists { return errors.New("job not found: " + jobUUID.String()) } - return opJob.job.RunNow() + return job.RunNow() } func (o *OpScheduler) jobLabels2Tags(labels JobLabels) []string { @@ -61,33 +62,39 @@ func (o *OpScheduler) tags2JobLabels(tags []string) JobLabels { return labels } -func (o *OpScheduler) buildJobParams(ctx context.Context, jobUUID uuid.UUID, runner JobRunner, params ...any) (gocron.Task, context.Context, context.CancelFunc) { - jobCtx, cancel := context.WithCancel(ctx) +func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { + disabled, exists := o.jobDisabledMap.Get(jobUUID) + return exists && disabled +} + +func (o *OpScheduler) buildJobParams( + ctx context.Context, + jobUUID uuid.UUID, + runner JobRunner, + params ...any, +) gocron.Task { var finalParams []any if len(params) == 0 { - finalParams = []any{jobCtx} + finalParams = []any{ctx} } else { finalParams = make([]any, 0, len(params)+1) - finalParams = append(finalParams, jobCtx) + finalParams = append(finalParams, ctx) finalParams = append(finalParams, params...) } task := gocron.NewTask(func(ctx context.Context, params ...any) error { // check if job is exists and not disabled - j, exists := o.jobsMap.Get(jobUUID) + j, exists := o.getCronJob(jobUUID) // In theory the job should always exist, but check just in case if !exists { return nil } // check disabled status - j.disableRWMutex.RLock() - disabled := j.disabled - j.disableRWMutex.RUnlock() - if disabled { + if o.jobIsDisabled(j.ID()) { return nil } return runner(ctx, params...) }, finalParams...) - return task, jobCtx, cancel + return task } // NewJob creates and schedules a new job. @@ -99,18 +106,20 @@ func (o *OpScheduler) NewJob( runner JobRunner, params ...any) (*OpJob, error) { jobUUID := uuid.New() tags := o.jobLabels2Tags(labels) - task, jobCtx, cancel := o.buildJobParams(ctx, jobUUID, runner, params...) - job, err := o.scheduler.NewJob(cron, task, gocron.WithIdentifier(jobUUID), gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...)) + task := o.buildJobParams(ctx, jobUUID, runner, params...) + job, err := o.scheduler.NewJob( + cron, + task, + gocron.WithIdentifier(jobUUID), + gocron.WithContext(ctx), + gocron.WithName(jobName), + gocron.WithTags(tags...), + ) if err != nil { - cancel() return nil, err } - // Save the cancel func - o.jobCancelMap.Set(jobUUID, cancel) - // Save the job - opJob := newOpJob(job, false) - o.jobsMap.Set(jobUUID, opJob) - return opJob, nil + o.jobsMap.Set(jobUUID, job) + return newOpJob(job, false), nil } // UpdateJob updates an existing job by its UUID. @@ -127,83 +136,64 @@ func (o *OpScheduler) UpdateJob( if err != nil { return err } - task, jobCtx, cancel := o.buildJobParams(ctx, jobUUID, runner, params...) + task := o.buildJobParams(ctx, jobUUID, runner, params...) tags := o.jobLabels2Tags(labels) job, err := o.scheduler.Update( jobUUID, cron, task, - gocron.WithContext(jobCtx), gocron.WithName(jobName), gocron.WithTags(tags...), + gocron.WithContext(ctx), gocron.WithName(jobName), gocron.WithTags(tags...), ) if err != nil { - cancel() return err } - // Save cancel func - o.jobCancelMap.Set(jobUUID, cancel) // Save job - opJob := newOpJob(job, disabled) - o.jobsMap.Set(jobUUID, opJob) + o.jobsMap.Set(jobUUID, job) return nil } +// exists +func (o *OpScheduler) Exists(uuid uuid.UUID) bool { + _, exists := o.getCronJob(uuid) + return exists +} + +// getCronJob retrieves a gocron.Job by its UUID. +func (o *OpScheduler) getCronJob(jobUUID uuid.UUID) (gocron.Job, bool) { + return o.jobsMap.Get(jobUUID) +} + // GetJob retrieves a job by its UUID. func (o *OpScheduler) GetJob(jobUUID uuid.UUID) (*OpJob, bool) { - return o.jobsMap.Get(jobUUID) + job, exists := o.getCronJob(jobUUID) + if !exists { + return nil, false + } + return newOpJob(job, o.jobIsDisabled(jobUUID)), true } // GetJobsByLabels retrieves jobs that have all of the provided labels. func (o *OpScheduler) GetJobsByLabels(labels JobLabels) []*OpJob { var result []*OpJob - filterLabels(o.jobsMap, func(j *OpJob) { - result = append(result, j) - }, labels) + o.filterLabels(labels, func(j gocron.Job, jobLabels JobLabels) { + result = append(result, newOpJob(j, o.jobIsDisabled(j.ID()))) + }) return result } // EnableJob enables a job by its UUID. func (o *OpScheduler) EnableJob(jobUUID uuid.UUID) error { - opJob, exists := o.GetJob(jobUUID) - if !exists { + if !o.Exists(jobUUID) { return errors.New("job not found: " + jobUUID.String()) } - opJob.disableRWMutex.Lock() - opJob.disabled = false - opJob.disableRWMutex.Unlock() + o.jobDisabledMap.Delete(jobUUID) return nil } // DisableJob disables a job by its UUID. func (o *OpScheduler) DisableJob(jobUUID uuid.UUID) error { - opJob, exists := o.GetJob(jobUUID) - if !exists { + if !o.Exists(jobUUID) { return errors.New("job not found: " + jobUUID.String()) } - opJob.disableRWMutex.Lock() - opJob.disabled = true - opJob.disableRWMutex.Unlock() - return nil -} - -// StopAndDisableJob stops and disables a job by its UUID. -func (o *OpScheduler) StopAndDisableJob(jobUUID uuid.UUID) error { - err := o.StopJobs(jobUUID) - if err != nil { - return err - } - return o.DisableJob(jobUUID) -} - -// StopJobs stops jobs by their UUIDs. -func (o *OpScheduler) StopJobs(jobUUIDs ...uuid.UUID) error { - if len(jobUUIDs) == 0 { - return nil - } - for _, jobID := range jobUUIDs { - cancelFunc, exists := o.jobCancelMap.Get(jobID) - if !exists { - return errors.New("job not found: " + jobID.String()) - } - cancelFunc() - } + o.jobDisabledMap.Set(jobUUID, true) return nil } @@ -212,31 +202,55 @@ func (o *OpScheduler) RemoveJobs(jobUUIDs ...uuid.UUID) error { if len(jobUUIDs) == 0 { return nil } + var errs []error for _, jobID := range jobUUIDs { err := o.scheduler.RemoveJob(jobID) if err != nil { - return err + errs = append(errs, err) + continue } - // Remove the cancel func - o.jobCancelMap.Delete(jobID) // Remove from jobsMap o.jobsMap.Delete(jobID) + // Remove from disabled map + o.jobDisabledMap.Delete(jobID) + } + if len(errs) > 0 { + return errors.Join(errs...) } return nil } +// filterLabels filters jobs in the jobsMap based on the provided labels and applies the action function to matching jobs. +func (o *OpScheduler) filterLabels( + labels JobLabels, + action func(gocron.Job, JobLabels), +) { + o.jobsMap.ForEach(func(_ uuid.UUID, job gocron.Job) { + jobLabels := o.tags2JobLabels(job.Tags()) + matches := true + for k, v := range labels { + if jobVal, exists := jobLabels[k]; !exists || jobVal != v { + matches = false + break + } + } + if matches { + action(job, jobLabels) + } + }) +} + // RemoveJobByTags removes all jobs that have all of the provided labels. func (o *OpScheduler) RemoveJobByLabels(labels JobLabels) error { if len(labels) == 0 { return nil } needRemovedJobsUUID := make([]uuid.UUID, 0) - filterLabels( - o.jobsMap, - func(j *OpJob) { + o.filterLabels( + labels, + func(j gocron.Job, jobLabels JobLabels) { needRemovedJobsUUID = append(needRemovedJobsUUID, j.ID()) }, - labels, ) if len(needRemovedJobsUUID) > 0 { return o.RemoveJobs(needRemovedJobsUUID...) @@ -244,46 +258,6 @@ func (o *OpScheduler) RemoveJobByLabels(labels JobLabels) error { return nil } -// StopJobByLabels stops all jobs that have all of the provided labels. -func (o *OpScheduler) StopJobByLabels(labels JobLabels) error { - if len(labels) == 0 { - return nil - } - needStopJobsUUID := make([]uuid.UUID, 0) - filterLabels( - o.jobsMap, - func(j *OpJob) { - needStopJobsUUID = append(needStopJobsUUID, j.ID()) - }, - labels, - ) - if len(needStopJobsUUID) > 0 { - return o.StopJobs(needStopJobsUUID...) - } - return nil -} - -// StopAndRemoveJobs stops and removes jobs by their UUIDs. -func (o *OpScheduler) StopAndRemoveJobs(jobUUID ...uuid.UUID) error { - for _, jobID := range jobUUID { - if err := o.StopJobs(jobID); err != nil { - return err - } - if err := o.RemoveJobs(jobID); err != nil { - return err - } - } - return nil -} - -// StopAndRemoveJobByLabels stops and removes jobs by their labels. -func (o *OpScheduler) StopAndRemoveJobByLabels(labels JobLabels) error { - if err := o.StopJobByLabels(labels); err != nil { - return err - } - return o.RemoveJobByLabels(labels) -} - // Start starts the scheduler. func (o *OpScheduler) Start() { o.scheduler.Start() @@ -301,10 +275,7 @@ func (o *OpScheduler) Shutdown() error { // StopAllJobs stops all jobs in the scheduler. func (o *OpScheduler) StopAllJobs() error { - o.jobCancelMap.ForEach(func(u uuid.UUID, cf context.CancelFunc) { - cf() - }) - return nil + return o.scheduler.StopJobs() } // RemoveAllJobs removes all jobs from the scheduler. @@ -318,7 +289,7 @@ func (o *OpScheduler) RemoveAllJobs() error { errs = append(errs, err) } } - o.jobCancelMap.Clear() + o.jobDisabledMap.Clear() o.jobsMap.Clear() if len(errs) > 0 { return errors.Join(errs...) diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index cd86a889e..f3c283c85 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -2,25 +2,8 @@ package scheduler import ( "strings" - - "github.com/google/uuid" ) -func filterLabels(j jobsMapType, call func(j *OpJob), labels JobLabels) { - j.ForEach(func(_ uuid.UUID, opJob *OpJob) { - matched := true - for k, v := range labels { - if val, exists := opJob.Label(k); !exists || val != v { - matched = false - break - } - } - if matched { - call(opJob) - } - }) -} - // escape escapes backslashes and colons in a string. func escape(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") From 78f537fb629be3f80dc6d43810ca61b6b99b726f Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:20:30 +0800 Subject: [PATCH 16/44] =?UTF-8?q?fix(schduler):=E5=A2=9E=E5=8A=A0=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=EF=BC=8C=E8=A7=84=E8=8C=83=E6=96=B9=E6=B3=95=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 30 +++++++++++++++++--------- pkg/scheduler/scheduler.go | 43 +++++++++++++++++++++++++++----------- pkg/scheduler/util.go | 23 ++++++++++++++++---- 3 files changed, 70 insertions(+), 26 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 60566f06c..af66c66ad 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -2,6 +2,8 @@ package scheduler import ( "context" + "errors" + "maps" "strings" "sync" "time" @@ -85,6 +87,7 @@ type OpJob struct { lastRun time.Time lastRunErr error nextRun10 []time.Time + nextRunErr error labels JobLabels disabled bool } @@ -121,13 +124,20 @@ func (o *OpJob) LastRun() (time.Time, error) { } // NextRun returns the next run time of the job. -func (o *OpJob) NextRun() time.Time { - return o.nextRun10[0] +func (o *OpJob) NextRun() (time.Time, error) { + if len(o.nextRun10) == 0 { + if o.nextRunErr == nil { + return time.Time{}, errors.New("no next run time available") + } else { + return time.Time{}, o.nextRunErr + } + } + return o.nextRun10[0], nil } // NextRuns returns the next n run times of the job. -func (o *OpJob) NextRuns10() []time.Time { - return o.nextRun10 +func (o *OpJob) NextRuns10() ([]time.Time, error) { + return o.nextRun10, o.nextRunErr } // newOpJob creates a new OpJob instance from a gocron.Job and its disabled status. @@ -136,21 +146,21 @@ func newOpJob(job gocron.Job, disabled bool) *OpJob { for _, tag := range job.Tags() { parts := strings.SplitN(tag, ":", 2) if len(parts) == 2 { - labels[unescape(parts[0])] = unescape(parts[1]) + labels[unescapeTagStr(parts[0])] = unescapeTagStr(parts[1]) } } lastRun, lastRunErr := job.LastRun() - nextRun10, err := job.NextRuns(10) - if err != nil { - nextRun10 = []time.Time{} - } + nextRun10, nextRunErr := job.NextRuns(10) + labelsCopy := make(JobLabels) + maps.Copy(labelsCopy, labels) return &OpJob{ id: job.ID(), name: job.Name(), lastRun: lastRun, lastRunErr: lastRunErr, nextRun10: nextRun10, - labels: labels, + nextRunErr: nextRunErr, + labels: labelsCopy, disabled: disabled, } } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 7de0f954e..5e58c7d0d 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -3,21 +3,23 @@ package scheduler import ( "context" "errors" - "strings" "github.com/go-co-op/gocron/v2" "github.com/google/uuid" ) +// jobsMapType is a thread-safe map for storing jobs. type jobsMapType = *safeMap[uuid.UUID, gocron.Job] -type boolMapType = *safeMap[uuid.UUID, bool] + +// jobDisabledMapType is a thread-safe map for storing boolean values. +type jobDisabledMapType = *safeMap[uuid.UUID, bool] // OpScheduler is the main scheduler struct that manages jobs. type OpScheduler struct { Name string scheduler gocron.Scheduler jobsMap jobsMapType - jobDisabledMap boolMapType + jobDisabledMap jobDisabledMapType } // NewOpScheduler creates a new OpScheduler instance. @@ -35,38 +37,47 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, } // RunNow runs a job immediately by its UUID. -func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { +func (o *OpScheduler) RunNow(jobUUID uuid.UUID, force bool) error { job, exists := o.getCronJob(jobUUID) if !exists { return errors.New("job not found: " + jobUUID.String()) } + if !force && o.jobIsDisabled(jobUUID) { + // job is disabled, do not run + return nil + } return job.RunNow() } +// jobLabels2Tags converts JobLabels to a slice of tags. func (o *OpScheduler) jobLabels2Tags(labels JobLabels) []string { tags := make([]string, 0, len(labels)) for k, v := range labels { - tags = append(tags, escape(k)+":"+escape(v)) + tags = append(tags, escapeTagStr(k)+":"+escapeTagStr(v)) } return tags } +// tags2JobLabels converts a slice of tags to JobLabels. func (o *OpScheduler) tags2JobLabels(tags []string) JobLabels { labels := make(JobLabels) for _, tag := range tags { - parts := strings.SplitN(tag, ":", 2) - if len(parts) == 2 { - labels[unescape(parts[0])] = unescape(parts[1]) + keyPart, valPart, ok := splitEscapedTag(tag) + if !ok { + continue } + labels[unescapeTagStr(keyPart)] = unescapeTagStr(valPart) } return labels } +// jobIsDisabled checks if a job is disabled. func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { disabled, exists := o.jobDisabledMap.Get(jobUUID) return exists && disabled } +// buildJobParams builds a gocron.Task with the provided parameters. func (o *OpScheduler) buildJobParams( ctx context.Context, jobUUID uuid.UUID, @@ -102,8 +113,10 @@ func (o *OpScheduler) NewJob( ctx context.Context, jobName string, cron gocron.JobDefinition, + disabled bool, labels JobLabels, - runner JobRunner, params ...any) (*OpJob, error) { + runner JobRunner, + params ...any) (*OpJob, error) { jobUUID := uuid.New() tags := o.jobLabels2Tags(labels) task := o.buildJobParams(ctx, jobUUID, runner, params...) @@ -119,6 +132,9 @@ func (o *OpScheduler) NewJob( return nil, err } o.jobsMap.Set(jobUUID, job) + if disabled { + o.jobDisabledMap.Set(jobUUID, true) + } return newOpJob(job, false), nil } @@ -132,9 +148,8 @@ func (o *OpScheduler) UpdateJob( labels JobLabels, runner JobRunner, params ...any) error { // Stop and remove the existing job - err := o.RemoveJobs(jobUUID) - if err != nil { - return err + if exists := o.Exists(jobUUID); !exists { + return errors.New("job not found: " + jobUUID.String()) } task := o.buildJobParams(ctx, jobUUID, runner, params...) tags := o.jobLabels2Tags(labels) @@ -147,6 +162,10 @@ func (o *OpScheduler) UpdateJob( } // Save job o.jobsMap.Set(jobUUID, job) + // Set disabled status + if disabled { + o.jobDisabledMap.Set(jobUUID, true) + } return nil } diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index f3c283c85..b2c51a1af 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -4,15 +4,15 @@ import ( "strings" ) -// escape escapes backslashes and colons in a string. -func escape(s string) string { +// escapeTagStr escapes backslashes and colons in a string. +func escapeTagStr(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, ":", "\\:") return s } -// unescape unescapes backslashes and colons in a string. -func unescape(s string) string { +// unescapeTagStr unescapes backslashes and colons in a string. +func unescapeTagStr(s string) string { var b strings.Builder b.Grow(len(s)) for i := 0; i < len(s); i++ { @@ -29,3 +29,18 @@ func unescape(s string) string { } return b.String() } + +// splitEscapedTag splits the first unescaped colon to separate key and value. +// It expects the input to be produced by escapeTagStr. +func splitEscapedTag(tag string) (string, string, bool) { + for i := 0; i < len(tag); i++ { + if tag[i] == '\\' { + i++ // Skip the escaped character + continue + } + if tag[i] == ':' { + return tag[:i], tag[i+1:], true + } + } + return "", "", false +} From d5aff13d837b58db89aabfb7fb4916f6d94309c9 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:20:45 +0800 Subject: [PATCH 17/44] =?UTF-8?q?fix(schduler):=E5=A2=9E=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler_test.go | 75 +++++++++++++++++++++++++++++++++ pkg/scheduler/util_test.go | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 pkg/scheduler/scheduler_test.go create mode 100644 pkg/scheduler/util_test.go diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go new file mode 100644 index 000000000..898bad1d6 --- /dev/null +++ b/pkg/scheduler/scheduler_test.go @@ -0,0 +1,75 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/go-co-op/gocron/v2" +) + +func TestSchedulerNormal(t *testing.T) { + s, err := NewOpScheduler("test-scheduler") + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + labels := JobLabels{ + "env": "test", + "team": "devops", + } + arg0 := 0 + arg1 := "arg1" + // store task status + var forTest = make(map[string]bool) + var runner JobRunner = func(ctx context.Context, params ...any) error { + if len(params) != 2 { + t.Fatalf("expected 2 params, got %d", len(params)) + } + if v0, ok := params[0].(int); !ok || v0 != arg0 { + t.Fatalf("expected param 0 to be %d, got %v", arg0, params[0]) + } + if v1, ok := params[1].(string); !ok || v1 != arg1 { + t.Fatalf("expected param 1 to be %q, got %v", arg1, params[1]) + } + forTest["runner_executed"] = true + return nil + } + ctx := context.WithoutCancel(context.Background()) + afterCreated, err := s.NewJob( + ctx, + "test-job", + // run every 1 minute + gocron.CronJob("* * * * *", false), + false, + labels, + runner, + arg0, arg1, + ) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + job, exists := s.GetJob(afterCreated.ID()) + if !exists { + t.Fatalf("job not found after creation") + } + if job.Name() != "test-job" { + t.Fatalf("expected job name to be %q, got %q", "test-job", job.Name()) + } + jobLabels := job.Labels() + if len(jobLabels) != len(labels) { + t.Fatalf("expected %d labels, got %d", len(labels), len(jobLabels)) + } + for k, v := range labels { + if jobLabels[k] != v { + t.Fatalf("expected label %q to be %q, got %q", k, v, jobLabels[k]) + } + } + // wait for a short while to let the job be scheduled + time.Sleep(2 * time.Minute) + if !forTest["runner_executed"] { + t.Fatalf("expected runner to be executed") + } + t.Logf("scheduler test passed") +} diff --git a/pkg/scheduler/util_test.go b/pkg/scheduler/util_test.go new file mode 100644 index 000000000..8455552dc --- /dev/null +++ b/pkg/scheduler/util_test.go @@ -0,0 +1,74 @@ +package scheduler + +import "testing" + +func TestEscapeUnescapeRoundTrip(t *testing.T) { + cases := []string{ + "", + "plain", + "has:colon", + "has\\backslash", + "both:colon\\and\\backslash", + "trailing\\", + "nested\\:colon", + "multiple::colons\\\\and\\mixed", + } + + for _, tc := range cases { + escaped := escapeTagStr(tc) + got := unescapeTagStr(escaped) + if got != tc { + t.Fatalf("round trip failed for %q: got %q", tc, got) + } + } +} + +func TestEscapeTagStrOutput(t *testing.T) { + input := "k:e\\y" + expected := "k\\:e\\\\y" + if got := escapeTagStr(input); got != expected { + t.Fatalf("escapeTagStr(%q)=%q, want %q", input, got, expected) + } +} + +func TestSplitEscapedTag(t *testing.T) { + build := func(k, v string) string { + return escapeTagStr(k) + ":" + escapeTagStr(v) + } + + cases := []struct { + name string + tag string + key string + val string + ok bool + }{ + {name: "simple", tag: build("k", "v"), key: "k", val: "v", ok: true}, + {name: "escaped colon in key", tag: build("k:e:y", "val"), key: "k:e:y", val: "val", ok: true}, + {name: "escaped colon in val", tag: build("key", "v:a:l"), key: "key", val: "v:a:l", ok: true}, + {name: "backslash in both", tag: build("k\\ey", "v\\al"), key: "k\\ey", val: "v\\al", ok: true}, + {name: "no colon", tag: "nocolon", ok: false}, + {name: "only escaped colon", tag: "key\\:part", ok: false}, + } + + for _, tc := range cases { + keyPart, valPart, ok := splitEscapedTag(tc.tag) + if ok != tc.ok { + t.Fatalf("%s: ok=%v, want %v (tag=%q)", tc.name, ok, tc.ok, tc.tag) + } + if !ok { + continue + } + if keyPart != escapeTagStr(tc.key) { + t.Fatalf("%s: key=%q, want %q", tc.name, keyPart, escapeTagStr(tc.key)) + } + if valPart != escapeTagStr(tc.val) { + t.Fatalf("%s: val=%q, want %q", tc.name, valPart, escapeTagStr(tc.val)) + } + + // Ensure unescape recovers originals. + if unescapeTagStr(keyPart) != tc.key || unescapeTagStr(valPart) != tc.val { + t.Fatalf("%s: unescape mismatch key=%q val=%q", tc.name, unescapeTagStr(keyPart), unescapeTagStr(valPart)) + } + } +} From dc6c7c64cf3fece57560bc863215581d1bbdda54 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:52:51 +0800 Subject: [PATCH 18/44] =?UTF-8?q?refactor(schduler):=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B=EF=BC=8C=E9=99=8D=E4=BD=8E?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E7=BC=96=E7=A0=81=E9=9A=BE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 3 +-- pkg/scheduler/scheduler.go | 51 ++++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index af66c66ad..5afdc8ce1 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -1,7 +1,6 @@ package scheduler import ( - "context" "errors" "maps" "strings" @@ -13,7 +12,7 @@ import ( ) // JobRunner defines the function signature for job runners -type JobRunner func(ctx context.Context, params ...any) error +type JobRunner any // JobLabels the type for job labels type JobLabels = map[string]string diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 5e58c7d0d..06065faa6 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -3,6 +3,7 @@ package scheduler import ( "context" "errors" + "reflect" "github.com/go-co-op/gocron/v2" "github.com/google/uuid" @@ -79,20 +80,20 @@ func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { // buildJobParams builds a gocron.Task with the provided parameters. func (o *OpScheduler) buildJobParams( - ctx context.Context, jobUUID uuid.UUID, runner JobRunner, - params ...any, -) gocron.Task { - var finalParams []any - if len(params) == 0 { - finalParams = []any{ctx} - } else { - finalParams = make([]any, 0, len(params)+1) - finalParams = append(finalParams, ctx) - finalParams = append(finalParams, params...) + params []any, +) (gocron.Task, error) { + f := reflect.ValueOf(runner) + if f.IsZero() { + return nil, errors.New("runner is nil") + } + if len(params)+1 != f.Type().NumIn() { + return nil, errors.New("number of params does not match runner function signature") } - task := gocron.NewTask(func(ctx context.Context, params ...any) error { + // check runner and params + // check runner as function and NumIn is match params length + task := gocron.NewTask(func(_ctx context.Context, params []any) error { // check if job is exists and not disabled j, exists := o.getCronJob(jobUUID) // In theory the job should always exist, but check just in case @@ -103,9 +104,21 @@ func (o *OpScheduler) buildJobParams( if o.jobIsDisabled(j.ID()) { return nil } - return runner(ctx, params...) - }, finalParams...) - return task + in := make([]reflect.Value, len(params)+1) + in[0] = reflect.ValueOf(_ctx) + for k, param := range params { + in[k+1] = reflect.ValueOf(param) + } + returnValues := f.Call(in) + // call runner with params + result := returnValues[0].Interface() + // 如果为空,返回空 + if result == nil { + return nil + } + return result.(error) + }, params) + return task, nil } // NewJob creates and schedules a new job. @@ -119,7 +132,10 @@ func (o *OpScheduler) NewJob( params ...any) (*OpJob, error) { jobUUID := uuid.New() tags := o.jobLabels2Tags(labels) - task := o.buildJobParams(ctx, jobUUID, runner, params...) + task, err := o.buildJobParams(jobUUID, runner, params) + if err != nil { + return nil, err + } job, err := o.scheduler.NewJob( cron, task, @@ -151,7 +167,10 @@ func (o *OpScheduler) UpdateJob( if exists := o.Exists(jobUUID); !exists { return errors.New("job not found: " + jobUUID.String()) } - task := o.buildJobParams(ctx, jobUUID, runner, params...) + task, err := o.buildJobParams(jobUUID, runner, params) + if err != nil { + return err + } tags := o.jobLabels2Tags(labels) job, err := o.scheduler.Update( jobUUID, cron, task, From 406238986c65e8238d015c74412e2224679b9bd2 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:53:19 +0800 Subject: [PATCH 19/44] =?UTF-8?q?feat(schduler):=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler_test.go | 81 +++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 898bad1d6..973bfe986 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -8,8 +8,40 @@ import ( "github.com/go-co-op/gocron/v2" ) +func TestGoCron(t *testing.T) { + s, err := gocron.NewScheduler(gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Shutdown() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + arg0 := 0 + arg1 := "arg1" + job, err := s.NewJob( + gocron.DurationJob(5*time.Second), + gocron.NewTask( + func(ctx context.Context, arg0 int, arg1 string) error { + t.Logf("task is running with args: %d, %s", arg0, arg1) + return nil + }, + arg0, arg1, + ), + gocron.WithContext(ctx), + ) + defer cancel() + t.Logf("job ID: %d", job.ID()) + err = job.RunNow() + if err != nil { + t.Fatalf("failed to run job now: %v", err) + } + time.Sleep(30 * time.Second) +} + func TestSchedulerNormal(t *testing.T) { - s, err := NewOpScheduler("test-scheduler") + t.Log("start test") + t.Logf("Localtime: %v", time.Local) + s, err := NewOpScheduler("test-scheduler", gocron.WithLocation(time.Local)) if err != nil { t.Fatalf("failed to create scheduler: %v", err) } @@ -22,26 +54,29 @@ func TestSchedulerNormal(t *testing.T) { arg0 := 0 arg1 := "arg1" // store task status - var forTest = make(map[string]bool) - var runner JobRunner = func(ctx context.Context, params ...any) error { - if len(params) != 2 { - t.Fatalf("expected 2 params, got %d", len(params)) - } - if v0, ok := params[0].(int); !ok || v0 != arg0 { - t.Fatalf("expected param 0 to be %d, got %v", arg0, params[0]) + executed := make(chan bool, 1) + var runner JobRunner = func(ctx context.Context, _arg0 int, _arg1 string) error { + t.Log("task is running") + if _arg0 != arg0 { + t.Fatalf("expected _arg0 to be %d, got %v", arg0, _arg0) } - if v1, ok := params[1].(string); !ok || v1 != arg1 { - t.Fatalf("expected param 1 to be %q, got %v", arg1, params[1]) + if _arg1 != arg1 { + t.Fatalf("expected _arg1 to be %q, got %v", arg1, _arg1) } - forTest["runner_executed"] = true + executed <- true + t.Log("task done") return nil } - ctx := context.WithoutCancel(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + t.Log("regsitry Job") afterCreated, err := s.NewJob( ctx, "test-job", - // run every 1 minute - gocron.CronJob("* * * * *", false), + // run every 10 seconds + gocron.DurationJob( + 5*time.Second, + ), false, labels, runner, @@ -50,13 +85,16 @@ func TestSchedulerNormal(t *testing.T) { if err != nil { t.Fatalf("failed to create job: %v", err) } + t.Log("check the job exists") job, exists := s.GetJob(afterCreated.ID()) if !exists { t.Fatalf("job not found after creation") } + t.Log("check the job name") if job.Name() != "test-job" { t.Fatalf("expected job name to be %q, got %q", "test-job", job.Name()) } + t.Log("check the labels") jobLabels := job.Labels() if len(jobLabels) != len(labels) { t.Fatalf("expected %d labels, got %d", len(labels), len(jobLabels)) @@ -66,10 +104,15 @@ func TestSchedulerNormal(t *testing.T) { t.Fatalf("expected label %q to be %q, got %q", k, v, jobLabels[k]) } } - // wait for a short while to let the job be scheduled - time.Sleep(2 * time.Minute) - if !forTest["runner_executed"] { - t.Fatalf("expected runner to be executed") + t.Log("wait for job execution") + select { + case <-executed: + t.Log("job executed successfully") + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("job did not execute within the expected time") + } else if ctx.Err() != nil { + t.Fatalf("context error: %v", ctx.Err()) + } } - t.Logf("scheduler test passed") } From 0b534cf0f6e7e9ff89ae8a1896ba620b3cf9c1f0 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:59:08 +0800 Subject: [PATCH 20/44] =?UTF-8?q?fix(scheduler):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=E6=B3=A8=E8=A7=A3=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=83=A8=E5=88=86=E4=BB=A3=E7=A0=81=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 9 +++------ pkg/scheduler/scheduler.go | 3 +++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 5afdc8ce1..e6acb60a4 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -56,9 +56,7 @@ func (sm *safeMap[K, V]) GetAll() map[K]V { sm.lock.RLock() defer sm.lock.RUnlock() result := make(map[K]V) - for k, v := range sm.data { - result[k] = v - } + maps.Copy(result, sm.data) return result } @@ -72,9 +70,8 @@ func (sm *safeMap[K, V]) Clear() { // ForEach iterates over all key-value pairs in the safeMap and applies the provided function. func (sm *safeMap[K, V]) ForEach(fn func(K, V)) { - sm.lock.RLock() - defer sm.lock.RUnlock() - for k, v := range sm.data { + snapshot := sm.GetAll() + for k, v := range snapshot { fn(k, v) } } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 06065faa6..b609558ef 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -17,6 +17,9 @@ type jobDisabledMapType = *safeMap[uuid.UUID, bool] // OpScheduler is the main scheduler struct that manages jobs. type OpScheduler struct { + // Name is an optional human-readable identifier for this scheduler instance. + // Callers can use it for logging, metrics, or debugging when working with + // multiple OpScheduler instances. Name string scheduler gocron.Scheduler jobsMap jobsMapType From aae49e507bae33c9f23251ff8758ccd88a78545e Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:28:57 +0800 Subject: [PATCH 21/44] =?UTF-8?q?refactor(scheduler):=20=E4=BF=AE=E8=A1=A5?= =?UTF-8?q?=E6=B3=A8=E8=A7=A3=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 9 ++------- pkg/scheduler/scheduler.go | 6 +++--- pkg/scheduler/scheduler_test.go | 17 ++++++++++++++--- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index e6acb60a4..92819cb74 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -1,7 +1,6 @@ package scheduler import ( - "errors" "maps" "strings" "sync" @@ -121,12 +120,8 @@ func (o *OpJob) LastRun() (time.Time, error) { // NextRun returns the next run time of the job. func (o *OpJob) NextRun() (time.Time, error) { - if len(o.nextRun10) == 0 { - if o.nextRunErr == nil { - return time.Time{}, errors.New("no next run time available") - } else { - return time.Time{}, o.nextRunErr - } + if o.nextRunErr != nil { + return time.Time{}, o.nextRunErr } return o.nextRun10[0], nil } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index b609558ef..2bc3d8e8c 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -115,7 +115,7 @@ func (o *OpScheduler) buildJobParams( returnValues := f.Call(in) // call runner with params result := returnValues[0].Interface() - // 如果为空,返回空 + // if runner returns err, return it if result == nil { return nil } @@ -191,7 +191,7 @@ func (o *OpScheduler) UpdateJob( return nil } -// exists +// Exists checks whether a job with the given UUID is registered in the scheduler. func (o *OpScheduler) Exists(uuid uuid.UUID) bool { _, exists := o.getCronJob(uuid) return exists @@ -281,7 +281,7 @@ func (o *OpScheduler) filterLabels( }) } -// RemoveJobByTags removes all jobs that have all of the provided labels. +// RemoveJobByLabels removes all jobs that have all of the provided labels. func (o *OpScheduler) RemoveJobByLabels(labels JobLabels) error { if len(labels) == 0 { return nil diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 973bfe986..ed1bd2ba3 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -16,26 +16,37 @@ func TestGoCron(t *testing.T) { s.Start() defer s.Shutdown() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() arg0 := 0 arg1 := "arg1" + executeCalled := make(chan bool, 1) job, err := s.NewJob( gocron.DurationJob(5*time.Second), gocron.NewTask( func(ctx context.Context, arg0 int, arg1 string) error { t.Logf("task is running with args: %d, %s", arg0, arg1) + executeCalled <- true return nil }, arg0, arg1, ), gocron.WithContext(ctx), ) - defer cancel() t.Logf("job ID: %d", job.ID()) err = job.RunNow() if err != nil { t.Fatalf("failed to run job now: %v", err) } - time.Sleep(30 * time.Second) + select { + case <-executeCalled: + t.Log("job executed successfully") + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("job did not execute within the expected time") + } else if ctx.Err() != nil { + t.Fatalf("context error: %v", ctx.Err()) + } + } } func TestSchedulerNormal(t *testing.T) { @@ -69,7 +80,7 @@ func TestSchedulerNormal(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - t.Log("regsitry Job") + t.Log("registry Job") afterCreated, err := s.NewJob( ctx, "test-job", From c41e27079ba3eb7a5123156764b58e40ff927c38 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:52:36 +0800 Subject: [PATCH 22/44] =?UTF-8?q?fix(scheduler):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=AD=E7=9A=84=E9=94=99=E8=AF=AF=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E8=A1=A5=E6=B3=A8=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 67 ++++++++++++++++++++------------- pkg/scheduler/scheduler.go | 19 +++++++--- pkg/scheduler/scheduler_test.go | 1 - pkg/scheduler/util.go | 3 ++ 4 files changed, 56 insertions(+), 34 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 92819cb74..4097c419b 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -2,7 +2,6 @@ package scheduler import ( "maps" - "strings" "sync" "time" @@ -10,7 +9,17 @@ import ( "github.com/google/uuid" ) -// JobRunner defines the function signature for job runners +// JobRunner defines the expected function signature for job runners. +// +// Implementations must be functions that accept a context.Context as the first +// parameter, followed by zero or more additional parameters, and return an error. +// +// A canonical example is: +// +// func(ctx context.Context, args ...any) error +// +// While JobRunner is typed as any for flexibility, callers are expected to +// adhere to this function shape. type JobRunner any // JobLabels the type for job labels @@ -68,23 +77,26 @@ func (sm *safeMap[K, V]) Clear() { } // ForEach iterates over all key-value pairs in the safeMap and applies the provided function. +// The callback is executed while holding a read lock; it should be fast and must not call +// methods on the same safeMap that acquire the lock (e.g., Set, Delete, Clear) to avoid deadlocks. func (sm *safeMap[K, V]) ForEach(fn func(K, V)) { - snapshot := sm.GetAll() - for k, v := range snapshot { + sm.lock.RLock() + defer sm.lock.RUnlock() + for k, v := range sm.data { fn(k, v) } } // OpJob represents an operational job with its metadata. type OpJob struct { - id uuid.UUID - name string - lastRun time.Time - lastRunErr error - nextRun10 []time.Time - nextRunErr error - labels JobLabels - disabled bool + id uuid.UUID + name string + lastRun time.Time + lastRunErr error + nextTenRuns []time.Time + nextRunErr error + labels JobLabels + disabled bool } // ID returns the UUID of the job. @@ -123,35 +135,36 @@ func (o *OpJob) NextRun() (time.Time, error) { if o.nextRunErr != nil { return time.Time{}, o.nextRunErr } - return o.nextRun10[0], nil + return o.nextTenRuns[0], nil } -// NextRuns returns the next n run times of the job. -func (o *OpJob) NextRuns10() ([]time.Time, error) { - return o.nextRun10, o.nextRunErr +// GetNextTenRuns returns the next 10 run times of the job. +func (o *OpJob) GetNextTenRuns() ([]time.Time, error) { + return o.nextTenRuns, o.nextRunErr } // newOpJob creates a new OpJob instance from a gocron.Job and its disabled status. func newOpJob(job gocron.Job, disabled bool) *OpJob { labels := make(JobLabels) for _, tag := range job.Tags() { - parts := strings.SplitN(tag, ":", 2) - if len(parts) == 2 { - labels[unescapeTagStr(parts[0])] = unescapeTagStr(parts[1]) + key, value, ok := splitEscapedTag(tag) + if !ok { + continue } + labels[key] = value } lastRun, lastRunErr := job.LastRun() nextRun10, nextRunErr := job.NextRuns(10) labelsCopy := make(JobLabels) maps.Copy(labelsCopy, labels) return &OpJob{ - id: job.ID(), - name: job.Name(), - lastRun: lastRun, - lastRunErr: lastRunErr, - nextRun10: nextRun10, - nextRunErr: nextRunErr, - labels: labelsCopy, - disabled: disabled, + id: job.ID(), + name: job.Name(), + lastRun: lastRun, + lastRunErr: lastRunErr, + nextTenRuns: nextRun10, + nextRunErr: nextRunErr, + labels: labelsCopy, + disabled: disabled, } } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 2bc3d8e8c..130e90575 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -92,16 +92,15 @@ func (o *OpScheduler) buildJobParams( return nil, errors.New("runner is nil") } if len(params)+1 != f.Type().NumIn() { - return nil, errors.New("number of params does not match runner function signature") + return nil, errors.New("number of params does not match runner function signature (expected N params plus context parameter)") } - // check runner and params // check runner as function and NumIn is match params length task := gocron.NewTask(func(_ctx context.Context, params []any) error { // check if job is exists and not disabled j, exists := o.getCronJob(jobUUID) // In theory the job should always exist, but check just in case if !exists { - return nil + return errors.New("cron job not found") } // check disabled status if o.jobIsDisabled(j.ID()) { @@ -112,10 +111,10 @@ func (o *OpScheduler) buildJobParams( for k, param := range params { in[k+1] = reflect.ValueOf(param) } + // call runner with params appended context at first returnValues := f.Call(in) - // call runner with params result := returnValues[0].Interface() - // if runner returns err, return it + // if runner returns an error, return it if result == nil { return nil } @@ -133,6 +132,12 @@ func (o *OpScheduler) NewJob( labels JobLabels, runner JobRunner, params ...any) (*OpJob, error) { + if runner == nil { + return nil, errors.New("runner is nil") + } + if jobName == "" { + return nil, errors.New("jobName is empty") + } jobUUID := uuid.New() tags := o.jobLabels2Tags(labels) task, err := o.buildJobParams(jobUUID, runner, params) @@ -154,7 +159,7 @@ func (o *OpScheduler) NewJob( if disabled { o.jobDisabledMap.Set(jobUUID, true) } - return newOpJob(job, false), nil + return newOpJob(job, disabled), nil } // UpdateJob updates an existing job by its UUID. @@ -187,6 +192,8 @@ func (o *OpScheduler) UpdateJob( // Set disabled status if disabled { o.jobDisabledMap.Set(jobUUID, true) + } else { + o.jobDisabledMap.Delete(jobUUID) } return nil } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index ed1bd2ba3..06a436d6b 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -84,7 +84,6 @@ func TestSchedulerNormal(t *testing.T) { afterCreated, err := s.NewJob( ctx, "test-job", - // run every 10 seconds gocron.DurationJob( 5*time.Second, ), diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index b2c51a1af..e030cc9a9 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -36,6 +36,9 @@ func splitEscapedTag(tag string) (string, string, bool) { for i := 0; i < len(tag); i++ { if tag[i] == '\\' { i++ // Skip the escaped character + if i >= len(tag) { + break + } continue } if tag[i] == ':' { From 6dd0e46efb6f10a76403bbee7e789da8f9d64227 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:44:42 +0800 Subject: [PATCH 23/44] feat(sheduler): add test methods --- pkg/scheduler/scheduler_test.go | 230 ++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 06a436d6b..440f37b3a 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler import ( "context" + "log" "testing" "time" @@ -126,3 +127,232 @@ func TestSchedulerNormal(t *testing.T) { } } } + +func TestDisabledJob(t *testing.T) { + t.Log("start test for disabled job") + s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + labels := JobLabels{ + "env": "test", + "team": "devops", + } + chanCount := make(chan int, 1) + var runner JobRunner = func(ctx context.Context) error { + t.Fatalf("disabled job should not run") + chanCount <- 1 + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + t.Log("register disabled job") + afterCreated, err := s.NewJob( + ctx, + "test-job", + // runs every 5 hours, but is disabled + gocron.DurationJob( + 5*time.Second, + ), + true, // disabled + labels, + runner, + ) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + // runNow + t.Log("attempt to run disabled job immediately") + err = s.RunNow(afterCreated.ID(), false) + if err != nil { + t.Fatalf("failed to run disabled job now: %v", err) + } + // check the channel to see if the job ran + select { + case count := <-chanCount: + t.Fatalf("disabled job ran unexpectedly, count: %d", count) + case <-time.After(5 * time.Second): + t.Log("disabled job did not run as expected") + } + t.Log("test complete for disabled job") +} + +// TestEnableJob +func TestEnableJob(t *testing.T) { + t.Log("start test for enable job") + s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + labels := JobLabels{ + "env": "test", + "team": "devops", + } + chanCount := make(chan int, 1) + var runner JobRunner = func(ctx context.Context) error { + t.Log("job has run") + chanCount <- 1 + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + t.Log("register disabled job") + afterCreated, err := s.NewJob( + ctx, + "test-job", + // runs every 5 seconds, but is disabled + gocron.DurationJob( + 5*time.Second, + ), + true, // disabled + labels, + runner, + ) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + // enabled + err = s.EnableJob(afterCreated.ID()) + if err != nil { + t.Fatalf("enable job fail %v", err) + } + // check the channel to see if the job ran + select { + case count := <-chanCount: + t.Logf("success run, count: %d", count) + case <-time.After(10 * time.Second): + t.Fatalf("enabled job did not run as expected") + } + t.Log("test complete for enable job") +} + +func TestUpdateJob(t *testing.T) { + // create job donothing + t.Log("start test for enable job") + s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + labels := JobLabels{ + "env": "test", + "team": "devops", + } + chanCount := make(chan int, 1) + var runner JobRunner = func(ctx context.Context) error { + t.Log("donothing") + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + t.Log("register disabled job") + afterCreated, err := s.NewJob( + ctx, + "test-job", + // runs every 5 second, but is disabled + gocron.DurationJob( + 5*time.Second, + ), + false, // disabled + labels, + runner, + ) + // wait for 10 seconds + time.Sleep(10 * time.Second) + var runner2 JobRunner = func(ctx context.Context) error { + t.Log("change the chancount") + chanCount <- 1 + return nil + } + err = s.UpdateJob( + ctx, afterCreated.ID(), + "afterUpdate", + gocron.DurationJob( + 1*time.Second, + ), + false, + labels, + runner2, + ) + if err != nil { + log.Fatalf("update found err: %v", err) + } + j, exists := s.GetJob(afterCreated.ID()) + if !exists { + log.Fatalf("can't found after update") + } + if j.Name() != "afterUpdate" { + log.Fatalf("update name faild") + } + if j.Disabled() { + log.Fatalf("update diabled faild") + } + select { + case count := <-chanCount: + t.Logf("success run, count: %d", count) + case <-time.After(10 * time.Second): + t.Fatalf("enabled job did not run as expected") + } + t.Log("test complete for enable job") +} + +func TestRemoveJob(t *testing.T) { + t.Log("start test remove job") + // create job donothing + t.Log("start test for enable job") + s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + labels := JobLabels{ + "env": "test", + "team": "devops", + } + chanCount := make(chan int, 1) + var runner JobRunner = func(ctx context.Context) error { + chanCount <- 1 + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + t.Log("register disabled job") + afterCreated, err := s.NewJob( + ctx, + "test-job", + // runs every 5 second, but is disabled + gocron.DurationJob( + 5*time.Second, + ), + false, // disabled + labels, + runner, + ) + err = s.RemoveJobs(afterCreated.ID()) + if err != nil { + t.Fatalf("remove job err : %v", err) + } + j, exists := s.GetJob(afterCreated.ID()) + if exists || j != nil { + t.Fatalf("job exists after removed") + } + // reset chanCoun + chanCount <- 0 + // check the channel to see if the job ran + select { + case count := <-chanCount: + if count > 0 { + t.Fatalf("removed job ran unexpectedly, count: %d", count) + } + case <-time.After(10 * time.Second): + t.Log("removed job did not run as expected") + } + t.Log("test complete for removed job") + +} From fff2d619d1aaeb0907f3ca1c23e070d0ba0f4f66 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:48:20 +0800 Subject: [PATCH 24/44] =?UTF-8?q?feat(sheduler):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler_test.go | 427 +++++++++++++++++++++++++------- 1 file changed, 340 insertions(+), 87 deletions(-) diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 440f37b3a..80b69628c 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -2,13 +2,20 @@ package scheduler import ( "context" - "log" "testing" "time" "github.com/go-co-op/gocron/v2" ) +const ( + fastInterval = 50 * time.Millisecond + fasterInterval = 20 * time.Millisecond + defaultTimeout = 2 * time.Second + shortWait = 300 * time.Millisecond +) + +// TestGoCron sanity-checks direct gocron usage with immediate execution. func TestGoCron(t *testing.T) { s, err := gocron.NewScheduler(gocron.WithLocation(time.Local)) if err != nil { @@ -16,13 +23,13 @@ func TestGoCron(t *testing.T) { } s.Start() defer s.Shutdown() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() arg0 := 0 arg1 := "arg1" executeCalled := make(chan bool, 1) job, err := s.NewJob( - gocron.DurationJob(5*time.Second), + gocron.DurationJob(fastInterval), gocron.NewTask( func(ctx context.Context, arg0 int, arg1 string) error { t.Logf("task is running with args: %d, %s", arg0, arg1) @@ -50,6 +57,7 @@ func TestGoCron(t *testing.T) { } } +// TestSchedulerNormal verifies a normal job runs with provided params and labels. func TestSchedulerNormal(t *testing.T) { t.Log("start test") t.Logf("Localtime: %v", time.Local) @@ -79,15 +87,13 @@ func TestSchedulerNormal(t *testing.T) { t.Log("task done") return nil } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() t.Log("registry Job") afterCreated, err := s.NewJob( ctx, "test-job", - gocron.DurationJob( - 5*time.Second, - ), + gocron.DurationJob(fastInterval), false, labels, runner, @@ -128,6 +134,7 @@ func TestSchedulerNormal(t *testing.T) { } } +// TestDisabledJob ensures a job created disabled does not execute. func TestDisabledJob(t *testing.T) { t.Log("start test for disabled job") s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) @@ -146,16 +153,14 @@ func TestDisabledJob(t *testing.T) { chanCount <- 1 return nil } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() t.Log("register disabled job") afterCreated, err := s.NewJob( ctx, "test-job", // runs every 5 hours, but is disabled - gocron.DurationJob( - 5*time.Second, - ), + gocron.DurationJob(fastInterval), true, // disabled labels, runner, @@ -163,6 +168,11 @@ func TestDisabledJob(t *testing.T) { if err != nil { t.Fatalf("failed to create job: %v", err) } + if job, ok := s.GetJob(afterCreated.ID()); !ok { + t.Fatalf("expected disabled job to exist after creation") + } else if !job.Disabled() { + t.Fatalf("expected job %s to be disabled", job.ID()) + } // runNow t.Log("attempt to run disabled job immediately") err = s.RunNow(afterCreated.ID(), false) @@ -173,13 +183,13 @@ func TestDisabledJob(t *testing.T) { select { case count := <-chanCount: t.Fatalf("disabled job ran unexpectedly, count: %d", count) - case <-time.After(5 * time.Second): + case <-time.After(shortWait): t.Log("disabled job did not run as expected") } t.Log("test complete for disabled job") } -// TestEnableJob +// TestEnableJob ensures enabling a disabled job allows execution. func TestEnableJob(t *testing.T) { t.Log("start test for enable job") s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) @@ -198,16 +208,14 @@ func TestEnableJob(t *testing.T) { chanCount <- 1 return nil } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() t.Log("register disabled job") afterCreated, err := s.NewJob( ctx, "test-job", // runs every 5 seconds, but is disabled - gocron.DurationJob( - 5*time.Second, - ), + gocron.DurationJob(fastInterval), true, // disabled labels, runner, @@ -220,17 +228,24 @@ func TestEnableJob(t *testing.T) { if err != nil { t.Fatalf("enable job fail %v", err) } + if job, ok := s.GetJob(afterCreated.ID()); !ok { + t.Fatalf("expected job to exist after enable") + } else if job.Disabled() { + t.Fatalf("job %s should be enabled after EnableJob", job.ID()) + } // check the channel to see if the job ran select { case count := <-chanCount: t.Logf("success run, count: %d", count) - case <-time.After(10 * time.Second): + case <-time.After(defaultTimeout): t.Fatalf("enabled job did not run as expected") } t.Log("test complete for enable job") } -func TestUpdateJob(t *testing.T) { +// TestRemoveJob ensures removing a job deletes it and prevents execution. +func TestRemoveJob(t *testing.T) { + t.Log("start test remove job") // create job donothing t.Log("start test for enable job") s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) @@ -245,114 +260,352 @@ func TestUpdateJob(t *testing.T) { } chanCount := make(chan int, 1) var runner JobRunner = func(ctx context.Context) error { - t.Log("donothing") + chanCount <- 1 return nil } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() t.Log("register disabled job") afterCreated, err := s.NewJob( ctx, "test-job", // runs every 5 second, but is disabled - gocron.DurationJob( - 5*time.Second, - ), + gocron.DurationJob(time.Hour), false, // disabled labels, runner, ) - // wait for 10 seconds - time.Sleep(10 * time.Second) - var runner2 JobRunner = func(ctx context.Context) error { - t.Log("change the chancount") - chanCount <- 1 - return nil + // avoid blocking if the channel already has a value + select { + case chanCount <- 0: + default: } - err = s.UpdateJob( - ctx, afterCreated.ID(), - "afterUpdate", - gocron.DurationJob( - 1*time.Second, - ), + err = s.RemoveJobs(afterCreated.ID()) + if err != nil { + t.Fatalf("remove job %s err: %v", afterCreated.ID(), err) + } + j, exists := s.GetJob(afterCreated.ID()) + if exists || j != nil { + t.Fatalf("job %s exists after removed", afterCreated.ID()) + } + // check the channel to see if the job ran + select { + case count := <-chanCount: + if count > 0 { + t.Fatalf("removed job ran unexpectedly, count: %d", count) + } + case <-time.After(defaultTimeout): + t.Log("removed job did not run as expected") + } + t.Log("test complete for removed job") + +} + +// TestDisableJobMethod ensures DisableJob marks an existing job disabled and prevents RunNow(false) from executing it. +func TestDisableJobMethod(t *testing.T) { + s, err := NewOpScheduler("test-disable-job", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + labels := JobLabels{"env": "test"} + executed := make(chan bool, 1) + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + job, err := s.NewJob( + ctx, + "disable-job", + gocron.DurationJob(time.Hour), false, labels, - runner2, + func(ctx context.Context) error { + executed <- true + return nil + }, ) if err != nil { - log.Fatalf("update found err: %v", err) + t.Fatalf("failed to create job: %v", err) } - j, exists := s.GetJob(afterCreated.ID()) - if !exists { - log.Fatalf("can't found after update") + if err := s.DisableJob(job.ID()); err != nil { + t.Fatalf("disable job failed: %v", err) } - if j.Name() != "afterUpdate" { - log.Fatalf("update name faild") + updated, ok := s.GetJob(job.ID()) + if !ok || !updated.Disabled() { + t.Fatalf("expected job disabled") } - if j.Disabled() { - log.Fatalf("update diabled faild") + if err := s.RunNow(job.ID(), false); err != nil { + t.Fatalf("run now failed for %s: %v", job.ID(), err) } select { - case count := <-chanCount: - t.Logf("success run, count: %d", count) - case <-time.After(10 * time.Second): - t.Fatalf("enabled job did not run as expected") + case <-executed: + t.Fatalf("disabled job should not run") + case <-time.After(shortWait): } - t.Log("test complete for enable job") } -func TestRemoveJob(t *testing.T) { - t.Log("start test remove job") - // create job donothing - t.Log("start test for enable job") - s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) +// TestRunNowForceExecutesJob ensures RunNow(true) triggers execution even on demand. +func TestRunNowForceExecutesJob(t *testing.T) { + s, err := NewOpScheduler("test-run-now-force", gocron.WithLocation(time.Local)) if err != nil { t.Fatalf("failed to create scheduler: %v", err) } s.Start() defer s.Close() - labels := JobLabels{ - "env": "test", - "team": "devops", + labels := JobLabels{"env": "test"} + executed := make(chan bool, 1) + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + job, err := s.NewJob( + ctx, + "force-run", + gocron.DurationJob(time.Hour), + false, + labels, + func(ctx context.Context) error { + executed <- true + return nil + }, + ) + if err != nil { + t.Fatalf("failed to create job: %v", err) } - chanCount := make(chan int, 1) - var runner JobRunner = func(ctx context.Context) error { - chanCount <- 1 - return nil + if _, ok := s.GetJob(job.ID()); !ok { + t.Fatalf("force-run job not found after creation") + } + if err := s.RunNow(job.ID(), true); err != nil { + t.Fatalf("force run failed for %s: %v", job.ID(), err) + } + select { + case <-executed: + return + case <-time.After(defaultTimeout): + t.Fatalf("force run did not execute") + } +} + +// TestUpdateJobLabelsAndEnable ensures UpdateJob toggles disabled->enabled and updates labels. +func TestUpdateJobLabelsAndEnable(t *testing.T) { + s, err := NewOpScheduler("test-update-toggle", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + s.Start() + defer s.Close() + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() - t.Log("register disabled job") - afterCreated, err := s.NewJob( + initialLabels := JobLabels{"env": "old", "team": "ops"} + job, err := s.NewJob( ctx, - "test-job", - // runs every 5 second, but is disabled - gocron.DurationJob( - 5*time.Second, - ), - false, // disabled - labels, - runner, + "update-job", + gocron.DurationJob(time.Hour), + true, + initialLabels, + func(ctx context.Context) error { return nil }, ) - err = s.RemoveJobs(afterCreated.ID()) if err != nil { - t.Fatalf("remove job err : %v", err) + t.Fatalf("failed to create job: %v", err) } - j, exists := s.GetJob(afterCreated.ID()) - if exists || j != nil { - t.Fatalf("job exists after removed") + updatedLabels := JobLabels{"env": "new", "team": "dev"} + executed := make(chan bool, 1) + if err := s.UpdateJob( + ctx, + job.ID(), + "update-job-new", + gocron.DurationJob(fastInterval), + false, + updatedLabels, + func(ctx context.Context) error { + executed <- true + return nil + }, + ); err != nil { + t.Fatalf("update failed: %v", err) + } + updated, ok := s.GetJob(job.ID()) + if !ok { + t.Fatalf("job not found after update") + } + if updated.Disabled() { + t.Fatalf("job should be enabled after update") + } + if updated.Name() != "update-job-new" { + t.Fatalf("unexpected name after update: %s", updated.Name()) + } + labels := updated.Labels() + if labels["env"] != "new" || labels["team"] != "dev" { + t.Fatalf("labels not updated: %+v", labels) } - // reset chanCoun - chanCount <- 0 - // check the channel to see if the job ran select { - case count := <-chanCount: - if count > 0 { - t.Fatalf("removed job ran unexpectedly, count: %d", count) + case <-executed: + case <-time.After(defaultTimeout): + t.Fatalf("updated job did not run") + } +} + +// TestRemoveJobsLeavesOthers removes one job while keeping another running. +func TestRemoveJobsLeavesOthers(t *testing.T) { + s, err := NewOpScheduler("test-remove-jobs", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + keepRan := make(chan bool, 1) + removeRan := make(chan bool, 1) + jobRemove, err := s.NewJob(ctx, "remove-me", gocron.DurationJob(time.Hour), false, JobLabels{"env": "remove"}, func(ctx context.Context) error { + removeRan <- true + return nil + }) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + jobKeep, err := s.NewJob(ctx, "keep-me", gocron.DurationJob(fastInterval), false, JobLabels{"env": "keep"}, func(ctx context.Context) error { + keepRan <- true + return nil + }) + if err != nil { + t.Fatalf("failed to create keep job: %v", err) + } + if err := s.RemoveJobs(jobRemove.ID()); err != nil { + t.Fatalf("remove jobs failed for %s: %v", jobRemove.ID(), err) + } + if _, ok := s.GetJob(jobRemove.ID()); ok { + t.Fatalf("removed job still exists: %s", jobRemove.ID()) + } + if keepJob, ok := s.GetJob(jobKeep.ID()); !ok { + t.Fatalf("kept job missing: %s", jobKeep.ID()) + } else if keepJob.Labels()["env"] != "keep" { + t.Fatalf("kept job label mismatch: got %q want %q", keepJob.Labels()["env"], "keep") + } + select { + case <-removeRan: + t.Fatalf("removed job executed") + case <-time.After(shortWait): + } + select { + case <-keepRan: + case <-time.After(defaultTimeout): + t.Fatalf("kept job did not execute") + } + // ensure keep job still exists + if _, ok := s.GetJob(jobKeep.ID()); !ok { + t.Fatalf("kept job missing: %s", jobKeep.ID()) + } +} + +// TestRemoveJobByLabels removes all jobs matching specific labels while keeping others. +func TestRemoveJobByLabels(t *testing.T) { + s, err := NewOpScheduler("test-remove-by-label", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + labelsDev := JobLabels{"env": "dev"} + labelsProd := JobLabels{"env": "prod"} + _, err = s.NewJob(ctx, "dev-1", gocron.DurationJob(time.Hour), false, labelsDev, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create dev-1: %v", err) + } + devTwo, err := s.NewJob(ctx, "dev-2", gocron.DurationJob(time.Hour), false, labelsDev, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create dev-2: %v", err) + } + prod, err := s.NewJob(ctx, "prod", gocron.DurationJob(time.Hour), false, labelsProd, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create prod: %v", err) + } + if err := s.RemoveJobByLabels(labelsDev); err != nil { + t.Fatalf("remove by labels failed for %v: %v", labelsDev, err) + } + if _, ok := s.GetJob(devTwo.ID()); ok { + t.Fatalf("dev job still exists after removal: %s labels=%v", devTwo.ID(), labelsDev) + } + if _, ok := s.GetJob(prod.ID()); !ok { + t.Fatalf("prod job should remain: %s labels=%v", prod.ID(), labelsProd) + } +} + +// TestGetJobsByLabelsFilters verifies label-based filtering returns matching jobs only. +func TestGetJobsByLabelsFilters(t *testing.T) { + s, err := NewOpScheduler("test-get-by-labels", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + labelsA := JobLabels{"env": "dev", "team": "a"} + labelsB := JobLabels{"env": "dev", "team": "b"} + labelsC := JobLabels{"env": "prod", "team": "a"} + jobA, err := s.NewJob(ctx, "job-a", gocron.DurationJob(time.Hour), false, labelsA, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create job-a: %v", err) + } + jobB, err := s.NewJob(ctx, "job-b", gocron.DurationJob(time.Hour), true, labelsB, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create job-b: %v", err) + } + _, err = s.NewJob(ctx, "job-c", gocron.DurationJob(time.Hour), false, labelsC, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create job-c: %v", err) + } + devJobs := s.GetJobsByLabels(JobLabels{"env": "dev"}) + if len(devJobs) != 2 { + t.Fatalf("expected 2 dev jobs for env=dev, got %d", len(devJobs)) + } + var seenA, seenB bool + for _, j := range devJobs { + if j.ID() == jobA.ID() { + seenA = true + } + if j.ID() == jobB.ID() { + seenB = true } - case <-time.After(10 * time.Second): - t.Log("removed job did not run as expected") } - t.Log("test complete for removed job") + if !seenA || !seenB { + t.Fatalf("missing dev jobs: seenA=%v seenB=%v", seenA, seenB) + } +} +// TestRemoveAllJobs clears all jobs and verifies none remain runnable. +func TestRemoveAllJobs(t *testing.T) { + s, err := NewOpScheduler("test-remove-all", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + labels := JobLabels{"env": "test"} + job1, err := s.NewJob(ctx, "job-1", gocron.DurationJob(time.Hour), false, labels, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create job1: %v", err) + } + job2, err := s.NewJob(ctx, "job-2", gocron.DurationJob(time.Hour), false, labels, func(ctx context.Context) error { return nil }) + if err != nil { + t.Fatalf("failed to create job2: %v", err) + } + if err := s.RemoveAllJobs(); err != nil { + t.Fatalf("remove all jobs failed: %v", err) + } + if _, ok := s.GetJob(job1.ID()); ok { + t.Fatalf("job1 still exists after remove all: %s", job1.ID()) + } + if _, ok := s.GetJob(job2.ID()); ok { + t.Fatalf("job2 still exists after remove all: %s", job2.ID()) + } + if got := s.GetJobsByLabels(JobLabels{"env": "test"}); len(got) != 0 { + t.Fatalf("expected no jobs after remove all, got %d", len(got)) + } + if err := s.RunNow(job1.ID(), false); err == nil { + t.Fatalf("expected error running removed job: %s", job1.ID()) + } } From f57091ca6668280df836e4bcd547429f7792c9a4 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:34:22 +0800 Subject: [PATCH 25/44] =?UTF-8?q?refactor(sheduler):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 9 +-------- pkg/scheduler/scheduler.go | 31 ++++++------------------------- pkg/scheduler/util.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 4097c419b..a6900ed4c 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -145,14 +145,7 @@ func (o *OpJob) GetNextTenRuns() ([]time.Time, error) { // newOpJob creates a new OpJob instance from a gocron.Job and its disabled status. func newOpJob(job gocron.Job, disabled bool) *OpJob { - labels := make(JobLabels) - for _, tag := range job.Tags() { - key, value, ok := splitEscapedTag(tag) - if !ok { - continue - } - labels[key] = value - } + labels := tags2JobLabels(job.Tags()) lastRun, lastRunErr := job.LastRun() nextRun10, nextRunErr := job.NextRuns(10) labelsCopy := make(JobLabels) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 130e90575..09f1993cc 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -53,28 +53,6 @@ func (o *OpScheduler) RunNow(jobUUID uuid.UUID, force bool) error { return job.RunNow() } -// jobLabels2Tags converts JobLabels to a slice of tags. -func (o *OpScheduler) jobLabels2Tags(labels JobLabels) []string { - tags := make([]string, 0, len(labels)) - for k, v := range labels { - tags = append(tags, escapeTagStr(k)+":"+escapeTagStr(v)) - } - return tags -} - -// tags2JobLabels converts a slice of tags to JobLabels. -func (o *OpScheduler) tags2JobLabels(tags []string) JobLabels { - labels := make(JobLabels) - for _, tag := range tags { - keyPart, valPart, ok := splitEscapedTag(tag) - if !ok { - continue - } - labels[unescapeTagStr(keyPart)] = unescapeTagStr(valPart) - } - return labels -} - // jobIsDisabled checks if a job is disabled. func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { disabled, exists := o.jobDisabledMap.Get(jobUUID) @@ -139,7 +117,7 @@ func (o *OpScheduler) NewJob( return nil, errors.New("jobName is empty") } jobUUID := uuid.New() - tags := o.jobLabels2Tags(labels) + tags := jobLabels2Tags(labels) task, err := o.buildJobParams(jobUUID, runner, params) if err != nil { return nil, err @@ -179,7 +157,7 @@ func (o *OpScheduler) UpdateJob( if err != nil { return err } - tags := o.jobLabels2Tags(labels) + tags := jobLabels2Tags(labels) job, err := o.scheduler.Update( jobUUID, cron, task, gocron.WithContext(ctx), gocron.WithName(jobName), gocron.WithTags(tags...), @@ -273,8 +251,11 @@ func (o *OpScheduler) filterLabels( labels JobLabels, action func(gocron.Job, JobLabels), ) { + if len(o.jobsMap.data) == 0 { + return + } o.jobsMap.ForEach(func(_ uuid.UUID, job gocron.Job) { - jobLabels := o.tags2JobLabels(job.Tags()) + jobLabels := tags2JobLabels(job.Tags()) matches := true for k, v := range labels { if jobVal, exists := jobLabels[k]; !exists || jobVal != v { diff --git a/pkg/scheduler/util.go b/pkg/scheduler/util.go index e030cc9a9..40f9dc72c 100644 --- a/pkg/scheduler/util.go +++ b/pkg/scheduler/util.go @@ -30,6 +30,34 @@ func unescapeTagStr(s string) string { return b.String() } +// jobLabels2Tags converts JobLabels to a slice of tags. +func jobLabels2Tags(labels JobLabels) []string { + tags := make([]string, 0, len(labels)) + if len(labels) == 0 { + return tags + } + for k, v := range labels { + tags = append(tags, escapeTagStr(k)+":"+escapeTagStr(v)) + } + return tags +} + +// tags2JobLabels converts a slice of tags to JobLabels. +func tags2JobLabels(tags []string) JobLabels { + labels := make(JobLabels) + if len(tags) == 0 { + return labels + } + for _, tag := range tags { + keyPart, valPart, ok := splitEscapedTag(tag) + if !ok { + continue + } + labels[unescapeTagStr(keyPart)] = unescapeTagStr(valPart) + } + return labels +} + // splitEscapedTag splits the first unescaped colon to separate key and value. // It expects the input to be produced by escapeTagStr. func splitEscapedTag(tag string) (string, string, bool) { From aee57a483c669883647be56a86aea6c4225195d5 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:26:19 +0800 Subject: [PATCH 26/44] =?UTF-8?q?refactor(sheduler):=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=9E=84=E9=80=A0=E5=99=A8=E6=A8=A1=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E7=BC=96=E7=A0=81=E4=BD=93=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E9=99=8D=E4=BD=8E=E8=BF=AD=E4=BB=A3=E9=9A=BE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 190 ++++++++++++++++++++++++++++++++ pkg/scheduler/meta.go | 7 ++ pkg/scheduler/scheduler.go | 52 +++++++-- pkg/scheduler/scheduler_test.go | 174 ++++++++++++++++------------- 4 files changed, 337 insertions(+), 86 deletions(-) create mode 100644 pkg/scheduler/job.go diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go new file mode 100644 index 000000000..42d6bcfde --- /dev/null +++ b/pkg/scheduler/job.go @@ -0,0 +1,190 @@ +package scheduler + +import ( + "context" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" +) + +type jobBuilder struct { + id uuid.UUID + ctx context.Context + jobName string + cron gocron.JobDefinition + disabled bool + labels JobLabels + runner JobRunner + params []any +} + +// NewJobBuilder create a jobBuilder +func NewJobBuilder() *jobBuilder { + return &jobBuilder{ + disabled: false, + labels: make(JobLabels), + } +} + +// ID sets the job ID if needed. +func (jb *jobBuilder) ID(id uuid.UUID) *jobBuilder { + jb.id = id + return jb +} + +// Ctx sets the job context. +func (jb *jobBuilder) Ctx(ctx context.Context) *jobBuilder { + jb.ctx = ctx + return jb +} + +// Name sets the job name. +func (jb *jobBuilder) Name(name string) *jobBuilder { + jb.jobName = name + return jb +} + +// rawCron sets the job cron definition. +func (jb *jobBuilder) rawCron(cron gocron.JobDefinition) *jobBuilder { + jb.cron = cron + return jb +} + +// ByCrontab defines a new job using the crontab syntax: `* * * * *`. +// An optional 6th field can be used at the beginning if withSeconds +// is set to true: `* * * * * *`. +// The timezone can be set on the Scheduler using WithLocation, or in the +// crontab in the form `TZ=America/Chicago * * * * *` or +// `CRON_TZ=America/Chicago * * * * *` +func (jb *jobBuilder) ByCrontab(crontab string, withSeconds bool) *jobBuilder { + return jb.rawCron(gocron.CronJob(crontab, withSeconds)) +} + +// ByDuration defines a new job using time.Duration +// for the interval. +func (jb *jobBuilder) ByDuration(d time.Duration) *jobBuilder { + return jb.rawCron(gocron.DurationJob(d)) +} + +// ByDurationRandomJob defines a new job that runs on a random interval +// between the min and max duration values provided. +// +// To achieve a similar behavior as tools that use a splay/jitter technique +// consider the median value as the baseline and the difference between the +// max-median or median-min as the splay/jitter. +// +// For example, if you want a job to run every 5 minutes, but want to add +// up to 1 min of jitter to the interval, you could use +// ByDurationRandomJob(4*time.Minute, 6*time.Minute) +func (jb *jobBuilder) ByDurationRandomJob(min, max time.Duration) *jobBuilder { + return jb.rawCron(gocron.DurationRandomJob(min, max)) +} + +// ByDaily defines a new job that runs daily at the specified time. +func (jb *jobBuilder) ByDaily(interval uint, atTimes AtTimes) *jobBuilder { + return jb.rawCron(gocron.DailyJob(interval, newAtTimes(atTimes))) +} + +// ByWeekly defines a new job that runs weekly at the specified time. +func (jb *jobBuilder) ByWeekly(interval uint, weekdays []time.Weekday, atTimes AtTimes) *jobBuilder { + return jb.rawCron(gocron.WeeklyJob(interval, newWeekdays(weekdays), newAtTimes(atTimes))) +} + +// ByMonthly runs the job on the interval of months, on the specific days of the month +// specified, and at the set times. Days of the month can be 1 to 31 or negative (-1 to -31), which +// count backwards from the end of the month. E.g. -1 is the last day of the month. +// +// If a day of the month is selected that does not exist in all months (e.g. 31st) +// any month that does not have that day will be skipped. +// +// By default, the job will start the next available day, considering the last run to be now, +// and the time and month based on the interval, days and times you input. +// This means, if you select an interval greater than 1, your job by default will run +// X (interval) months from now if there are no daysOfTheMonth left in the current month. +// You can use WithStartAt to tell the scheduler to start the job sooner. +// +// Carefully consider your configuration! +// - For example: an interval of 2 months on the 31st of each month, starting 12/31 +// would skip Feb, April, June, and next run would be in August. +func (jb *jobBuilder) ByMonthly(interval uint, daysOfTheMonth []int, atTimes AtTimes) *jobBuilder { + return jb.rawCron(gocron.MonthlyJob( + interval, + newDaysOfTheMonth(daysOfTheMonth), + newAtTimes(atTimes))) +} + +// ByOneTimeJobStartImmediately tells the scheduler to run the one time job immediately. +func (jb *jobBuilder) ByOneTimeJobStartImmediately() *jobBuilder { + return jb.rawCron(gocron.OneTimeJob(gocron.OneTimeJobStartImmediately())) +} + +// ByOneTimeJobStartDateTime sets the date & time at which the job should run. +// This datetime must be in the future (according to the scheduler clock). +func (jb *jobBuilder) ByOneTimeJobStartDateTime(start time.Time) *jobBuilder { + return jb.rawCron(gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start))) +} + +// ByOneTimeJobStartDateTimes sets the date & times at which the job should run. +// At least one of the date/times must be in the future (according to the scheduler clock). +func (jb *jobBuilder) ByOneTimeJobStartDateTimes(times ...time.Time) *jobBuilder { + return jb.rawCron(gocron.OneTimeJob(gocron.OneTimeJobStartDateTimes(times...))) +} + +// Disabled sets the job disabled status. +func (jb *jobBuilder) Disabled(disabled bool) *jobBuilder { + jb.disabled = disabled + return jb +} + +// Label add or replaces a label key/value pair. +func (jb *jobBuilder) Label(key, value string) *jobBuilder { + jb.labels[key] = value + return jb +} + +// Labels batch adds or replaces multiple label key/value pairs. +func (jb *jobBuilder) Labels(labels JobLabels) *jobBuilder { + if len(labels) == 0 { + return jb + } + for k, v := range labels { + jb.labels[k] = v + } + return jb +} + +// Runner sets the job runner function and the params. +func (jb *jobBuilder) Runner(runner JobRunner, params ...any) *jobBuilder { + jb.runner = runner + jb.params = params + return jb +} + +func newAtTimes(atTimes []AtTime) gocron.AtTimes { + if len(atTimes) == 1 { + at := gocron.NewAtTime(atTimes[0].hours, atTimes[0].minutes, atTimes[0].seconds) + return gocron.NewAtTimes(at) + } + var gocronAtTimes []gocron.AtTime + for _, at := range atTimes[1:] { + gocronAtTimes = append(gocronAtTimes, gocron.NewAtTime(at.hours, at.minutes, at.seconds)) + } + return gocron.NewAtTimes( + gocron.NewAtTime(atTimes[0].hours, atTimes[0].minutes, atTimes[0].seconds), + gocronAtTimes..., + ) +} +func newWeekdays(weekdays []time.Weekday) gocron.Weekdays { + if len(weekdays) == 1 { + return gocron.NewWeekdays(weekdays[0]) + } + return gocron.NewWeekdays(weekdays[0], weekdays[1:]...) +} + +func newDaysOfTheMonth(days []int) gocron.DaysOfTheMonth { + if len(days) == 1 { + return gocron.NewDaysOfTheMonth(days[0]) + } + return gocron.NewDaysOfTheMonth(days[0], days[1:]...) +} diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index a6900ed4c..016f25a41 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -161,3 +161,10 @@ func newOpJob(job gocron.Job, disabled bool) *OpJob { disabled: disabled, } } + +type AtTime struct { + hours, minutes, seconds uint +} + +// AtTimes define a list of AtTime +type AtTimes []AtTime diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 09f1993cc..2db7af3f3 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -42,7 +42,7 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, // RunNow runs a job immediately by its UUID. func (o *OpScheduler) RunNow(jobUUID uuid.UUID, force bool) error { - job, exists := o.getCronJob(jobUUID) + job, exists := o._internalGetCronJob(jobUUID) if !exists { return errors.New("job not found: " + jobUUID.String()) } @@ -75,7 +75,7 @@ func (o *OpScheduler) buildJobParams( // check runner as function and NumIn is match params length task := gocron.NewTask(func(_ctx context.Context, params []any) error { // check if job is exists and not disabled - j, exists := o.getCronJob(jobUUID) + j, exists := o._internalGetCronJob(jobUUID) // In theory the job should always exist, but check just in case if !exists { return errors.New("cron job not found") @@ -101,8 +101,38 @@ func (o *OpScheduler) buildJobParams( return task, nil } -// NewJob creates and schedules a new job. -func (o *OpScheduler) NewJob( +// NewJobByBuilder creates and shedules a new job by builder +func (o *OpScheduler) NewJob(jb *jobBuilder) (*OpJob, error) { + return o._internalNewJob( + jb.ctx, + jb.jobName, + jb.cron, + jb.disabled, + jb.labels, + jb.runner, + jb.params..., + ) +} + +// UpdateJob updates an existing job by its UUID using a job builder. +func (o *OpScheduler) UpdateJob( + jobUUID uuid.UUID, + jb *jobBuilder, +) error { + return o._internalUpdateJob( + jobUUID, + jb.ctx, + jb.jobName, + jb.cron, + jb.disabled, + jb.labels, + jb.runner, + jb.params..., + ) +} + +// _internalNewJob creates and schedules a new job. +func (o *OpScheduler) _internalNewJob( ctx context.Context, jobName string, cron gocron.JobDefinition, @@ -140,10 +170,10 @@ func (o *OpScheduler) NewJob( return newOpJob(job, disabled), nil } -// UpdateJob updates an existing job by its UUID. -func (o *OpScheduler) UpdateJob( - ctx context.Context, +// _internalUpdateJob updates an existing job by its UUID. +func (o *OpScheduler) _internalUpdateJob( jobUUID uuid.UUID, + ctx context.Context, jobName string, cron gocron.JobDefinition, disabled bool, @@ -178,18 +208,18 @@ func (o *OpScheduler) UpdateJob( // Exists checks whether a job with the given UUID is registered in the scheduler. func (o *OpScheduler) Exists(uuid uuid.UUID) bool { - _, exists := o.getCronJob(uuid) + _, exists := o._internalGetCronJob(uuid) return exists } -// getCronJob retrieves a gocron.Job by its UUID. -func (o *OpScheduler) getCronJob(jobUUID uuid.UUID) (gocron.Job, bool) { +// _internalGetCronJob retrieves a gocron.Job by its UUID. +func (o *OpScheduler) _internalGetCronJob(jobUUID uuid.UUID) (gocron.Job, bool) { return o.jobsMap.Get(jobUUID) } // GetJob retrieves a job by its UUID. func (o *OpScheduler) GetJob(jobUUID uuid.UUID) (*OpJob, bool) { - job, exists := o.getCronJob(jobUUID) + job, exists := o._internalGetCronJob(jobUUID) if !exists { return nil, false } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 80b69628c..0e5f4748c 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -15,6 +15,8 @@ const ( shortWait = 300 * time.Millisecond ) +var donothingRunner = func(ctx context.Context) error { return nil } + // TestGoCron sanity-checks direct gocron usage with immediate execution. func TestGoCron(t *testing.T) { s, err := gocron.NewScheduler(gocron.WithLocation(time.Local)) @@ -91,13 +93,12 @@ func TestSchedulerNormal(t *testing.T) { defer cancel() t.Log("registry Job") afterCreated, err := s.NewJob( - ctx, - "test-job", - gocron.DurationJob(fastInterval), - false, - labels, - runner, - arg0, arg1, + NewJobBuilder(). + Ctx(ctx). + ByDuration(fastInterval). + Name("test-job"). + Labels(labels). + Runner(runner, arg0, arg1), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -157,13 +158,11 @@ func TestDisabledJob(t *testing.T) { defer cancel() t.Log("register disabled job") afterCreated, err := s.NewJob( - ctx, - "test-job", - // runs every 5 hours, but is disabled - gocron.DurationJob(fastInterval), - true, // disabled - labels, - runner, + NewJobBuilder().Ctx(ctx). + Name("test-job"). + ByDuration(fastInterval). + Labels(labels). + Runner(runner), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -212,13 +211,12 @@ func TestEnableJob(t *testing.T) { defer cancel() t.Log("register disabled job") afterCreated, err := s.NewJob( - ctx, - "test-job", - // runs every 5 seconds, but is disabled - gocron.DurationJob(fastInterval), - true, // disabled - labels, - runner, + NewJobBuilder().Ctx(ctx). + Name("test-job"). + ByDuration(fastInterval). + Disabled(true). + Labels(labels). + Runner(runner), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -267,13 +265,12 @@ func TestRemoveJob(t *testing.T) { defer cancel() t.Log("register disabled job") afterCreated, err := s.NewJob( - ctx, - "test-job", - // runs every 5 second, but is disabled - gocron.DurationJob(time.Hour), - false, // disabled - labels, - runner, + NewJobBuilder(). + Ctx(ctx). + Name("test-job"). + ByDuration(time.Hour). + Labels(labels). + Runner(runner), ) // avoid blocking if the channel already has a value select { @@ -314,15 +311,15 @@ func TestDisableJobMethod(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() job, err := s.NewJob( - ctx, - "disable-job", - gocron.DurationJob(time.Hour), - false, - labels, - func(ctx context.Context) error { - executed <- true - return nil - }, + NewJobBuilder(). + Ctx(ctx). + Name("test-job"). + ByDuration(time.Hour). + Labels(labels). + Runner(func(ctx context.Context) error { + executed <- true + return nil + }), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -357,15 +354,16 @@ func TestRunNowForceExecutesJob(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() job, err := s.NewJob( - ctx, - "force-run", - gocron.DurationJob(time.Hour), - false, - labels, - func(ctx context.Context) error { - executed <- true - return nil - }, + NewJobBuilder(). + Ctx(ctx). + Name("force-run-job"). + ByDuration(time.Hour). + Labels(labels). + Disabled(true). + Runner(func(ctx context.Context) error { + executed <- true + return nil + }), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -396,12 +394,13 @@ func TestUpdateJobLabelsAndEnable(t *testing.T) { defer cancel() initialLabels := JobLabels{"env": "old", "team": "ops"} job, err := s.NewJob( - ctx, - "update-job", - gocron.DurationJob(time.Hour), - true, - initialLabels, - func(ctx context.Context) error { return nil }, + NewJobBuilder(). + Ctx(ctx). + Name("update-job"). + ByDuration(time.Hour). + Labels(initialLabels). + Disabled(true). + Runner(donothingRunner), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -409,16 +408,11 @@ func TestUpdateJobLabelsAndEnable(t *testing.T) { updatedLabels := JobLabels{"env": "new", "team": "dev"} executed := make(chan bool, 1) if err := s.UpdateJob( - ctx, job.ID(), - "update-job-new", - gocron.DurationJob(fastInterval), - false, - updatedLabels, - func(ctx context.Context) error { + NewJobBuilder().Ctx(ctx).Name("update-job-new").ByDuration(fastInterval).Labels(updatedLabels).Runner(func(ctx context.Context) error { executed <- true return nil - }, + }), ); err != nil { t.Fatalf("update failed: %v", err) } @@ -455,23 +449,36 @@ func TestRemoveJobsLeavesOthers(t *testing.T) { defer cancel() keepRan := make(chan bool, 1) removeRan := make(chan bool, 1) - jobRemove, err := s.NewJob(ctx, "remove-me", gocron.DurationJob(time.Hour), false, JobLabels{"env": "remove"}, func(ctx context.Context) error { - removeRan <- true - return nil - }) + jobRemove, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("remove-me").ByDuration(fastInterval).Label("env", "remove"). + Runner( + func(ctx context.Context) error { + removeRan <- true + return nil + }, + ), + ) if err != nil { t.Fatalf("failed to create job: %v", err) } - jobKeep, err := s.NewJob(ctx, "keep-me", gocron.DurationJob(fastInterval), false, JobLabels{"env": "keep"}, func(ctx context.Context) error { - keepRan <- true - return nil - }) + jobKeep, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("keep-me").ByDuration(fastInterval).Label("env", "keep"). + Runner( + func(ctx context.Context) error { + keepRan <- true + return nil + }, + ), + ) if err != nil { t.Fatalf("failed to create keep job: %v", err) } if err := s.RemoveJobs(jobRemove.ID()); err != nil { t.Fatalf("remove jobs failed for %s: %v", jobRemove.ID(), err) } + // reset channels + removeRan <- false + keepRan <- false if _, ok := s.GetJob(jobRemove.ID()); ok { t.Fatalf("removed job still exists: %s", jobRemove.ID()) } @@ -508,15 +515,22 @@ func TestRemoveJobByLabels(t *testing.T) { defer cancel() labelsDev := JobLabels{"env": "dev"} labelsProd := JobLabels{"env": "prod"} - _, err = s.NewJob(ctx, "dev-1", gocron.DurationJob(time.Hour), false, labelsDev, func(ctx context.Context) error { return nil }) + + _, err = s.NewJob( + NewJobBuilder().Ctx(ctx).Name("dev-1").ByDuration(time.Hour).Labels(labelsDev).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create dev-1: %v", err) } - devTwo, err := s.NewJob(ctx, "dev-2", gocron.DurationJob(time.Hour), false, labelsDev, func(ctx context.Context) error { return nil }) + devTwo, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("dev-2").ByDuration(time.Hour).Labels(labelsDev).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create dev-2: %v", err) } - prod, err := s.NewJob(ctx, "prod", gocron.DurationJob(time.Hour), false, labelsProd, func(ctx context.Context) error { return nil }) + prod, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("dev-2").ByDuration(time.Hour).Labels(labelsProd).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create prod: %v", err) } @@ -544,15 +558,21 @@ func TestGetJobsByLabelsFilters(t *testing.T) { labelsA := JobLabels{"env": "dev", "team": "a"} labelsB := JobLabels{"env": "dev", "team": "b"} labelsC := JobLabels{"env": "prod", "team": "a"} - jobA, err := s.NewJob(ctx, "job-a", gocron.DurationJob(time.Hour), false, labelsA, func(ctx context.Context) error { return nil }) + jobA, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("job-a").ByDuration(time.Hour).Labels(labelsA).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create job-a: %v", err) } - jobB, err := s.NewJob(ctx, "job-b", gocron.DurationJob(time.Hour), true, labelsB, func(ctx context.Context) error { return nil }) + jobB, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("job-b").ByDuration(time.Hour).Labels(labelsB).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create job-b: %v", err) } - _, err = s.NewJob(ctx, "job-c", gocron.DurationJob(time.Hour), false, labelsC, func(ctx context.Context) error { return nil }) + _, err = s.NewJob( + NewJobBuilder().Ctx(ctx).Name("job-c").ByDuration(time.Hour).Labels(labelsC).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create job-c: %v", err) } @@ -585,11 +605,15 @@ func TestRemoveAllJobs(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() labels := JobLabels{"env": "test"} - job1, err := s.NewJob(ctx, "job-1", gocron.DurationJob(time.Hour), false, labels, func(ctx context.Context) error { return nil }) + job1, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("job-1").ByDuration(time.Hour).Labels(labels).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create job1: %v", err) } - job2, err := s.NewJob(ctx, "job-2", gocron.DurationJob(time.Hour), false, labels, func(ctx context.Context) error { return nil }) + job2, err := s.NewJob( + NewJobBuilder().Ctx(ctx).Name("job-2").ByDuration(time.Hour).Labels(labels).Runner(donothingRunner), + ) if err != nil { t.Fatalf("failed to create job2: %v", err) } From 7f609adce3f306c894e6e48c1b7d4bfd9e0d11ec Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:50:47 +0800 Subject: [PATCH 27/44] =?UTF-8?q?refactor(sheduler):=20=E8=A1=A5=E5=85=85o?= =?UTF-8?q?pts=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 124 ++++++++++++++++++++++++++++++++++--- pkg/scheduler/scheduler.go | 84 +++++++------------------ 2 files changed, 137 insertions(+), 71 deletions(-) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index 42d6bcfde..6826d7292 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -9,14 +9,19 @@ import ( ) type jobBuilder struct { - id uuid.UUID - ctx context.Context - jobName string - cron gocron.JobDefinition - disabled bool - labels JobLabels - runner JobRunner - params []any + id uuid.UUID + ctx context.Context + jobName string + cron gocron.JobDefinition + disabled bool + labels JobLabels + runner JobRunner + params []any + afterJobRuns []func(jobID uuid.UUID, jobName string) + afterJobRunsWithErrors []func(jobID uuid.UUID, jobName string, runErr error) + afterJobRunsWithPanics []func(jobID uuid.UUID, jobName string, panicData any) + beforeJobRuns []func(jobID uuid.UUID, jobName string) + beforeJobRunsSkipIfBeforeFuncErrors []func(jobID uuid.UUID, jobName string) error } // NewJobBuilder create a jobBuilder @@ -161,6 +166,109 @@ func (jb *jobBuilder) Runner(runner JobRunner, params ...any) *jobBuilder { return jb } +// AfterJobRuns sets functions to be called after the job runs. +func (jb *jobBuilder) AfterJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) *jobBuilder { + jb.afterJobRuns = append(jb.afterJobRuns, eventListenerFunc) + return jb +} + +// AfterJobRunsWithError is used to listen for when a job has run and returned an error, and then run the provided function. +func (jb *jobBuilder) AfterJobRunsWithError(eventListenerFunc func(jobID uuid.UUID, jobName string, runErr error)) *jobBuilder { + jb.afterJobRunsWithErrors = append(jb.afterJobRunsWithErrors, eventListenerFunc) + return jb +} + +// AfterJobRunsWithPanic is used to listen for when a job has run and returned panicked recover data, and then run the provided function. +func (jb *jobBuilder) AfterJobRunsWithPanic(eventListenerFunc func(jobID uuid.UUID, jobName string, panicData any)) *jobBuilder { + jb.afterJobRunsWithPanics = append(jb.afterJobRunsWithPanics, eventListenerFunc) + return jb +} + +// BeforeJobRuns sets functions to be called before the job runs. +func (jb *jobBuilder) BeforeJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) *jobBuilder { + jb.beforeJobRuns = append(jb.beforeJobRuns, eventListenerFunc) + return jb +} + +// BeforeJobRunsSkipIfBeforeFuncErrors sets functions to be called before the job runs. +// If any of these functions return an error, the job run will be skipped. +func (jb *jobBuilder) BeforeJobRunsSkipIfBeforeFuncErrors(eventListenerFunc func(jobID uuid.UUID, jobName string) error) *jobBuilder { + jb.beforeJobRunsSkipIfBeforeFuncErrors = append(jb.beforeJobRunsSkipIfBeforeFuncErrors, eventListenerFunc) + return jb +} + +func (jb *jobBuilder) _internalGetOrCreateID() uuid.UUID { + if jb.id == uuid.Nil { + jb.id = uuid.New() + } + return jb.id +} + +func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { + tags := jobLabels2Tags(jb.labels) + opts := []gocron.JobOption{ + gocron.WithIdentifier(jb._internalGetOrCreateID()), + gocron.WithContext(jb.ctx), + gocron.WithName(jb.jobName), + gocron.WithTags(tags...), + } + + if jb.afterJobRuns != nil { + opts = append(opts, gocron.WithEventListeners( + gocron.AfterJobRuns( + func(jobID uuid.UUID, jobName string) { + for _, e := range jb.afterJobRuns { + e(jobID, jobName) + } + }), + )) + } + if jb.afterJobRunsWithErrors != nil { + opts = append(opts, gocron.WithEventListeners( + gocron.AfterJobRunsWithError( + func(jobID uuid.UUID, jobName string, runErr error) { + for _, e := range jb.afterJobRunsWithErrors { + e(jobID, jobName, runErr) + } + }), + )) + } + if jb.afterJobRunsWithPanics != nil { + opts = append(opts, gocron.WithEventListeners( + gocron.AfterJobRunsWithPanic( + func(jobID uuid.UUID, jobName string, panicData any) { + for _, e := range jb.afterJobRunsWithPanics { + e(jobID, jobName, panicData) + } + }), + )) + } + if jb.beforeJobRuns != nil { + opts = append(opts, gocron.WithEventListeners( + gocron.BeforeJobRuns( + func(jobID uuid.UUID, jobName string) { + for _, e := range jb.beforeJobRuns { + e(jobID, jobName) + } + }), + )) + } + if jb.beforeJobRunsSkipIfBeforeFuncErrors != nil { + opts = append(opts, gocron.WithEventListeners( + gocron.BeforeJobRunsSkipIfBeforeFuncErrors( + func(jobID uuid.UUID, jobName string) error { + for _, e := range jb.beforeJobRunsSkipIfBeforeFuncErrors { + if err := e(jobID, jobName); err != nil { + return err + } + } + return nil + }), + )) + } + return opts +} + func newAtTimes(atTimes []AtTime) gocron.AtTimes { if len(atTimes) == 1 { at := gocron.NewAtTime(atTimes[0].hours, atTimes[0].minutes, atTimes[0].seconds) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 2db7af3f3..ddc93293f 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -103,102 +103,60 @@ func (o *OpScheduler) buildJobParams( // NewJobByBuilder creates and shedules a new job by builder func (o *OpScheduler) NewJob(jb *jobBuilder) (*OpJob, error) { - return o._internalNewJob( - jb.ctx, - jb.jobName, - jb.cron, - jb.disabled, - jb.labels, - jb.runner, - jb.params..., - ) -} - -// UpdateJob updates an existing job by its UUID using a job builder. -func (o *OpScheduler) UpdateJob( - jobUUID uuid.UUID, - jb *jobBuilder, -) error { - return o._internalUpdateJob( - jobUUID, - jb.ctx, - jb.jobName, - jb.cron, - jb.disabled, - jb.labels, - jb.runner, - jb.params..., - ) -} - -// _internalNewJob creates and schedules a new job. -func (o *OpScheduler) _internalNewJob( - ctx context.Context, - jobName string, - cron gocron.JobDefinition, - disabled bool, - labels JobLabels, - runner JobRunner, - params ...any) (*OpJob, error) { - if runner == nil { + if jb.runner == nil { return nil, errors.New("runner is nil") } - if jobName == "" { + if jb.jobName == "" { return nil, errors.New("jobName is empty") } - jobUUID := uuid.New() - tags := jobLabels2Tags(labels) - task, err := o.buildJobParams(jobUUID, runner, params) + opts := jb._internalGetOptions() + var jobUUID uuid.UUID = jb._internalGetOrCreateID() + task, err := o.buildJobParams(jobUUID, jb.runner, jb.params) if err != nil { return nil, err } job, err := o.scheduler.NewJob( - cron, + jb.cron, task, - gocron.WithIdentifier(jobUUID), - gocron.WithContext(ctx), - gocron.WithName(jobName), - gocron.WithTags(tags...), + opts..., ) if err != nil { return nil, err } o.jobsMap.Set(jobUUID, job) - if disabled { + if jb.disabled { o.jobDisabledMap.Set(jobUUID, true) } - return newOpJob(job, disabled), nil + return newOpJob(job, jb.disabled), nil } -// _internalUpdateJob updates an existing job by its UUID. -func (o *OpScheduler) _internalUpdateJob( +// UpdateJob updates an existing job by its UUID using a job builder. +func (o *OpScheduler) UpdateJob( jobUUID uuid.UUID, - ctx context.Context, - jobName string, - cron gocron.JobDefinition, - disabled bool, - labels JobLabels, - runner JobRunner, params ...any) error { + jb *jobBuilder, +) error { // Stop and remove the existing job if exists := o.Exists(jobUUID); !exists { return errors.New("job not found: " + jobUUID.String()) } - task, err := o.buildJobParams(jobUUID, runner, params) + task, err := o.buildJobParams(jobUUID, jb.runner, jb.params) if err != nil { return err } - tags := jobLabels2Tags(labels) + // update the ID of jobBuilder to ensure consistency + jb.ID(jobUUID) + opts := jb._internalGetOptions() job, err := o.scheduler.Update( - jobUUID, cron, task, - gocron.WithContext(ctx), gocron.WithName(jobName), gocron.WithTags(tags...), + jobUUID, jb.cron, task, + opts..., ) if err != nil { return err } // Save job o.jobsMap.Set(jobUUID, job) - // Set disabled status - if disabled { + // Update disabled status + if jb.disabled { o.jobDisabledMap.Set(jobUUID, true) } else { o.jobDisabledMap.Delete(jobUUID) From f5e8f27109a42ad491630aa07152f9f535510e7e Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:18:09 +0800 Subject: [PATCH 28/44] =?UTF-8?q?refactor(sheduler):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=89=A7=E8=A1=8C=EF=BC=8C=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 6 ++-- pkg/scheduler/scheduler_test.go | 58 ++++++--------------------------- 2 files changed, 13 insertions(+), 51 deletions(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index ddc93293f..f4e5d209f 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -40,13 +40,13 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, }, nil } -// RunNow runs a job immediately by its UUID. -func (o *OpScheduler) RunNow(jobUUID uuid.UUID, force bool) error { +// RunNow runs a job immediately by its UUID if the job is enabled. +func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { job, exists := o._internalGetCronJob(jobUUID) if !exists { return errors.New("job not found: " + jobUUID.String()) } - if !force && o.jobIsDisabled(jobUUID) { + if o.jobIsDisabled(jobUUID) { // job is disabled, do not run return nil } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 0e5f4748c..0693ef535 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -9,10 +9,10 @@ import ( ) const ( - fastInterval = 50 * time.Millisecond fasterInterval = 20 * time.Millisecond - defaultTimeout = 2 * time.Second + fastInterval = 50 * time.Millisecond shortWait = 300 * time.Millisecond + defaultTimeout = 10 * time.Second ) var donothingRunner = func(ctx context.Context) error { return nil } @@ -161,6 +161,7 @@ func TestDisabledJob(t *testing.T) { NewJobBuilder().Ctx(ctx). Name("test-job"). ByDuration(fastInterval). + Disabled(true). Labels(labels). Runner(runner), ) @@ -174,7 +175,7 @@ func TestDisabledJob(t *testing.T) { } // runNow t.Log("attempt to run disabled job immediately") - err = s.RunNow(afterCreated.ID(), false) + err = s.RunNow(afterCreated.ID()) if err != nil { t.Fatalf("failed to run disabled job now: %v", err) } @@ -331,7 +332,7 @@ func TestDisableJobMethod(t *testing.T) { if !ok || !updated.Disabled() { t.Fatalf("expected job disabled") } - if err := s.RunNow(job.ID(), false); err != nil { + if err := s.RunNow(job.ID()); err != nil { t.Fatalf("run now failed for %s: %v", job.ID(), err) } select { @@ -341,47 +342,6 @@ func TestDisableJobMethod(t *testing.T) { } } -// TestRunNowForceExecutesJob ensures RunNow(true) triggers execution even on demand. -func TestRunNowForceExecutesJob(t *testing.T) { - s, err := NewOpScheduler("test-run-now-force", gocron.WithLocation(time.Local)) - if err != nil { - t.Fatalf("failed to create scheduler: %v", err) - } - s.Start() - defer s.Close() - labels := JobLabels{"env": "test"} - executed := make(chan bool, 1) - ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) - defer cancel() - job, err := s.NewJob( - NewJobBuilder(). - Ctx(ctx). - Name("force-run-job"). - ByDuration(time.Hour). - Labels(labels). - Disabled(true). - Runner(func(ctx context.Context) error { - executed <- true - return nil - }), - ) - if err != nil { - t.Fatalf("failed to create job: %v", err) - } - if _, ok := s.GetJob(job.ID()); !ok { - t.Fatalf("force-run job not found after creation") - } - if err := s.RunNow(job.ID(), true); err != nil { - t.Fatalf("force run failed for %s: %v", job.ID(), err) - } - select { - case <-executed: - return - case <-time.After(defaultTimeout): - t.Fatalf("force run did not execute") - } -} - // TestUpdateJobLabelsAndEnable ensures UpdateJob toggles disabled->enabled and updates labels. func TestUpdateJobLabelsAndEnable(t *testing.T) { s, err := NewOpScheduler("test-update-toggle", gocron.WithLocation(time.Local)) @@ -447,6 +407,7 @@ func TestRemoveJobsLeavesOthers(t *testing.T) { defer s.Close() ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() + // channels to track which jobs ran keepRan := make(chan bool, 1) removeRan := make(chan bool, 1) jobRemove, err := s.NewJob( @@ -477,8 +438,9 @@ func TestRemoveJobsLeavesOthers(t *testing.T) { t.Fatalf("remove jobs failed for %s: %v", jobRemove.ID(), err) } // reset channels - removeRan <- false - keepRan <- false + removeRan = make(chan bool, 1) + keepRan = make(chan bool, 1) + // verify removed job does not exist and kept job does if _, ok := s.GetJob(jobRemove.ID()); ok { t.Fatalf("removed job still exists: %s", jobRemove.ID()) } @@ -629,7 +591,7 @@ func TestRemoveAllJobs(t *testing.T) { if got := s.GetJobsByLabels(JobLabels{"env": "test"}); len(got) != 0 { t.Fatalf("expected no jobs after remove all, got %d", len(got)) } - if err := s.RunNow(job1.ID(), false); err == nil { + if err := s.RunNow(job1.ID()); err == nil { t.Fatalf("expected error running removed job: %s", job1.ID()) } } From ee834f34f6072b1d8d98cdbc6c2a92954632119e Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:38:08 +0800 Subject: [PATCH 29/44] =?UTF-8?q?feat(sheduler):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler_test.go | 206 ++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 0693ef535..17a01a7b0 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -2,10 +2,12 @@ package scheduler import ( "context" + "errors" "testing" "time" "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" ) const ( @@ -595,3 +597,207 @@ func TestRemoveAllJobs(t *testing.T) { t.Fatalf("expected error running removed job: %s", job1.ID()) } } + +// TestBeforeJobRuns is a placeholder for future tests of pre-run hooks. +func TestBeforeJobRuns(t *testing.T) { + s, err := NewOpScheduler("test-before-job-runs", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + beforeRunChan := make(chan bool, 1) + jb := NewJobBuilder(). + Ctx(context.Background()). + Name("before-job"). + ByDuration(fastInterval). + Runner(donothingRunner). + BeforeJobRuns(func(jobID uuid.UUID, jobName string) { + beforeRunChan <- true + }) + _, err = s.NewJob(jb) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + select { + case <-beforeRunChan: + t.Logf("success") + case <-time.After(defaultTimeout): + t.Fatalf("before job run hook was not called within expected time") + } +} + +// TestBeforeJobRunsSkipIfBeforeFuncErrorsSkip +func TestBeforeJobRunsSkipIfBeforeFuncErrorsSkip(t *testing.T) { + s, err := NewOpScheduler("test-before-job-runs-error", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + beforeRunChan := make(chan bool, 1) + executeChan := make(chan bool, 1) + jb := NewJobBuilder(). + Ctx(context.Background()). + Name("before-job-error"). + ByDuration(fastInterval). + Runner(func(ctx context.Context) error { + executeChan <- true + return nil + }). + BeforeJobRunsSkipIfBeforeFuncErrors(func(jobID uuid.UUID, jobName string) error { + beforeRunChan <- true + return errors.New("skip execution") + }) + _, err = s.NewJob(jb) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + select { + case <-beforeRunChan: + t.Logf("before run hook called") + case <-time.After(defaultTimeout): + t.Fatalf("before job run hook was not called within expected time") + } + select { + case <-executeChan: + t.Fatalf("job execution should have been skipped due to before hook error") + case <-time.After(shortWait): + t.Logf("job execution correctly skipped") + } +} + +// TestBeforeJobRunsSkipIfBeforeFuncErrorsNotSkip +func TestBeforeJobRunsSkipIfBeforeFuncErrorsNotSkip(t *testing.T) { + s, err := NewOpScheduler("test-before-job-runs-error", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + beforeRunChan := make(chan bool, 1) + executeChan := make(chan bool, 1) + jb := NewJobBuilder(). + Ctx(context.Background()). + Name("before-job-error"). + ByDuration(fastInterval). + Runner(func(ctx context.Context) error { + executeChan <- true + return nil + }). + BeforeJobRunsSkipIfBeforeFuncErrors(func(jobID uuid.UUID, jobName string) error { + beforeRunChan <- true + return nil + }) + _, err = s.NewJob(jb) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + select { + case <-beforeRunChan: + t.Logf("before run hook called") + case <-time.After(defaultTimeout): + t.Fatalf("before job run hook was not called within expected time") + } + select { + case <-executeChan: + t.Logf("job executed successfully") + case <-time.After(defaultTimeout): + t.Fatalf("job execution did not occur as expected") + } +} + +// TestAfterJobRuns is a placeholder for future tests of post-run hooks. +func TestAfterJobRuns(t *testing.T) { + s, err := NewOpScheduler("test-after-job-runs", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + afterRunChan := make(chan bool, 1) + jb := NewJobBuilder(). + Ctx(context.Background()). + Name("after-job"). + ByDuration(fastInterval). + Runner(donothingRunner). + AfterJobRuns(func(jobID uuid.UUID, jobName string) { + afterRunChan <- true + }) + _, err = s.NewJob(jb) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + select { + case <-afterRunChan: + t.Logf("success") + case <-time.After(defaultTimeout): + t.Fatalf("after job run hook was not called within expected time") + } +} + +// TestAfterJobRunsWithError is a placeholder for future tests of post-run hooks with error. +func TestAfterJobRunsWithError(t *testing.T) { + s, err := NewOpScheduler("test-after-job-runs-error", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + afterRunChan := make(chan bool, 1) + jb := NewJobBuilder(). + Ctx(context.Background()). + Name("after-job-error"). + ByDuration(fastInterval). + Runner(func(ctx context.Context) error { + return errors.New("intentional error") + }). + AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, runErr error) { + if runErr != nil && runErr.Error() == "intentional error" { + afterRunChan <- true + } + }) + _, err = s.NewJob(jb) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + select { + case <-afterRunChan: + t.Logf("success") + case <-time.After(defaultTimeout): + t.Fatalf("after job run with error hook was not called within expected time") + } +} + +// AfterJobRunsWithPanic is a placeholder for future tests of post-run hooks with panic. +func TestAfterJobRunsWithPanic(t *testing.T) { + s, err := NewOpScheduler("test-after-job-runs-panic", gocron.WithLocation(time.Local)) + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + s.Start() + defer s.Close() + afterRunChan := make(chan bool, 1) + jb := NewJobBuilder(). + Ctx(context.Background()). + Name("after-job-panic"). + ByDuration(fastInterval). + Runner(func(ctx context.Context) error { + panic("intentional panic") + }). + AfterJobRunsWithPanic(func(jobID uuid.UUID, jobName string, panicErr interface{}) { + if panicErr != nil && panicErr == "intentional panic" { + afterRunChan <- true + } + }) + _, err = s.NewJob(jb) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } + select { + case <-afterRunChan: + t.Logf("success") + case <-time.After(defaultTimeout): + t.Fatalf("after job run with panic hook was not called within expected time") + } +} From 4645e3480787646792a1dc6dbd01c5dd19c4c8b4 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:04:05 +0800 Subject: [PATCH 30/44] =?UTF-8?q?fix(sheduler):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E5=AD=98=E5=9C=A8=E7=9A=84panic=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E4=BC=98=E5=8C=96opts=E8=8E=B7=E5=8F=96=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 27 +++++++++++++++++++++------ pkg/scheduler/scheduler.go | 5 ++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index 6826d7292..b5c23a33b 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -206,13 +206,19 @@ func (jb *jobBuilder) _internalGetOrCreateID() uuid.UUID { func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { tags := jobLabels2Tags(jb.labels) - opts := []gocron.JobOption{ - gocron.WithIdentifier(jb._internalGetOrCreateID()), - gocron.WithContext(jb.ctx), - gocron.WithName(jb.jobName), - gocron.WithTags(tags...), + opts := []gocron.JobOption{} + if jb.id != uuid.Nil { + opts = append(opts, gocron.WithIdentifier(jb.id)) + } + if jb.ctx != nil { + opts = append(opts, gocron.WithContext(jb.ctx)) + } + if jb.jobName != "" { + opts = append(opts, gocron.WithName(jb.jobName)) + } + if len(tags) > 0 { + opts = append(opts, gocron.WithTags(tags...)) } - if jb.afterJobRuns != nil { opts = append(opts, gocron.WithEventListeners( gocron.AfterJobRuns( @@ -270,6 +276,9 @@ func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { } func newAtTimes(atTimes []AtTime) gocron.AtTimes { + if len(atTimes) == 0 { + return nil + } if len(atTimes) == 1 { at := gocron.NewAtTime(atTimes[0].hours, atTimes[0].minutes, atTimes[0].seconds) return gocron.NewAtTimes(at) @@ -284,6 +293,9 @@ func newAtTimes(atTimes []AtTime) gocron.AtTimes { ) } func newWeekdays(weekdays []time.Weekday) gocron.Weekdays { + if len(weekdays) == 0 { + return nil + } if len(weekdays) == 1 { return gocron.NewWeekdays(weekdays[0]) } @@ -291,6 +303,9 @@ func newWeekdays(weekdays []time.Weekday) gocron.Weekdays { } func newDaysOfTheMonth(days []int) gocron.DaysOfTheMonth { + if len(days) == 0 { + return nil + } if len(days) == 1 { return gocron.NewDaysOfTheMonth(days[0]) } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index f4e5d209f..d36dad39a 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -69,6 +69,9 @@ func (o *OpScheduler) buildJobParams( if f.IsZero() { return nil, errors.New("runner is nil") } + if f.Kind() != reflect.Func { + return nil, errors.New("runner must be a function") + } if len(params)+1 != f.Type().NumIn() { return nil, errors.New("number of params does not match runner function signature (expected N params plus context parameter)") } @@ -101,7 +104,7 @@ func (o *OpScheduler) buildJobParams( return task, nil } -// NewJobByBuilder creates and shedules a new job by builder +// NewJobByBuilder creates and schedules a new job by builder func (o *OpScheduler) NewJob(jb *jobBuilder) (*OpJob, error) { if jb.runner == nil { return nil, errors.New("runner is nil") From f3cfdcdbe54ae5c2408b247c422e437f1d3ff780 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:05:44 +0800 Subject: [PATCH 31/44] =?UTF-8?q?fix(sheduler):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E5=AD=98=E5=9C=A8=E7=9A=84panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 016f25a41..ff42df552 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -1,6 +1,7 @@ package scheduler import ( + "errors" "maps" "sync" "time" @@ -135,6 +136,9 @@ func (o *OpJob) NextRun() (time.Time, error) { if o.nextRunErr != nil { return time.Time{}, o.nextRunErr } + if len(o.nextTenRuns) == 0 { + return time.Time{}, errors.New("no next run scheduled,maybe the scheduler is not started or has been stopped") + } return o.nextTenRuns[0], nil } From 498ca261afccb79b5f8755eb9e351b6acadd137e Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:08:25 +0800 Subject: [PATCH 32/44] =?UTF-8?q?fix(sheduler):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E5=AD=98=E5=9C=A8=E7=9A=84panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 23 ++++++++++++----------- pkg/scheduler/meta.go | 2 +- pkg/scheduler/scheduler_test.go | 1 - 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index b5c23a33b..5a79fa718 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -50,8 +50,9 @@ func (jb *jobBuilder) Name(name string) *jobBuilder { return jb } -// rawCron sets the job cron definition. -func (jb *jobBuilder) rawCron(cron gocron.JobDefinition) *jobBuilder { +// _internalCron sets the job cron definition. +// This is an internal method; prefer using the By... methods. +func (jb *jobBuilder) _internalCron(cron gocron.JobDefinition) *jobBuilder { jb.cron = cron return jb } @@ -63,13 +64,13 @@ func (jb *jobBuilder) rawCron(cron gocron.JobDefinition) *jobBuilder { // crontab in the form `TZ=America/Chicago * * * * *` or // `CRON_TZ=America/Chicago * * * * *` func (jb *jobBuilder) ByCrontab(crontab string, withSeconds bool) *jobBuilder { - return jb.rawCron(gocron.CronJob(crontab, withSeconds)) + return jb._internalCron(gocron.CronJob(crontab, withSeconds)) } // ByDuration defines a new job using time.Duration // for the interval. func (jb *jobBuilder) ByDuration(d time.Duration) *jobBuilder { - return jb.rawCron(gocron.DurationJob(d)) + return jb._internalCron(gocron.DurationJob(d)) } // ByDurationRandomJob defines a new job that runs on a random interval @@ -83,17 +84,17 @@ func (jb *jobBuilder) ByDuration(d time.Duration) *jobBuilder { // up to 1 min of jitter to the interval, you could use // ByDurationRandomJob(4*time.Minute, 6*time.Minute) func (jb *jobBuilder) ByDurationRandomJob(min, max time.Duration) *jobBuilder { - return jb.rawCron(gocron.DurationRandomJob(min, max)) + return jb._internalCron(gocron.DurationRandomJob(min, max)) } // ByDaily defines a new job that runs daily at the specified time. func (jb *jobBuilder) ByDaily(interval uint, atTimes AtTimes) *jobBuilder { - return jb.rawCron(gocron.DailyJob(interval, newAtTimes(atTimes))) + return jb._internalCron(gocron.DailyJob(interval, newAtTimes(atTimes))) } // ByWeekly defines a new job that runs weekly at the specified time. func (jb *jobBuilder) ByWeekly(interval uint, weekdays []time.Weekday, atTimes AtTimes) *jobBuilder { - return jb.rawCron(gocron.WeeklyJob(interval, newWeekdays(weekdays), newAtTimes(atTimes))) + return jb._internalCron(gocron.WeeklyJob(interval, newWeekdays(weekdays), newAtTimes(atTimes))) } // ByMonthly runs the job on the interval of months, on the specific days of the month @@ -113,7 +114,7 @@ func (jb *jobBuilder) ByWeekly(interval uint, weekdays []time.Weekday, atTimes A // - For example: an interval of 2 months on the 31st of each month, starting 12/31 // would skip Feb, April, June, and next run would be in August. func (jb *jobBuilder) ByMonthly(interval uint, daysOfTheMonth []int, atTimes AtTimes) *jobBuilder { - return jb.rawCron(gocron.MonthlyJob( + return jb._internalCron(gocron.MonthlyJob( interval, newDaysOfTheMonth(daysOfTheMonth), newAtTimes(atTimes))) @@ -121,19 +122,19 @@ func (jb *jobBuilder) ByMonthly(interval uint, daysOfTheMonth []int, atTimes AtT // ByOneTimeJobStartImmediately tells the scheduler to run the one time job immediately. func (jb *jobBuilder) ByOneTimeJobStartImmediately() *jobBuilder { - return jb.rawCron(gocron.OneTimeJob(gocron.OneTimeJobStartImmediately())) + return jb._internalCron(gocron.OneTimeJob(gocron.OneTimeJobStartImmediately())) } // ByOneTimeJobStartDateTime sets the date & time at which the job should run. // This datetime must be in the future (according to the scheduler clock). func (jb *jobBuilder) ByOneTimeJobStartDateTime(start time.Time) *jobBuilder { - return jb.rawCron(gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start))) + return jb._internalCron(gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start))) } // ByOneTimeJobStartDateTimes sets the date & times at which the job should run. // At least one of the date/times must be in the future (according to the scheduler clock). func (jb *jobBuilder) ByOneTimeJobStartDateTimes(times ...time.Time) *jobBuilder { - return jb.rawCron(gocron.OneTimeJob(gocron.OneTimeJobStartDateTimes(times...))) + return jb._internalCron(gocron.OneTimeJob(gocron.OneTimeJobStartDateTimes(times...))) } // Disabled sets the job disabled status. diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index ff42df552..8ad50b0c9 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -170,5 +170,5 @@ type AtTime struct { hours, minutes, seconds uint } -// AtTimes define a list of AtTime +// AtTimes defines a list of AtTime type AtTimes []AtTime diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 17a01a7b0..c263494f7 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -11,7 +11,6 @@ import ( ) const ( - fasterInterval = 20 * time.Millisecond fastInterval = 50 * time.Millisecond shortWait = 300 * time.Millisecond defaultTimeout = 10 * time.Second From 46e52162cc2e68f9d2aaf8296570809c85ac6b73 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:38:56 +0800 Subject: [PATCH 33/44] =?UTF-8?q?fix(sheduler):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E5=AD=98=E5=9C=A8=E7=9A=84panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 82 ++++++++++++++++----------------- pkg/scheduler/meta.go | 11 ++++- pkg/scheduler/scheduler.go | 24 +++++----- pkg/scheduler/scheduler_test.go | 5 +- 4 files changed, 65 insertions(+), 57 deletions(-) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index 5a79fa718..f43af7248 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -220,58 +220,55 @@ func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { if len(tags) > 0 { opts = append(opts, gocron.WithTags(tags...)) } + listens := []gocron.EventListener{} if jb.afterJobRuns != nil { - opts = append(opts, gocron.WithEventListeners( - gocron.AfterJobRuns( - func(jobID uuid.UUID, jobName string) { - for _, e := range jb.afterJobRuns { - e(jobID, jobName) - } - }), - )) + listens = append(listens, gocron.AfterJobRuns( + func(jobID uuid.UUID, jobName string) { + for _, e := range jb.afterJobRuns { + e(jobID, jobName) + } + })) } if jb.afterJobRunsWithErrors != nil { - opts = append(opts, gocron.WithEventListeners( - gocron.AfterJobRunsWithError( - func(jobID uuid.UUID, jobName string, runErr error) { - for _, e := range jb.afterJobRunsWithErrors { - e(jobID, jobName, runErr) - } - }), - )) + listens = append(listens, gocron.AfterJobRunsWithError( + func(jobID uuid.UUID, jobName string, runErr error) { + for _, e := range jb.afterJobRunsWithErrors { + e(jobID, jobName, runErr) + } + }), + ) } if jb.afterJobRunsWithPanics != nil { - opts = append(opts, gocron.WithEventListeners( - gocron.AfterJobRunsWithPanic( - func(jobID uuid.UUID, jobName string, panicData any) { - for _, e := range jb.afterJobRunsWithPanics { - e(jobID, jobName, panicData) - } - }), - )) + listens = append(listens, gocron.AfterJobRunsWithPanic( + func(jobID uuid.UUID, jobName string, panicData any) { + for _, e := range jb.afterJobRunsWithPanics { + e(jobID, jobName, panicData) + } + }), + ) } if jb.beforeJobRuns != nil { - opts = append(opts, gocron.WithEventListeners( - gocron.BeforeJobRuns( - func(jobID uuid.UUID, jobName string) { - for _, e := range jb.beforeJobRuns { - e(jobID, jobName) - } - }), - )) + listens = append(listens, gocron.BeforeJobRuns( + func(jobID uuid.UUID, jobName string) { + for _, e := range jb.beforeJobRuns { + e(jobID, jobName) + } + })) } if jb.beforeJobRunsSkipIfBeforeFuncErrors != nil { - opts = append(opts, gocron.WithEventListeners( - gocron.BeforeJobRunsSkipIfBeforeFuncErrors( - func(jobID uuid.UUID, jobName string) error { - for _, e := range jb.beforeJobRunsSkipIfBeforeFuncErrors { - if err := e(jobID, jobName); err != nil { - return err - } + listens = append(listens, gocron.BeforeJobRunsSkipIfBeforeFuncErrors( + func(jobID uuid.UUID, jobName string) error { + for _, e := range jb.beforeJobRunsSkipIfBeforeFuncErrors { + if err := e(jobID, jobName); err != nil { + return err } - return nil - }), - )) + } + return nil + }), + ) + } + if len(listens) > 0 { + opts = append(opts, gocron.WithEventListeners(listens...)) } return opts } @@ -293,6 +290,7 @@ func newAtTimes(atTimes []AtTime) gocron.AtTimes { gocronAtTimes..., ) } + func newWeekdays(weekdays []time.Weekday) gocron.Weekdays { if len(weekdays) == 0 { return nil diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 8ad50b0c9..835269c14 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -23,7 +23,7 @@ import ( // adhere to this function shape. type JobRunner any -// JobLabels the type for job labels +// JobLabels is the type for job labels type JobLabels = map[string]string // safeMap is a thread-safe map implementation @@ -170,5 +170,14 @@ type AtTime struct { hours, minutes, seconds uint } +// NewAtTime constructs a new AtTime instance. +func NewAtTime(hours, minutes, seconds uint) AtTime { + return AtTime{ + hours: hours, + minutes: minutes, + seconds: seconds, + } +} + // AtTimes defines a list of AtTime type AtTimes []AtTime diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index d36dad39a..58803a050 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -1,3 +1,4 @@ +// Package scheduler provides a job scheduling system using gocron. package scheduler import ( @@ -106,6 +107,9 @@ func (o *OpScheduler) buildJobParams( // NewJobByBuilder creates and schedules a new job by builder func (o *OpScheduler) NewJob(jb *jobBuilder) (*OpJob, error) { + if jb.ctx == nil { + jb.Ctx(context.Background()) + } if jb.runner == nil { return nil, errors.New("runner is nil") } @@ -242,9 +246,6 @@ func (o *OpScheduler) filterLabels( labels JobLabels, action func(gocron.Job, JobLabels), ) { - if len(o.jobsMap.data) == 0 { - return - } o.jobsMap.ForEach(func(_ uuid.UUID, job gocron.Job) { jobLabels := tags2JobLabels(job.Tags()) matches := true @@ -301,18 +302,15 @@ func (o *OpScheduler) StopAllJobs() error { // RemoveAllJobs removes all jobs from the scheduler. func (o *OpScheduler) RemoveAllJobs() error { var errs []error + // First, stop all running jobs. if err := o.scheduler.StopJobs(); err != nil { errs = append(errs, err) } - for _, job := range o.scheduler.Jobs() { - if err := o.scheduler.RemoveJob(job.ID()); err != nil { - errs = append(errs, err) - } - } - o.jobDisabledMap.Clear() - o.jobsMap.Clear() - if len(errs) > 0 { - return errors.Join(errs...) + // Only clear the internal maps if the scheduler successfully removed all jobs. + if len(errs) == 0 { + o.jobDisabledMap.Clear() + o.jobsMap.Clear() + return nil } - return nil + return errors.Join(errs...) } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index c263494f7..1adb31530 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -43,6 +43,9 @@ func TestGoCron(t *testing.T) { ), gocron.WithContext(ctx), ) + if err != nil { + t.Fatalf("failed to create job: %v", err) + } t.Logf("job ID: %d", job.ID()) err = job.RunNow() if err != nil { @@ -492,7 +495,7 @@ func TestRemoveJobByLabels(t *testing.T) { t.Fatalf("failed to create dev-2: %v", err) } prod, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("dev-2").ByDuration(time.Hour).Labels(labelsProd).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("prod-1").ByDuration(time.Hour).Labels(labelsProd).Runner(donothingRunner), ) if err != nil { t.Fatalf("failed to create prod: %v", err) From 4fe1b5e4c55b3eb9a05c02a076c8f76360e8ddf4 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:43:53 +0800 Subject: [PATCH 34/44] =?UTF-8?q?doc(sheduler):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=E6=B3=A8=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 835269c14..92395dafb 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -23,7 +23,7 @@ import ( // adhere to this function shape. type JobRunner any -// JobLabels is the type for job labels +// JobLabels is the type for job labels. type JobLabels = map[string]string // safeMap is a thread-safe map implementation From 8a946aa29299b5534a1aad2ca99f9817700f3b18 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:06:31 +0800 Subject: [PATCH 35/44] =?UTF-8?q?fix(sheduler):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E5=AD=98=E5=9C=A8=E7=9A=84=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 10 +++++----- pkg/scheduler/meta.go | 2 +- pkg/scheduler/scheduler.go | 21 ++++++++++++++++++--- pkg/scheduler/scheduler_test.go | 2 -- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index f43af7248..929b8bd9c 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -221,7 +221,7 @@ func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { opts = append(opts, gocron.WithTags(tags...)) } listens := []gocron.EventListener{} - if jb.afterJobRuns != nil { + if len(jb.afterJobRuns) > 0 { listens = append(listens, gocron.AfterJobRuns( func(jobID uuid.UUID, jobName string) { for _, e := range jb.afterJobRuns { @@ -229,7 +229,7 @@ func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { } })) } - if jb.afterJobRunsWithErrors != nil { + if len(jb.afterJobRunsWithErrors) > 0 { listens = append(listens, gocron.AfterJobRunsWithError( func(jobID uuid.UUID, jobName string, runErr error) { for _, e := range jb.afterJobRunsWithErrors { @@ -238,7 +238,7 @@ func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { }), ) } - if jb.afterJobRunsWithPanics != nil { + if len(jb.afterJobRunsWithPanics) > 0 { listens = append(listens, gocron.AfterJobRunsWithPanic( func(jobID uuid.UUID, jobName string, panicData any) { for _, e := range jb.afterJobRunsWithPanics { @@ -247,7 +247,7 @@ func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { }), ) } - if jb.beforeJobRuns != nil { + if len(jb.beforeJobRuns) > 0 { listens = append(listens, gocron.BeforeJobRuns( func(jobID uuid.UUID, jobName string) { for _, e := range jb.beforeJobRuns { @@ -255,7 +255,7 @@ func (jb *jobBuilder) _internalGetOptions() []gocron.JobOption { } })) } - if jb.beforeJobRunsSkipIfBeforeFuncErrors != nil { + if len(jb.beforeJobRunsSkipIfBeforeFuncErrors) > 0 { listens = append(listens, gocron.BeforeJobRunsSkipIfBeforeFuncErrors( func(jobID uuid.UUID, jobName string) error { for _, e := range jb.beforeJobRunsSkipIfBeforeFuncErrors { diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 92395dafb..3bccb9319 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -137,7 +137,7 @@ func (o *OpJob) NextRun() (time.Time, error) { return time.Time{}, o.nextRunErr } if len(o.nextTenRuns) == 0 { - return time.Time{}, errors.New("no next run scheduled,maybe the scheduler is not started or has been stopped") + return time.Time{}, errors.New("no next run scheduled, maybe the scheduler is not started or has been stopped") } return o.nextTenRuns[0], nil } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 58803a050..dbdcc0a43 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -219,12 +219,13 @@ func (o *OpScheduler) DisableJob(jobUUID uuid.UUID) error { } // RemoveJobs removes jobs by their UUIDs. -func (o *OpScheduler) RemoveJobs(jobUUIDs ...uuid.UUID) error { - if len(jobUUIDs) == 0 { +func (o *OpScheduler) RemoveJobs(waitForRemoveJobUUIDs ...uuid.UUID) error { + if len(waitForRemoveJobUUIDs) == 0 { return nil } var errs []error - for _, jobID := range jobUUIDs { + // try to remove jobs one by one + for _, jobID := range waitForRemoveJobUUIDs { err := o.scheduler.RemoveJob(jobID) if err != nil { errs = append(errs, err) @@ -236,6 +237,20 @@ func (o *OpScheduler) RemoveJobs(jobUUIDs ...uuid.UUID) error { o.jobDisabledMap.Delete(jobID) } if len(errs) > 0 { + + existsJobIDs := make(map[uuid.UUID]bool) + for _, item := range o.scheduler.Jobs() { + existsJobIDs[item.ID()] = true + } + // if job removal failed, check if job not exists in scheduler, but still in internal maps + for _, jobID := range waitForRemoveJobUUIDs { + if _, exists := existsJobIDs[jobID]; exists { + continue + } + // if job removal failed, but job not exists in scheduler, remove from internal maps + o.jobsMap.Delete(jobID) + o.jobDisabledMap.Delete(jobID) + } return errors.Join(errs...) } return nil diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 1adb31530..096b330d2 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -249,8 +249,6 @@ func TestEnableJob(t *testing.T) { // TestRemoveJob ensures removing a job deletes it and prevents execution. func TestRemoveJob(t *testing.T) { t.Log("start test remove job") - // create job donothing - t.Log("start test for enable job") s, err := NewOpScheduler("test-scheduler-disabled", gocron.WithLocation(time.Local)) if err != nil { t.Fatalf("failed to create scheduler: %v", err) From 0799c96463bf6f8e9b36c919a447afc59b8eed11 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:54:39 +0800 Subject: [PATCH 36/44] =?UTF-8?q?refactor(scheduler):=20=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E5=AE=89=E5=85=A8map=E4=BD=BF=E7=94=A8=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E7=9A=84struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 65 +++----------------------------------- pkg/scheduler/scheduler.go | 28 ++++++++-------- 2 files changed, 20 insertions(+), 73 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 3bccb9319..502866f6f 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -3,9 +3,9 @@ package scheduler import ( "errors" "maps" - "sync" "time" + "github.com/OpenListTeam/OpenList/v4/pkg/generic_sync" "github.com/go-co-op/gocron/v2" "github.com/google/uuid" ) @@ -26,66 +26,11 @@ type JobRunner any // JobLabels is the type for job labels. type JobLabels = map[string]string -// safeMap is a thread-safe map implementation -type safeMap[K comparable, V any] struct { - lock sync.RWMutex - data map[K]V -} - -func newSafeMap[K comparable, V any]() *safeMap[K, V] { - return &safeMap[K, V]{ - data: make(map[K]V), - } -} - -// Get retrieves a value by key from the safeMap. -func (sm *safeMap[K, V]) Get(key K) (V, bool) { - sm.lock.RLock() - defer sm.lock.RUnlock() - value, exists := sm.data[key] - return value, exists -} - -// Set sets a key-value pair in the safeMap. -func (sm *safeMap[K, V]) Set(key K, value V) { - sm.lock.Lock() - defer sm.lock.Unlock() - sm.data[key] = value -} - -// Delete removes a key-value pair from the safeMap by key. -func (sm *safeMap[K, V]) Delete(key K) { - sm.lock.Lock() - defer sm.lock.Unlock() - delete(sm.data, key) -} - -// GetAll retrieves all key-value pairs from the safeMap. -func (sm *safeMap[K, V]) GetAll() map[K]V { - sm.lock.RLock() - defer sm.lock.RUnlock() - result := make(map[K]V) - maps.Copy(result, sm.data) - return result -} - -// Clear removes all key-value pairs from the safeMap. -func (sm *safeMap[K, V]) Clear() { - sm.lock.Lock() - defer sm.lock.Unlock() - // reinitialize the map to clear all entries - sm.data = make(map[K]V) -} +// // safeMap is a thread-safe map implementation +// type safeMap[K comparable, V any] -// ForEach iterates over all key-value pairs in the safeMap and applies the provided function. -// The callback is executed while holding a read lock; it should be fast and must not call -// methods on the same safeMap that acquire the lock (e.g., Set, Delete, Clear) to avoid deadlocks. -func (sm *safeMap[K, V]) ForEach(fn func(K, V)) { - sm.lock.RLock() - defer sm.lock.RUnlock() - for k, v := range sm.data { - fn(k, v) - } +func newSafeMap[K comparable, V any]() *generic_sync.MapOf[K, V] { + return new(generic_sync.MapOf[K, V]) } // OpJob represents an operational job with its metadata. diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index dbdcc0a43..c69947317 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -6,15 +6,16 @@ import ( "errors" "reflect" + "github.com/OpenListTeam/OpenList/v4/pkg/generic_sync" "github.com/go-co-op/gocron/v2" "github.com/google/uuid" ) // jobsMapType is a thread-safe map for storing jobs. -type jobsMapType = *safeMap[uuid.UUID, gocron.Job] +type jobsMapType = *generic_sync.MapOf[uuid.UUID, gocron.Job] // jobDisabledMapType is a thread-safe map for storing boolean values. -type jobDisabledMapType = *safeMap[uuid.UUID, bool] +type jobDisabledMapType = *generic_sync.MapOf[uuid.UUID, any] // OpScheduler is the main scheduler struct that manages jobs. type OpScheduler struct { @@ -36,7 +37,7 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, return &OpScheduler{ scheduler: scheduler, Name: name, - jobDisabledMap: newSafeMap[uuid.UUID, bool](), + jobDisabledMap: newSafeMap[uuid.UUID, struct{}](), jobsMap: newSafeMap[uuid.UUID, gocron.Job](), }, nil } @@ -56,8 +57,7 @@ func (o *OpScheduler) RunNow(jobUUID uuid.UUID) error { // jobIsDisabled checks if a job is disabled. func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { - disabled, exists := o.jobDisabledMap.Get(jobUUID) - return exists && disabled + return o.jobDisabledMap.Has(jobUUID) } // buildJobParams builds a gocron.Task with the provided parameters. @@ -130,9 +130,9 @@ func (o *OpScheduler) NewJob(jb *jobBuilder) (*OpJob, error) { if err != nil { return nil, err } - o.jobsMap.Set(jobUUID, job) + o.jobsMap.Store(jobUUID, job) if jb.disabled { - o.jobDisabledMap.Set(jobUUID, true) + o.jobDisabledMap.Store(jobUUID, struct{}{}) } return newOpJob(job, jb.disabled), nil } @@ -161,10 +161,10 @@ func (o *OpScheduler) UpdateJob( return err } // Save job - o.jobsMap.Set(jobUUID, job) + o.jobsMap.Store(jobUUID, job) // Update disabled status if jb.disabled { - o.jobDisabledMap.Set(jobUUID, true) + o.jobDisabledMap.Store(jobUUID, struct{}{}) } else { o.jobDisabledMap.Delete(jobUUID) } @@ -179,7 +179,7 @@ func (o *OpScheduler) Exists(uuid uuid.UUID) bool { // _internalGetCronJob retrieves a gocron.Job by its UUID. func (o *OpScheduler) _internalGetCronJob(jobUUID uuid.UUID) (gocron.Job, bool) { - return o.jobsMap.Get(jobUUID) + return o.jobsMap.Load(jobUUID) } // GetJob retrieves a job by its UUID. @@ -214,7 +214,7 @@ func (o *OpScheduler) DisableJob(jobUUID uuid.UUID) error { if !o.Exists(jobUUID) { return errors.New("job not found: " + jobUUID.String()) } - o.jobDisabledMap.Set(jobUUID, true) + o.jobDisabledMap.Store(jobUUID, struct{}{}) return nil } @@ -261,7 +261,7 @@ func (o *OpScheduler) filterLabels( labels JobLabels, action func(gocron.Job, JobLabels), ) { - o.jobsMap.ForEach(func(_ uuid.UUID, job gocron.Job) { + var loopFunc = func(_ uuid.UUID, job gocron.Job) bool { jobLabels := tags2JobLabels(job.Tags()) matches := true for k, v := range labels { @@ -273,7 +273,9 @@ func (o *OpScheduler) filterLabels( if matches { action(job, jobLabels) } - }) + return true + } + o.jobsMap.Range(loopFunc) } // RemoveJobByLabels removes all jobs that have all of the provided labels. From 4ec0fcbd4c863ffeafce5c77f2243247a8fb72ff Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:55:07 +0800 Subject: [PATCH 37/44] =?UTF-8?q?refactor(scheduler):=20=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E5=AE=89=E5=85=A8map=E4=BD=BF=E7=94=A8=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E7=9A=84struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index c69947317..30297c049 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -37,7 +37,7 @@ func NewOpScheduler(name string, opts ...gocron.SchedulerOption) (*OpScheduler, return &OpScheduler{ scheduler: scheduler, Name: name, - jobDisabledMap: newSafeMap[uuid.UUID, struct{}](), + jobDisabledMap: newSafeMap[uuid.UUID, any](), jobsMap: newSafeMap[uuid.UUID, gocron.Job](), }, nil } From 2246852c4c7155b96b863b61141b196a630939b1 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:05:45 +0800 Subject: [PATCH 38/44] =?UTF-8?q?refactor(scheduler):=20nextRuns=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=96=B9=E6=B3=95=EF=BC=8C=E4=BC=A0=E9=80=92=E6=8C=87?= =?UTF-8?q?=E9=92=88=E5=90=8E=E5=AE=9E=E6=97=B6=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 45 +++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index 502866f6f..e3c9530eb 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -1,7 +1,6 @@ package scheduler import ( - "errors" "maps" "time" @@ -35,14 +34,11 @@ func newSafeMap[K comparable, V any]() *generic_sync.MapOf[K, V] { // OpJob represents an operational job with its metadata. type OpJob struct { - id uuid.UUID - name string - lastRun time.Time - lastRunErr error - nextTenRuns []time.Time - nextRunErr error - labels JobLabels - disabled bool + id uuid.UUID + name string + labels JobLabels + disabled bool + _rawJob gocron.Job } // ID returns the UUID of the job. @@ -73,41 +69,30 @@ func (o *OpJob) Disabled() bool { // LastRun returns the last run time of the job. func (o *OpJob) LastRun() (time.Time, error) { - return o.lastRun, o.lastRunErr + return o._rawJob.LastRun() } // NextRun returns the next run time of the job. func (o *OpJob) NextRun() (time.Time, error) { - if o.nextRunErr != nil { - return time.Time{}, o.nextRunErr - } - if len(o.nextTenRuns) == 0 { - return time.Time{}, errors.New("no next run scheduled, maybe the scheduler is not started or has been stopped") - } - return o.nextTenRuns[0], nil + return o._rawJob.NextRun() } -// GetNextTenRuns returns the next 10 run times of the job. -func (o *OpJob) GetNextTenRuns() ([]time.Time, error) { - return o.nextTenRuns, o.nextRunErr +// GetNextRuns returns the next n run times of the job. +func (o *OpJob) GetNextRuns(n int) ([]time.Time, error) { + return o._rawJob.NextRuns(n) } // newOpJob creates a new OpJob instance from a gocron.Job and its disabled status. func newOpJob(job gocron.Job, disabled bool) *OpJob { labels := tags2JobLabels(job.Tags()) - lastRun, lastRunErr := job.LastRun() - nextRun10, nextRunErr := job.NextRuns(10) labelsCopy := make(JobLabels) maps.Copy(labelsCopy, labels) return &OpJob{ - id: job.ID(), - name: job.Name(), - lastRun: lastRun, - lastRunErr: lastRunErr, - nextTenRuns: nextRun10, - nextRunErr: nextRunErr, - labels: labelsCopy, - disabled: disabled, + id: job.ID(), + name: job.Name(), + labels: labelsCopy, + disabled: disabled, + _rawJob: job, } } From 5ac19b9ab59b30a8e072bf109e10e5f60e4ddf36 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:44:52 +0800 Subject: [PATCH 39/44] =?UTF-8?q?refactor(scheduler):=20=E5=B0=86=E5=BB=BA?= =?UTF-8?q?=E9=80=A0=E8=80=85=E6=96=B9=E6=B3=95=E8=BF=9B=E8=A1=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=A4=84=E7=90=86=EF=BC=8C=E9=A2=9D=E5=A4=96=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0execute=E5=AE=9A=E4=B9=89=E5=AF=B9=E8=B1=A1=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 21 +++++++++++++++++ pkg/scheduler/scheduler.go | 46 +++++++++++++++----------------------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index 929b8bd9c..3a5b210ef 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" ) +type ExecuteDefine func() (JobRunner, []any) type jobBuilder struct { id uuid.UUID ctx context.Context @@ -24,6 +25,14 @@ type jobBuilder struct { beforeJobRunsSkipIfBeforeFuncErrors []func(jobID uuid.UUID, jobName string) error } +type jobDefine struct { + id uuid.UUID + cron gocron.JobDefinition + disabled bool + execute ExecuteDefine + opts []gocron.JobOption +} + // NewJobBuilder create a jobBuilder func NewJobBuilder() *jobBuilder { return &jobBuilder{ @@ -198,6 +207,18 @@ func (jb *jobBuilder) BeforeJobRunsSkipIfBeforeFuncErrors(eventListenerFunc func return jb } +func (jb *jobBuilder) Build() *jobDefine { + return &jobDefine{ + id: jb._internalGetOrCreateID(), + opts: jb._internalGetOptions(), + execute: func() (JobRunner, []any) { + return jb.runner, jb.params + }, + cron: jb.cron, + disabled: jb.disabled, + } +} + func (jb *jobBuilder) _internalGetOrCreateID() uuid.UUID { if jb.id == uuid.Nil { jb.id = uuid.New() diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 30297c049..dabb69536 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -63,9 +63,9 @@ func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { // buildJobParams builds a gocron.Task with the provided parameters. func (o *OpScheduler) buildJobParams( jobUUID uuid.UUID, - runner JobRunner, - params []any, + executeDefine ExecuteDefine, ) (gocron.Task, error) { + runner, params := executeDefine() f := reflect.ValueOf(runner) if f.IsZero() { return nil, errors.New("runner is nil") @@ -106,35 +106,25 @@ func (o *OpScheduler) buildJobParams( } // NewJobByBuilder creates and schedules a new job by builder -func (o *OpScheduler) NewJob(jb *jobBuilder) (*OpJob, error) { - if jb.ctx == nil { - jb.Ctx(context.Background()) - } - if jb.runner == nil { - return nil, errors.New("runner is nil") - } - if jb.jobName == "" { - return nil, errors.New("jobName is empty") - } - opts := jb._internalGetOptions() - var jobUUID uuid.UUID = jb._internalGetOrCreateID() - task, err := o.buildJobParams(jobUUID, jb.runner, jb.params) +func (o *OpScheduler) NewJob(jBuilder *jobBuilder) (*OpJob, error) { + jd := jBuilder.Build() + task, err := o.buildJobParams(jd.id, jd.execute) if err != nil { return nil, err } job, err := o.scheduler.NewJob( - jb.cron, + jd.cron, task, - opts..., + jd.opts..., ) if err != nil { return nil, err } - o.jobsMap.Store(jobUUID, job) - if jb.disabled { - o.jobDisabledMap.Store(jobUUID, struct{}{}) + o.jobsMap.Store(jd.id, job) + if jd.disabled { + o.jobDisabledMap.Store(jd.id, struct{}{}) } - return newOpJob(job, jb.disabled), nil + return newOpJob(job, jd.disabled), nil } // UpdateJob updates an existing job by its UUID using a job builder. @@ -146,16 +136,16 @@ func (o *OpScheduler) UpdateJob( if exists := o.Exists(jobUUID); !exists { return errors.New("job not found: " + jobUUID.String()) } - task, err := o.buildJobParams(jobUUID, jb.runner, jb.params) + // update the ID of jobBuilder to ensure consistency + jb.ID(jobUUID) + jd := jb.Build() + task, err := o.buildJobParams(jobUUID, jd.execute) if err != nil { return err } - // update the ID of jobBuilder to ensure consistency - jb.ID(jobUUID) - opts := jb._internalGetOptions() job, err := o.scheduler.Update( - jobUUID, jb.cron, task, - opts..., + jobUUID, jd.cron, task, + jd.opts..., ) if err != nil { return err @@ -163,7 +153,7 @@ func (o *OpScheduler) UpdateJob( // Save job o.jobsMap.Store(jobUUID, job) // Update disabled status - if jb.disabled { + if jd.disabled { o.jobDisabledMap.Store(jobUUID, struct{}{}) } else { o.jobDisabledMap.Delete(jobUUID) From ea09dd5f544d35fb9b2dd0bc7000a73390319ee5 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:19:11 +0800 Subject: [PATCH 40/44] =?UTF-8?q?refactor(scheduler):=20=E5=B0=86=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E6=96=B9=E6=B3=95=E8=BF=9B=E8=A1=8C=E9=97=AD=E5=8C=85?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/errors.go | 8 ++ pkg/scheduler/job.go | 62 ++++++++++----- pkg/scheduler/scheduler.go | 61 ++++---------- pkg/scheduler/scheduler_test.go | 137 +++++++++++++++++++++++--------- 4 files changed, 166 insertions(+), 102 deletions(-) create mode 100644 pkg/scheduler/errors.go diff --git a/pkg/scheduler/errors.go b/pkg/scheduler/errors.go new file mode 100644 index 000000000..b7a1baf07 --- /dev/null +++ b/pkg/scheduler/errors.go @@ -0,0 +1,8 @@ +package scheduler + +import "errors" + +var ( + ErrJobCronNotDefined = errors.New("job cron not defined") + ErrJobTaskNotDefined = errors.New("job task not defined") +) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index 3a5b210ef..ba153542a 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -8,7 +8,25 @@ import ( "github.com/google/uuid" ) -type ExecuteDefine func() (JobRunner, []any) +type TaskExecutor interface { + Execute(ctx context.Context) error +} + +type simpleTask struct { + f func(ctx context.Context) error +} + +func (st *simpleTask) Execute(ctx context.Context) error { + return st.f(ctx) +} +func NewSimpleTask(f func(ctx context.Context) error) TaskExecutor { + return &simpleTask{ + f: f, + } +} + +var _ TaskExecutor = (*simpleTask)(nil) + type jobBuilder struct { id uuid.UUID ctx context.Context @@ -16,8 +34,7 @@ type jobBuilder struct { cron gocron.JobDefinition disabled bool labels JobLabels - runner JobRunner - params []any + taskExecutor TaskExecutor afterJobRuns []func(jobID uuid.UUID, jobName string) afterJobRunsWithErrors []func(jobID uuid.UUID, jobName string, runErr error) afterJobRunsWithPanics []func(jobID uuid.UUID, jobName string, panicData any) @@ -26,11 +43,11 @@ type jobBuilder struct { } type jobDefine struct { - id uuid.UUID - cron gocron.JobDefinition - disabled bool - execute ExecuteDefine - opts []gocron.JobOption + id uuid.UUID + cron gocron.JobDefinition + disabled bool + taskExecutor TaskExecutor + opts []gocron.JobOption } // NewJobBuilder create a jobBuilder @@ -169,10 +186,9 @@ func (jb *jobBuilder) Labels(labels JobLabels) *jobBuilder { return jb } -// Runner sets the job runner function and the params. -func (jb *jobBuilder) Runner(runner JobRunner, params ...any) *jobBuilder { - jb.runner = runner - jb.params = params +// TaskExecutor sets the job taskExecutor. +func (jb *jobBuilder) TaskExecutor(taskExecutor TaskExecutor) *jobBuilder { + jb.taskExecutor = taskExecutor return jb } @@ -207,16 +223,20 @@ func (jb *jobBuilder) BeforeJobRunsSkipIfBeforeFuncErrors(eventListenerFunc func return jb } -func (jb *jobBuilder) Build() *jobDefine { - return &jobDefine{ - id: jb._internalGetOrCreateID(), - opts: jb._internalGetOptions(), - execute: func() (JobRunner, []any) { - return jb.runner, jb.params - }, - cron: jb.cron, - disabled: jb.disabled, +func (jb *jobBuilder) Build() (*jobDefine, error) { + if jb.cron == nil { + return nil, ErrJobCronNotDefined + } + if jb.taskExecutor == nil { + return nil, ErrJobTaskNotDefined } + return &jobDefine{ + id: jb._internalGetOrCreateID(), + opts: jb._internalGetOptions(), + taskExecutor: jb.taskExecutor, + cron: jb.cron, + disabled: jb.disabled, + }, nil } func (jb *jobBuilder) _internalGetOrCreateID() uuid.UUID { diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index dabb69536..a68a4634f 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -4,7 +4,6 @@ package scheduler import ( "context" "errors" - "reflect" "github.com/OpenListTeam/OpenList/v4/pkg/generic_sync" "github.com/go-co-op/gocron/v2" @@ -62,53 +61,24 @@ func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { // buildJobParams builds a gocron.Task with the provided parameters. func (o *OpScheduler) buildJobParams( - jobUUID uuid.UUID, - executeDefine ExecuteDefine, + jd *jobDefine, ) (gocron.Task, error) { - runner, params := executeDefine() - f := reflect.ValueOf(runner) - if f.IsZero() { - return nil, errors.New("runner is nil") - } - if f.Kind() != reflect.Func { - return nil, errors.New("runner must be a function") - } - if len(params)+1 != f.Type().NumIn() { - return nil, errors.New("number of params does not match runner function signature (expected N params plus context parameter)") - } // check runner as function and NumIn is match params length - task := gocron.NewTask(func(_ctx context.Context, params []any) error { - // check if job is exists and not disabled - j, exists := o._internalGetCronJob(jobUUID) - // In theory the job should always exist, but check just in case - if !exists { - return errors.New("cron job not found") - } - // check disabled status - if o.jobIsDisabled(j.ID()) { - return nil - } - in := make([]reflect.Value, len(params)+1) - in[0] = reflect.ValueOf(_ctx) - for k, param := range params { - in[k+1] = reflect.ValueOf(param) - } - // call runner with params appended context at first - returnValues := f.Call(in) - result := returnValues[0].Interface() - // if runner returns an error, return it - if result == nil { - return nil - } - return result.(error) - }, params) - return task, nil + _task := gocron.NewTask( + func(ctx context.Context) error { + return jd.taskExecutor.Execute(ctx) + }, + ) + return _task, nil } // NewJobByBuilder creates and schedules a new job by builder func (o *OpScheduler) NewJob(jBuilder *jobBuilder) (*OpJob, error) { - jd := jBuilder.Build() - task, err := o.buildJobParams(jd.id, jd.execute) + jd, err := jBuilder.Build() + if err != nil { + return nil, err + } + task, err := o.buildJobParams(jd) if err != nil { return nil, err } @@ -138,8 +108,11 @@ func (o *OpScheduler) UpdateJob( } // update the ID of jobBuilder to ensure consistency jb.ID(jobUUID) - jd := jb.Build() - task, err := o.buildJobParams(jobUUID, jd.execute) + jd, err := jb.Build() + if err != nil { + return err + } + task, err := o.buildJobParams(jd) if err != nil { return err } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 096b330d2..f97487f71 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -3,6 +3,7 @@ package scheduler import ( "context" "errors" + "reflect" "testing" "time" @@ -18,6 +19,54 @@ const ( var donothingRunner = func(ctx context.Context) error { return nil } +type paramTask struct { + Runner func(ctx context.Context, params ...any) error + Params []any +} + +func (pt *paramTask) Execute(ctx context.Context) error { + return pt.Runner(ctx, pt.Params...) +} + +type anyParamTask struct { + Runner JobRunner + Params []any +} + +func (apt *anyParamTask) Execute(ctx context.Context) error { + f := reflect.ValueOf(apt.Runner) + if f.IsZero() { + return errors.New("with out runner define") + } + if len(apt.Params) != f.Type().NumIn() { + return errors.New("params count not match runner func") + } + // 判断是否 return 是否为 1个 + if f.Type().NumOut() != 1 { + return errors.New("runner func return values more than 1") + } + // 判断是否返回的类型 + _, ok := f.Type().Out(0).Elem().FieldByName("error") + if !ok { + return errors.New("runner func return value is not error type") + } + in := make([]reflect.Value, len(apt.Params)) + for k, param := range apt.Params { + in[k] = reflect.ValueOf(param) + } + returnValues := f.Call(in) + if len(returnValues) != 1 { + return nil + } + if returnValues[0].IsNil() { + return nil + } + if err, ok := returnValues[0].Interface().(error); ok { + return err + } + return errors.New("runner func return value is not error type") +} + // TestGoCron sanity-checks direct gocron usage with immediate execution. func TestGoCron(t *testing.T) { s, err := gocron.NewScheduler(gocron.WithLocation(time.Local)) @@ -81,7 +130,7 @@ func TestSchedulerNormal(t *testing.T) { arg1 := "arg1" // store task status executed := make(chan bool, 1) - var runner JobRunner = func(ctx context.Context, _arg0 int, _arg1 string) error { + runner := func(ctx context.Context, _arg0 int, _arg1 string) error { t.Log("task is running") if _arg0 != arg0 { t.Fatalf("expected _arg0 to be %d, got %v", arg0, _arg0) @@ -102,7 +151,10 @@ func TestSchedulerNormal(t *testing.T) { ByDuration(fastInterval). Name("test-job"). Labels(labels). - Runner(runner, arg0, arg1), + TaskExecutor(&anyParamTask{ + Runner: runner, + Params: []any{arg0, arg1}, + }), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -153,7 +205,7 @@ func TestDisabledJob(t *testing.T) { "team": "devops", } chanCount := make(chan int, 1) - var runner JobRunner = func(ctx context.Context) error { + runner := func(ctx context.Context) error { t.Fatalf("disabled job should not run") chanCount <- 1 return nil @@ -167,7 +219,7 @@ func TestDisabledJob(t *testing.T) { ByDuration(fastInterval). Disabled(true). Labels(labels). - Runner(runner), + TaskExecutor(NewSimpleTask(runner)), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -207,7 +259,7 @@ func TestEnableJob(t *testing.T) { "team": "devops", } chanCount := make(chan int, 1) - var runner JobRunner = func(ctx context.Context) error { + runner := func(ctx context.Context) error { t.Log("job has run") chanCount <- 1 return nil @@ -221,7 +273,7 @@ func TestEnableJob(t *testing.T) { ByDuration(fastInterval). Disabled(true). Labels(labels). - Runner(runner), + TaskExecutor(NewSimpleTask(runner)), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -260,7 +312,7 @@ func TestRemoveJob(t *testing.T) { "team": "devops", } chanCount := make(chan int, 1) - var runner JobRunner = func(ctx context.Context) error { + runner := func(ctx context.Context) error { chanCount <- 1 return nil } @@ -273,7 +325,7 @@ func TestRemoveJob(t *testing.T) { Name("test-job"). ByDuration(time.Hour). Labels(labels). - Runner(runner), + TaskExecutor(NewSimpleTask(runner)), ) // avoid blocking if the channel already has a value select { @@ -319,10 +371,10 @@ func TestDisableJobMethod(t *testing.T) { Name("test-job"). ByDuration(time.Hour). Labels(labels). - Runner(func(ctx context.Context) error { + TaskExecutor(NewSimpleTask(func(ctx context.Context) error { executed <- true return nil - }), + })), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -362,7 +414,9 @@ func TestUpdateJobLabelsAndEnable(t *testing.T) { ByDuration(time.Hour). Labels(initialLabels). Disabled(true). - Runner(donothingRunner), + TaskExecutor( + NewSimpleTask(donothingRunner), + ), ) if err != nil { t.Fatalf("failed to create job: %v", err) @@ -371,10 +425,17 @@ func TestUpdateJobLabelsAndEnable(t *testing.T) { executed := make(chan bool, 1) if err := s.UpdateJob( job.ID(), - NewJobBuilder().Ctx(ctx).Name("update-job-new").ByDuration(fastInterval).Labels(updatedLabels).Runner(func(ctx context.Context) error { - executed <- true - return nil - }), + NewJobBuilder(). + Ctx(ctx). + Name("update-job-new"). + ByDuration(fastInterval). + Labels(updatedLabels). + TaskExecutor(NewSimpleTask( + func(ctx context.Context) error { + executed <- true + return nil + }), + ), ); err != nil { t.Fatalf("update failed: %v", err) } @@ -414,24 +475,24 @@ func TestRemoveJobsLeavesOthers(t *testing.T) { removeRan := make(chan bool, 1) jobRemove, err := s.NewJob( NewJobBuilder().Ctx(ctx).Name("remove-me").ByDuration(fastInterval).Label("env", "remove"). - Runner( + TaskExecutor(NewSimpleTask( func(ctx context.Context) error { removeRan <- true return nil }, - ), + )), ) if err != nil { t.Fatalf("failed to create job: %v", err) } jobKeep, err := s.NewJob( NewJobBuilder().Ctx(ctx).Name("keep-me").ByDuration(fastInterval).Label("env", "keep"). - Runner( + TaskExecutor(NewSimpleTask( func(ctx context.Context) error { keepRan <- true return nil }, - ), + )), ) if err != nil { t.Fatalf("failed to create keep job: %v", err) @@ -481,19 +542,21 @@ func TestRemoveJobByLabels(t *testing.T) { labelsProd := JobLabels{"env": "prod"} _, err = s.NewJob( - NewJobBuilder().Ctx(ctx).Name("dev-1").ByDuration(time.Hour).Labels(labelsDev).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("dev-1"). + ByDuration(time.Hour).Labels(labelsDev). + TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create dev-1: %v", err) } devTwo, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("dev-2").ByDuration(time.Hour).Labels(labelsDev).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("dev-2").ByDuration(time.Hour).Labels(labelsDev).TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create dev-2: %v", err) } prod, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("prod-1").ByDuration(time.Hour).Labels(labelsProd).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("prod-1").ByDuration(time.Hour).Labels(labelsProd).TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create prod: %v", err) @@ -523,19 +586,19 @@ func TestGetJobsByLabelsFilters(t *testing.T) { labelsB := JobLabels{"env": "dev", "team": "b"} labelsC := JobLabels{"env": "prod", "team": "a"} jobA, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-a").ByDuration(time.Hour).Labels(labelsA).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("job-a").ByDuration(time.Hour).Labels(labelsA).TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create job-a: %v", err) } jobB, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-b").ByDuration(time.Hour).Labels(labelsB).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("job-b").ByDuration(time.Hour).Labels(labelsB).TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create job-b: %v", err) } _, err = s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-c").ByDuration(time.Hour).Labels(labelsC).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("job-c").ByDuration(time.Hour).Labels(labelsC).TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create job-c: %v", err) @@ -570,13 +633,13 @@ func TestRemoveAllJobs(t *testing.T) { defer cancel() labels := JobLabels{"env": "test"} job1, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-1").ByDuration(time.Hour).Labels(labels).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("job-1").ByDuration(time.Hour).Labels(labels).TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create job1: %v", err) } job2, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-2").ByDuration(time.Hour).Labels(labels).Runner(donothingRunner), + NewJobBuilder().Ctx(ctx).Name("job-2").ByDuration(time.Hour).Labels(labels).TaskExecutor(NewSimpleTask(donothingRunner)), ) if err != nil { t.Fatalf("failed to create job2: %v", err) @@ -611,7 +674,7 @@ func TestBeforeJobRuns(t *testing.T) { Ctx(context.Background()). Name("before-job"). ByDuration(fastInterval). - Runner(donothingRunner). + TaskExecutor(NewSimpleTask(donothingRunner)). BeforeJobRuns(func(jobID uuid.UUID, jobName string) { beforeRunChan <- true }) @@ -641,10 +704,10 @@ func TestBeforeJobRunsSkipIfBeforeFuncErrorsSkip(t *testing.T) { Ctx(context.Background()). Name("before-job-error"). ByDuration(fastInterval). - Runner(func(ctx context.Context) error { + TaskExecutor(NewSimpleTask(func(ctx context.Context) error { executeChan <- true return nil - }). + })). BeforeJobRunsSkipIfBeforeFuncErrors(func(jobID uuid.UUID, jobName string) error { beforeRunChan <- true return errors.New("skip execution") @@ -681,10 +744,10 @@ func TestBeforeJobRunsSkipIfBeforeFuncErrorsNotSkip(t *testing.T) { Ctx(context.Background()). Name("before-job-error"). ByDuration(fastInterval). - Runner(func(ctx context.Context) error { + TaskExecutor(NewSimpleTask(func(ctx context.Context) error { executeChan <- true return nil - }). + })). BeforeJobRunsSkipIfBeforeFuncErrors(func(jobID uuid.UUID, jobName string) error { beforeRunChan <- true return nil @@ -720,7 +783,7 @@ func TestAfterJobRuns(t *testing.T) { Ctx(context.Background()). Name("after-job"). ByDuration(fastInterval). - Runner(donothingRunner). + TaskExecutor(NewSimpleTask(donothingRunner)). AfterJobRuns(func(jobID uuid.UUID, jobName string) { afterRunChan <- true }) @@ -749,9 +812,9 @@ func TestAfterJobRunsWithError(t *testing.T) { Ctx(context.Background()). Name("after-job-error"). ByDuration(fastInterval). - Runner(func(ctx context.Context) error { + TaskExecutor(NewSimpleTask(func(ctx context.Context) error { return errors.New("intentional error") - }). + })). AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, runErr error) { if runErr != nil && runErr.Error() == "intentional error" { afterRunChan <- true @@ -782,9 +845,9 @@ func TestAfterJobRunsWithPanic(t *testing.T) { Ctx(context.Background()). Name("after-job-panic"). ByDuration(fastInterval). - Runner(func(ctx context.Context) error { + TaskExecutor(NewSimpleTask(func(ctx context.Context) error { panic("intentional panic") - }). + })). AfterJobRunsWithPanic(func(jobID uuid.UUID, jobName string, panicErr interface{}) { if panicErr != nil && panicErr == "intentional panic" { afterRunChan <- true From 81ae0f419590a7e8430a5cd3c5de79397d6e6214 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:21:42 +0800 Subject: [PATCH 41/44] =?UTF-8?q?refactor(scheduler):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index ba153542a..0cab4ed29 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -8,17 +8,23 @@ import ( "github.com/google/uuid" ) +// TaskExecutor is the interface that wraps the Execute method. type TaskExecutor interface { + // Execute performs the task's action. Execute(ctx context.Context) error } +// simpleTask is a simple implementation of TaskExecutor type simpleTask struct { f func(ctx context.Context) error } +// Execute performs the task's action. func (st *simpleTask) Execute(ctx context.Context) error { return st.f(ctx) } + +// NewSimpleTask creates a new simpleTask with the provided function. func NewSimpleTask(f func(ctx context.Context) error) TaskExecutor { return &simpleTask{ f: f, @@ -27,6 +33,7 @@ func NewSimpleTask(f func(ctx context.Context) error) TaskExecutor { var _ TaskExecutor = (*simpleTask)(nil) +// jobBuilder is used to build job definitions. type jobBuilder struct { id uuid.UUID ctx context.Context @@ -42,6 +49,7 @@ type jobBuilder struct { beforeJobRunsSkipIfBeforeFuncErrors []func(jobID uuid.UUID, jobName string) error } +// jobDefine defines the parameters of a job. type jobDefine struct { id uuid.UUID cron gocron.JobDefinition From 323aff933ede352c8257db21323f7e8151a5f7a6 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:37:40 +0800 Subject: [PATCH 42/44] =?UTF-8?q?fix(scheduler):=20=E4=BF=AE=E5=A4=8D=20di?= =?UTF-8?q?sabled=20=E7=9A=84=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/scheduler.go | 36 ++++++++++++++++++++++++--------- pkg/scheduler/scheduler_test.go | 15 +++++++++----- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index a68a4634f..15950c80c 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -66,6 +66,9 @@ func (o *OpScheduler) buildJobParams( // check runner as function and NumIn is match params length _task := gocron.NewTask( func(ctx context.Context) error { + if o.jobIsDisabled(jd.id) { + return nil + } return jd.taskExecutor.Execute(ctx) }, ) @@ -82,18 +85,22 @@ func (o *OpScheduler) NewJob(jBuilder *jobBuilder) (*OpJob, error) { if err != nil { return nil, err } + if jd.disabled { + o.jobDisabledMap.Store(jd.id, struct{}{}) + } job, err := o.scheduler.NewJob( jd.cron, task, jd.opts..., ) if err != nil { + // remove the disabled status if job creation failed + if jd.disabled { + o.jobDisabledMap.Delete(jd.id) + } return nil, err } o.jobsMap.Store(jd.id, job) - if jd.disabled { - o.jobDisabledMap.Store(jd.id, struct{}{}) - } return newOpJob(job, jd.disabled), nil } @@ -116,21 +123,32 @@ func (o *OpScheduler) UpdateJob( if err != nil { return err } + // Update disabled status + rawDsiabled := o.jobIsDisabled(jobUUID) + if rawDsiabled != jd.disabled { + if jd.disabled { + o.jobDisabledMap.Store(jobUUID, struct{}{}) + } else { + o.jobDisabledMap.Delete(jobUUID) + } + } job, err := o.scheduler.Update( jobUUID, jd.cron, task, jd.opts..., ) if err != nil { + // rollback disabled status if update failed + if jd.disabled != rawDsiabled { + if rawDsiabled { + o.jobDisabledMap.Store(jobUUID, struct{}{}) + } else { + o.jobDisabledMap.Delete(jobUUID) + } + } return err } // Save job o.jobsMap.Store(jobUUID, job) - // Update disabled status - if jd.disabled { - o.jobDisabledMap.Store(jobUUID, struct{}{}) - } else { - o.jobDisabledMap.Delete(jobUUID) - } return nil } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index f97487f71..5c6e8d131 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -38,7 +38,11 @@ func (apt *anyParamTask) Execute(ctx context.Context) error { if f.IsZero() { return errors.New("with out runner define") } - if len(apt.Params) != f.Type().NumIn() { + // 判断 f 是方法 + if f.Kind() != reflect.Func { + return errors.New("runner is not a function") + } + if len(apt.Params)+1 != f.Type().NumIn() { return errors.New("params count not match runner func") } // 判断是否 return 是否为 1个 @@ -46,13 +50,14 @@ func (apt *anyParamTask) Execute(ctx context.Context) error { return errors.New("runner func return values more than 1") } // 判断是否返回的类型 - _, ok := f.Type().Out(0).Elem().FieldByName("error") - if !ok { + outType := f.Type().Out(0) + if !outType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { return errors.New("runner func return value is not error type") } - in := make([]reflect.Value, len(apt.Params)) + in := make([]reflect.Value, len(apt.Params)+1) + in[0] = reflect.ValueOf(ctx) for k, param := range apt.Params { - in[k] = reflect.ValueOf(param) + in[k+1] = reflect.ValueOf(param) } returnValues := f.Call(in) if len(returnValues) != 1 { From 1f83c0e7249c503fcd3349a1eb51aa7a799f6bb8 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:02:56 +0800 Subject: [PATCH 43/44] =?UTF-8?q?fix(scheduler):=20=E4=BF=AE=E5=A4=8Dcopil?= =?UTF-8?q?ot=E6=8F=90=E5=87=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/meta.go | 16 ----------- pkg/scheduler/scheduler.go | 17 ++++++------ pkg/scheduler/scheduler_test.go | 47 +++++++++++++++++++++------------ 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/pkg/scheduler/meta.go b/pkg/scheduler/meta.go index e3c9530eb..99d01d907 100644 --- a/pkg/scheduler/meta.go +++ b/pkg/scheduler/meta.go @@ -9,25 +9,9 @@ import ( "github.com/google/uuid" ) -// JobRunner defines the expected function signature for job runners. -// -// Implementations must be functions that accept a context.Context as the first -// parameter, followed by zero or more additional parameters, and return an error. -// -// A canonical example is: -// -// func(ctx context.Context, args ...any) error -// -// While JobRunner is typed as any for flexibility, callers are expected to -// adhere to this function shape. -type JobRunner any - // JobLabels is the type for job labels. type JobLabels = map[string]string -// // safeMap is a thread-safe map implementation -// type safeMap[K comparable, V any] - func newSafeMap[K comparable, V any]() *generic_sync.MapOf[K, V] { return new(generic_sync.MapOf[K, V]) } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 15950c80c..e45e72597 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -59,8 +59,8 @@ func (o *OpScheduler) jobIsDisabled(jobUUID uuid.UUID) bool { return o.jobDisabledMap.Has(jobUUID) } -// buildJobParams builds a gocron.Task with the provided parameters. -func (o *OpScheduler) buildJobParams( +// buildJobTask builds a gocron.Task with the provided parameters. +func (o *OpScheduler) buildJobTask( jd *jobDefine, ) (gocron.Task, error) { // check runner as function and NumIn is match params length @@ -81,7 +81,7 @@ func (o *OpScheduler) NewJob(jBuilder *jobBuilder) (*OpJob, error) { if err != nil { return nil, err } - task, err := o.buildJobParams(jd) + task, err := o.buildJobTask(jd) if err != nil { return nil, err } @@ -119,13 +119,13 @@ func (o *OpScheduler) UpdateJob( if err != nil { return err } - task, err := o.buildJobParams(jd) + task, err := o.buildJobTask(jd) if err != nil { return err } // Update disabled status - rawDsiabled := o.jobIsDisabled(jobUUID) - if rawDsiabled != jd.disabled { + rawDisabled := o.jobIsDisabled(jobUUID) + if rawDisabled != jd.disabled { if jd.disabled { o.jobDisabledMap.Store(jobUUID, struct{}{}) } else { @@ -138,8 +138,8 @@ func (o *OpScheduler) UpdateJob( ) if err != nil { // rollback disabled status if update failed - if jd.disabled != rawDsiabled { - if rawDsiabled { + if jd.disabled != rawDisabled { + if rawDisabled { o.jobDisabledMap.Store(jobUUID, struct{}{}) } else { o.jobDisabledMap.Delete(jobUUID) @@ -218,7 +218,6 @@ func (o *OpScheduler) RemoveJobs(waitForRemoveJobUUIDs ...uuid.UUID) error { o.jobDisabledMap.Delete(jobID) } if len(errs) > 0 { - existsJobIDs := make(map[uuid.UUID]bool) for _, item := range o.scheduler.Jobs() { existsJobIDs[item.ID()] = true diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index 5c6e8d131..a523ad44d 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -17,7 +17,7 @@ const ( defaultTimeout = 10 * time.Second ) -var donothingRunner = func(ctx context.Context) error { return nil } +var doNothingRunner = func(ctx context.Context) error { return nil } type paramTask struct { Runner func(ctx context.Context, params ...any) error @@ -28,28 +28,41 @@ func (pt *paramTask) Execute(ctx context.Context) error { return pt.Runner(ctx, pt.Params...) } +// jobRunner defines the expected function signature for job runners. +// +// Implementations must be functions that accept a context.Context as the first +// parameter, followed by zero or more additional parameters, and return an error. +// +// A canonical example is: +// +// func(ctx context.Context, args ...any) error +// +// While jobRunner is typed as any for flexibility, callers are expected to +// adhere to this function shape. +type jobRunner any + type anyParamTask struct { - Runner JobRunner + Runner jobRunner Params []any } func (apt *anyParamTask) Execute(ctx context.Context) error { f := reflect.ValueOf(apt.Runner) if f.IsZero() { - return errors.New("with out runner define") + return errors.New("without runner define") } - // 判断 f 是方法 + // check if f is a function if f.Kind() != reflect.Func { return errors.New("runner is not a function") } if len(apt.Params)+1 != f.Type().NumIn() { return errors.New("params count not match runner func") } - // 判断是否 return 是否为 1个 + // check that the function returns exactly 1 value if f.Type().NumOut() != 1 { return errors.New("runner func return values more than 1") } - // 判断是否返回的类型 + // validate the return type outType := f.Type().Out(0) if !outType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { return errors.New("runner func return value is not error type") @@ -420,7 +433,7 @@ func TestUpdateJobLabelsAndEnable(t *testing.T) { Labels(initialLabels). Disabled(true). TaskExecutor( - NewSimpleTask(donothingRunner), + NewSimpleTask(doNothingRunner), ), ) if err != nil { @@ -549,19 +562,19 @@ func TestRemoveJobByLabels(t *testing.T) { _, err = s.NewJob( NewJobBuilder().Ctx(ctx).Name("dev-1"). ByDuration(time.Hour).Labels(labelsDev). - TaskExecutor(NewSimpleTask(donothingRunner)), + TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create dev-1: %v", err) } devTwo, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("dev-2").ByDuration(time.Hour).Labels(labelsDev).TaskExecutor(NewSimpleTask(donothingRunner)), + NewJobBuilder().Ctx(ctx).Name("dev-2").ByDuration(time.Hour).Labels(labelsDev).TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create dev-2: %v", err) } prod, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("prod-1").ByDuration(time.Hour).Labels(labelsProd).TaskExecutor(NewSimpleTask(donothingRunner)), + NewJobBuilder().Ctx(ctx).Name("prod-1").ByDuration(time.Hour).Labels(labelsProd).TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create prod: %v", err) @@ -591,19 +604,19 @@ func TestGetJobsByLabelsFilters(t *testing.T) { labelsB := JobLabels{"env": "dev", "team": "b"} labelsC := JobLabels{"env": "prod", "team": "a"} jobA, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-a").ByDuration(time.Hour).Labels(labelsA).TaskExecutor(NewSimpleTask(donothingRunner)), + NewJobBuilder().Ctx(ctx).Name("job-a").ByDuration(time.Hour).Labels(labelsA).TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create job-a: %v", err) } jobB, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-b").ByDuration(time.Hour).Labels(labelsB).TaskExecutor(NewSimpleTask(donothingRunner)), + NewJobBuilder().Ctx(ctx).Name("job-b").ByDuration(time.Hour).Labels(labelsB).TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create job-b: %v", err) } _, err = s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-c").ByDuration(time.Hour).Labels(labelsC).TaskExecutor(NewSimpleTask(donothingRunner)), + NewJobBuilder().Ctx(ctx).Name("job-c").ByDuration(time.Hour).Labels(labelsC).TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create job-c: %v", err) @@ -638,13 +651,13 @@ func TestRemoveAllJobs(t *testing.T) { defer cancel() labels := JobLabels{"env": "test"} job1, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-1").ByDuration(time.Hour).Labels(labels).TaskExecutor(NewSimpleTask(donothingRunner)), + NewJobBuilder().Ctx(ctx).Name("job-1").ByDuration(time.Hour).Labels(labels).TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create job1: %v", err) } job2, err := s.NewJob( - NewJobBuilder().Ctx(ctx).Name("job-2").ByDuration(time.Hour).Labels(labels).TaskExecutor(NewSimpleTask(donothingRunner)), + NewJobBuilder().Ctx(ctx).Name("job-2").ByDuration(time.Hour).Labels(labels).TaskExecutor(NewSimpleTask(doNothingRunner)), ) if err != nil { t.Fatalf("failed to create job2: %v", err) @@ -679,7 +692,7 @@ func TestBeforeJobRuns(t *testing.T) { Ctx(context.Background()). Name("before-job"). ByDuration(fastInterval). - TaskExecutor(NewSimpleTask(donothingRunner)). + TaskExecutor(NewSimpleTask(doNothingRunner)). BeforeJobRuns(func(jobID uuid.UUID, jobName string) { beforeRunChan <- true }) @@ -788,7 +801,7 @@ func TestAfterJobRuns(t *testing.T) { Ctx(context.Background()). Name("after-job"). ByDuration(fastInterval). - TaskExecutor(NewSimpleTask(donothingRunner)). + TaskExecutor(NewSimpleTask(doNothingRunner)). AfterJobRuns(func(jobID uuid.UUID, jobName string) { afterRunChan <- true }) From 9815049575d339f05be1a4efd1c15271354188e1 Mon Sep 17 00:00:00 2001 From: dezhishen <26274059+dezhishen@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:31:53 +0800 Subject: [PATCH 44/44] =?UTF-8?q?fix(scheduler):=20=E4=BF=AE=E5=A4=8Dcopil?= =?UTF-8?q?ot=E6=8F=90=E5=87=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/scheduler/job.go | 6 +++--- pkg/scheduler/scheduler_test.go | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/scheduler/job.go b/pkg/scheduler/job.go index 0cab4ed29..d3920542b 100644 --- a/pkg/scheduler/job.go +++ b/pkg/scheduler/job.go @@ -331,12 +331,12 @@ func newAtTimes(atTimes []AtTime) gocron.AtTimes { return gocron.NewAtTimes(at) } var gocronAtTimes []gocron.AtTime - for _, at := range atTimes[1:] { + for _, at := range atTimes { gocronAtTimes = append(gocronAtTimes, gocron.NewAtTime(at.hours, at.minutes, at.seconds)) } return gocron.NewAtTimes( - gocron.NewAtTime(atTimes[0].hours, atTimes[0].minutes, atTimes[0].seconds), - gocronAtTimes..., + gocronAtTimes[0], + gocronAtTimes[1:]..., ) } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go index a523ad44d..e823d799f 100644 --- a/pkg/scheduler/scheduler_test.go +++ b/pkg/scheduler/scheduler_test.go @@ -73,9 +73,6 @@ func (apt *anyParamTask) Execute(ctx context.Context) error { in[k+1] = reflect.ValueOf(param) } returnValues := f.Call(in) - if len(returnValues) != 1 { - return nil - } if returnValues[0].IsNil() { return nil }