diff --git a/tools/cron/cron.go b/tools/cron/cron.go index 39f8539c..448fa7c5 100644 --- a/tools/cron/cron.go +++ b/tools/cron/cron.go @@ -11,21 +11,17 @@ package cron import ( "errors" "fmt" + "slices" "sync" "time" ) -type job struct { - schedule *Schedule - run func() -} - // Cron is a crontab-like struct for tasks/jobs scheduling. type Cron struct { timezone *time.Location ticker *time.Ticker startTimer *time.Timer - jobs map[string]*job + jobs []*Job tickerDone chan bool interval time.Duration mux sync.RWMutex @@ -40,7 +36,7 @@ func New() *Cron { return &Cron{ interval: 1 * time.Minute, timezone: time.UTC, - jobs: map[string]*job{}, + jobs: []*Job{}, tickerDone: make(chan bool), } } @@ -82,23 +78,30 @@ func (c *Cron) MustAdd(jobId string, cronExpr string, run func()) { // // cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour). // Check cron.NewSchedule() for the supported tokens. -func (c *Cron) Add(jobId string, cronExpr string, run func()) error { - if run == nil { - return errors.New("failed to add new cron job: run must be non-nil function") +func (c *Cron) Add(jobId string, cronExpr string, fn func()) error { + if fn == nil { + return errors.New("failed to add new cron job: fn must be non-nil function") } - c.mux.Lock() - defer c.mux.Unlock() - schedule, err := NewSchedule(cronExpr) if err != nil { return fmt.Errorf("failed to add new cron job: %w", err) } - c.jobs[jobId] = &job{ + c.mux.Lock() + defer c.mux.Unlock() + + // remove previous (if any) + c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool { + return j.Id() == jobId + }) + + // add new + c.jobs = append(c.jobs, &Job{ + id: jobId, + fn: fn, schedule: schedule, - run: run, - } + }) return nil } @@ -108,7 +111,13 @@ func (c *Cron) Remove(jobId string) { c.mux.Lock() defer c.mux.Unlock() - delete(c.jobs, jobId) + if c.jobs == nil { + return // nothing to remove + } + + c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool { + return j.Id() == jobId + }) } // RemoveAll removes all registered cron jobs. @@ -116,7 +125,7 @@ func (c *Cron) RemoveAll() { c.mux.Lock() defer c.mux.Unlock() - c.jobs = map[string]*job{} + c.jobs = []*Job{} } // Total returns the current total number of registered cron jobs. @@ -127,6 +136,19 @@ func (c *Cron) Total() int { return len(c.jobs) } +// Jobs returns a shallow copy of the currently registered cron jobs. +func (c *Cron) Jobs() []*Job { + c.mux.RLock() + defer c.mux.RUnlock() + + copy := make([]*Job, len(c.jobs)) + for i, j := range c.jobs { + copy[i] = j + } + + return copy +} + // Stop stops the current cron ticker (if not already). // // You can resume the ticker by calling Start(). @@ -200,7 +222,7 @@ func (c *Cron) runDue(t time.Time) { for _, j := range c.jobs { if j.schedule.IsDue(moment) { - go j.run() + go j.Run() } } } diff --git a/tools/cron/cron_test.go b/tools/cron/cron_test.go index 70456c32..6ed73cb4 100644 --- a/tools/cron/cron_test.go +++ b/tools/cron/cron_test.go @@ -2,6 +2,7 @@ package cron import ( "encoding/json" + "slices" "testing" "time" ) @@ -98,6 +99,11 @@ func TestCronAddAndRemove(t *testing.T) { // try to remove non-existing (should be no-op) c.Remove("missing") + indexedJobs := make(map[string]*Job, len(c.jobs)) + for _, j := range c.jobs { + indexedJobs[j.Id()] = j + } + // check job keys { expectedKeys := []string{"test3", "test2", "test5"} @@ -107,7 +113,7 @@ func TestCronAddAndRemove(t *testing.T) { } for _, k := range expectedKeys { - if c.jobs[k] == nil { + if indexedJobs[k] == nil { t.Fatalf("Expected job with key %s, got nil", k) } } @@ -121,7 +127,7 @@ func TestCronAddAndRemove(t *testing.T) { "test5": `{"minutes":{"1":{}},"hours":{"2":{}},"days":{"3":{}},"months":{"4":{}},"daysOfWeek":{"5":{}}}`, } for k, v := range expectedSchedules { - raw, err := json.Marshal(c.jobs[k].schedule) + raw, err := json.Marshal(indexedJobs[k].schedule) if err != nil { t.Fatal(err) } @@ -148,7 +154,7 @@ func TestCronMustAdd(t *testing.T) { c.MustAdd("test2", "* * * * *", func() {}) - if _, ok := c.jobs["test2"]; !ok { + if !slices.ContainsFunc(c.jobs, func(j *Job) bool { return j.Id() == "test2" }) { t.Fatal("Couldn't find job test2") } } @@ -208,6 +214,42 @@ func TestCronTotal(t *testing.T) { } } +func TestCronJobs(t *testing.T) { + t.Parallel() + + c := New() + + calls := "" + + if err := c.Add("a", "1 * * * *", func() { calls += "a" }); err != nil { + t.Fatal(err) + } + + if err := c.Add("b", "2 * * * *", func() { calls += "b" }); err != nil { + t.Fatal(err) + } + + // overwrite + if err := c.Add("b", "3 * * * *", func() { calls += "b" }); err != nil { + t.Fatal(err) + } + + jobs := c.Jobs() + + if len(jobs) != 2 { + t.Fatalf("Expected 2 jobs, got %v", len(jobs)) + } + + for _, j := range jobs { + j.Run() + } + + expectedCalls := "ab" + if calls != expectedCalls { + t.Fatalf("Expected %q calls, got %q", expectedCalls, calls) + } +} + func TestCronStartStop(t *testing.T) { t.Parallel() diff --git a/tools/cron/job.go b/tools/cron/job.go new file mode 100644 index 00000000..477ace6a --- /dev/null +++ b/tools/cron/job.go @@ -0,0 +1,25 @@ +package cron + +// Job defines a single registered cron job. +type Job struct { + fn func() + schedule *Schedule + id string +} + +// Id returns the cron job id. +func (j *Job) Id() string { + return j.id +} + +// Expr returns the plain cron job schedule expression. +func (j *Job) Expr() string { + return j.schedule.rawExpr +} + +// Run runs the cron job function. +func (j *Job) Run() { + if j.fn != nil { + j.fn() + } +} diff --git a/tools/cron/job_test.go b/tools/cron/job_test.go new file mode 100644 index 00000000..40bcc41b --- /dev/null +++ b/tools/cron/job_test.go @@ -0,0 +1,49 @@ +package cron + +import "testing" + +func TestJobId(t *testing.T) { + expected := "test" + + j := Job{id: expected} + + if j.Id() != expected { + t.Fatalf("Expected job with id %q, got %q", expected, j.Id()) + } +} + +func TestJobExpr(t *testing.T) { + expected := "1 2 3 4 5" + + s, err := NewSchedule(expected) + if err != nil { + t.Fatal(err) + } + + j := Job{schedule: s} + + if j.Expr() != expected { + t.Fatalf("Expected job with cron expression %q, got %q", expected, j.Expr()) + } +} + +func TestJobRun(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Shouldn't panic: %v", r) + } + }() + + calls := "" + + j1 := Job{} + j2 := Job{fn: func() { calls += "2" }} + + j1.Run() + j2.Run() + + expected := "2" + if calls != expected { + t.Fatalf("Expected calls %q, got %q", expected, calls) + } +} diff --git a/tools/cron/schedule.go b/tools/cron/schedule.go index 26f281ff..d0199166 100644 --- a/tools/cron/schedule.go +++ b/tools/cron/schedule.go @@ -35,6 +35,8 @@ type Schedule struct { Days map[int]struct{} `json:"days"` Months map[int]struct{} `json:"months"` DaysOfWeek map[int]struct{} `json:"daysOfWeek"` + + rawExpr string } // IsDue checks whether the provided Moment satisfies the current Schedule. @@ -130,6 +132,7 @@ func NewSchedule(cronExpr string) (*Schedule, error) { Days: days, Months: months, DaysOfWeek: daysOfWeek, + rawExpr: cronExpr, }, nil }