├── .codecov.yml
├── .github
└── workflows
│ ├── build.yml
│ └── golangci-lint.yml
├── .gitignore
├── .golangci.yml
├── LICENSE
├── README.md
├── examples
├── main.go
├── print_job.go
├── queue
│ └── file_system.go
└── readme
│ └── main.go
├── go.mod
├── internal
├── assert
│ └── assert.go
├── csm
│ ├── common_node.go
│ ├── cron_state_machine.go
│ ├── day_node.go
│ ├── doc.go
│ ├── fn_find_forward.go
│ ├── fn_next.go
│ ├── node.go
│ └── util.go
└── mock
│ └── http_handler.go
├── job
├── curl_job.go
├── doc.go
├── function_job.go
├── function_job_test.go
├── isolated_job.go
├── job_status.go
├── job_test.go
└── shell_job.go
├── logger
├── doc.go
├── logger.go
├── logger_test.go
├── simple_logger.go
└── slog_logger.go
├── matcher
├── doc.go
├── job_group.go
├── job_matcher_test.go
├── job_name.go
├── job_status.go
└── string_operator.go
└── quartz
├── cron.go
├── cron_test.go
├── csm.go
├── doc.go
├── error.go
├── error_test.go
├── job.go
├── job_detail.go
├── job_detail_test.go
├── job_key.go
├── job_key_test.go
├── matcher.go
├── queue.go
├── queue_test.go
├── scheduler.go
├── scheduler_test.go
├── trigger.go
├── trigger_test.go
└── util.go
/.codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | project:
4 | default:
5 | target: 80%
6 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 | pull_request:
8 | branches:
9 | - master
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | go-version: [1.21.x, 1.23.x]
17 | steps:
18 | - name: Setup Go
19 | uses: actions/setup-go@v5
20 | with:
21 | go-version: ${{ matrix.go-version }}
22 |
23 | - name: Checkout code
24 | uses: actions/checkout@v4
25 |
26 | - name: Run coverage
27 | run: go test -race ./... -coverprofile=coverage.out -covermode=atomic
28 |
29 | - name: Upload coverage to Codecov
30 | if: ${{ matrix.go-version == '1.21.x' }}
31 | uses: codecov/codecov-action@v4
32 | with:
33 | token: ${{ secrets.CODECOV_TOKEN }}
34 |
--------------------------------------------------------------------------------
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: golangci-lint
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 | pull_request:
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | golangci:
14 | name: lint
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout code
18 | uses: actions/checkout@v4
19 |
20 | - name: Get go version from go.mod
21 | run: |
22 | echo "GO_VERSION=$(grep '^go ' go.mod | cut -d " " -f 2)" >> $GITHUB_ENV
23 |
24 | - name: Setup-go
25 | uses: actions/setup-go@v5
26 | with:
27 | go-version: ${{ env.GO_VERSION }}
28 |
29 | - name: Run golangci-lint
30 | uses: golangci/golangci-lint-action@v7
31 | with:
32 | version: v2.1.5
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | .vscode/
3 | examples/examples
4 | coverage.out
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | run:
3 | timeout: 2m
4 | linters:
5 | default: none
6 | enable:
7 | - dupl
8 | - errcheck
9 | - errname
10 | - errorlint
11 | - funlen
12 | - goconst
13 | - gocritic
14 | - gocyclo
15 | - gosec
16 | - govet
17 | - ineffassign
18 | - lll
19 | - misspell
20 | - nolintlint
21 | - prealloc
22 | - reassign
23 | - revive
24 | - staticcheck
25 | - thelper
26 | - tparallel
27 | - unconvert
28 | - unparam
29 | - unused
30 | - whitespace
31 | settings:
32 | errcheck:
33 | exclude-functions:
34 | - (*log.Logger).Output
35 | thelper:
36 | test:
37 | begin: false
38 | exclusions:
39 | generated: lax
40 | presets:
41 | - comments
42 | - common-false-positives
43 | - legacy
44 | - std-error-handling
45 | rules:
46 | - linters:
47 | - funlen
48 | - unparam
49 | path: _test\.go
50 | paths:
51 | - third_party$
52 | - builtin$
53 | - examples$
54 | formatters:
55 | enable:
56 | - gci
57 | - gofmt
58 | - goimports
59 | exclusions:
60 | generated: lax
61 | paths:
62 | - third_party$
63 | - builtin$
64 | - examples$
65 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 reugn
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # go-quartz
2 |
3 | [](https://github.com/reugn/go-quartz/actions/workflows/build.yml)
4 | [](https://pkg.go.dev/github.com/reugn/go-quartz)
5 | [](https://goreportcard.com/report/github.com/reugn/go-quartz)
6 | [](https://codecov.io/gh/reugn/go-quartz)
7 |
8 | A minimalistic and zero-dependency scheduling library for Go.
9 |
10 | ## About
11 |
12 | The implementation is inspired by the design of the [Quartz](https://github.com/quartz-scheduler/quartz)
13 | Java scheduler.
14 |
15 | The core [scheduler](#scheduler-interface) component can be used to manage scheduled [jobs](#job-interface) (tasks)
16 | using [triggers](#trigger-interface).
17 | The implementation of the cron trigger fully supports the Quartz [cron expression format](#cron-expression-format)
18 | and can be used independently to calculate a future time given the previous execution time.
19 |
20 | If you need to run multiple instances of the scheduler, see the [distributed mode](#distributed-mode) section for
21 | guidance.
22 |
23 | ### Library building blocks
24 |
25 | #### Scheduler interface
26 |
27 | ```go
28 | type Scheduler interface {
29 | // Start starts the scheduler. The scheduler will run until
30 | // the Stop method is called or the context is canceled. Use
31 | // the Wait method to block until all running jobs have completed.
32 | Start(context.Context)
33 |
34 | // IsStarted determines whether the scheduler has been started.
35 | IsStarted() bool
36 |
37 | // ScheduleJob schedules a job using the provided Trigger.
38 | ScheduleJob(jobDetail *JobDetail, trigger Trigger) error
39 |
40 | // GetJobKeys returns the keys of scheduled jobs.
41 | // For a job key to be returned, the job must satisfy all of the
42 | // matchers specified.
43 | // Given no matchers, it returns the keys of all scheduled jobs.
44 | GetJobKeys(...Matcher[ScheduledJob]) ([]*JobKey, error)
45 |
46 | // GetScheduledJob returns the scheduled job with the specified key.
47 | GetScheduledJob(jobKey *JobKey) (ScheduledJob, error)
48 |
49 | // DeleteJob removes the job with the specified key from the
50 | // scheduler's execution queue.
51 | DeleteJob(jobKey *JobKey) error
52 |
53 | // PauseJob suspends the job with the specified key from being
54 | // executed by the scheduler.
55 | PauseJob(jobKey *JobKey) error
56 |
57 | // ResumeJob restarts the suspended job with the specified key.
58 | ResumeJob(jobKey *JobKey) error
59 |
60 | // Clear removes all of the scheduled jobs.
61 | Clear() error
62 |
63 | // Wait blocks until the scheduler stops running and all jobs
64 | // have returned. Wait will return when the context passed to
65 | // it has expired. Until the context passed to start is
66 | // cancelled or Stop is called directly.
67 | Wait(context.Context)
68 |
69 | // Stop shutdowns the scheduler.
70 | Stop()
71 | }
72 | ```
73 |
74 | Implemented Schedulers
75 |
76 | - StdScheduler
77 |
78 | #### Trigger interface
79 |
80 | ```go
81 | type Trigger interface {
82 | // NextFireTime returns the next time at which the Trigger is scheduled to fire.
83 | NextFireTime(prev int64) (int64, error)
84 |
85 | // Description returns the description of the Trigger.
86 | Description() string
87 | }
88 | ```
89 |
90 | Implemented Triggers
91 |
92 | - CronTrigger
93 | - SimpleTrigger
94 | - RunOnceTrigger
95 |
96 | #### Job interface
97 |
98 | Any type that implements it can be scheduled.
99 |
100 | ```go
101 | type Job interface {
102 | // Execute is called by a Scheduler when the Trigger associated with this job fires.
103 | Execute(context.Context) error
104 |
105 | // Description returns the description of the Job.
106 | Description() string
107 | }
108 | ```
109 |
110 | Several common Job implementations can be found in the [job](./job) package.
111 |
112 | ## Cron expression format
113 |
114 | | Field Name | Mandatory | Allowed Values | Allowed Special Characters |
115 | |--------------|-----------|-----------------|----------------------------|
116 | | Seconds | YES | 0-59 | , - * / |
117 | | Minutes | YES | 0-59 | , - * / |
118 | | Hours | YES | 0-23 | , - * / |
119 | | Day of month | YES | 1-31 | , - * ? / L W |
120 | | Month | YES | 1-12 or JAN-DEC | , - * / |
121 | | Day of week | YES | 1-7 or SUN-SAT | , - * ? / L # |
122 | | Year | NO | empty, 1970- | , - * / |
123 |
124 | ### Special characters
125 |
126 | - `*`: All values in a field (e.g., `*` in minutes = "every minute").
127 | - `?`: No specific value; use when specifying one of two related fields (e.g., "10" in day-of- month, `?` in
128 | day-of-week).
129 | - `-`: Range of values (e.g., `10-12` in hour = "hours 10, 11, and 12").
130 | - `,`: List of values (e.g., `MON,WED,FRI` in day-of-week = "Monday, Wednesday, Friday").
131 | - `/`: Increments (e.g., `0/15` in seconds = "0, 15, 30, 45"; `1/3` in day-of-month = "every 3 days from the 1st").
132 | - `L`: Last day; meaning varies by field. Ranges or lists are not allowed with `L`.
133 | - Day-of-month: Last day of the month (e.g, `L-3` is the third to last day of the month).
134 | - Day-of-week: Last day of the week (7 or SAT) when alone; "last xxx day" when used after
135 | another value (e.g., `6L` = "last Friday").
136 | - `W`: Nearest weekday in the month to the given day (e.g., `15W` = "nearest weekday to the 15th"). If `1W` on
137 | Saturday, it fires Monday the 3rd. `W` only applies to a single day, not ranges or lists.
138 | - `#`: Nth weekday of the month (e.g., `6#3` = "third Friday"; `2#1` = "first Monday"). Firing does not occur if
139 | that nth weekday does not exist in the month.
140 |
141 | 1 The `L` and `W` characters can also be combined in the day-of-month field to yield `LW`, which
142 | translates to "last weekday of the month".
143 |
144 | 2 The names of months and days of the week are not case-sensitive. MON is the same as mon.
145 |
146 | ## Distributed mode
147 |
148 | The scheduler can use its own implementation of `quartz.JobQueue` to allow state sharing.
149 | An example implementation of the job queue using the file system as a persistence layer
150 | can be found [here](./examples/queue/file_system.go).
151 |
152 | ## Usage example
153 |
154 | ```go
155 | package main
156 |
157 | import (
158 | "context"
159 | "log/slog"
160 | "net/http"
161 | "os"
162 | "time"
163 |
164 | "github.com/reugn/go-quartz/job"
165 | "github.com/reugn/go-quartz/logger"
166 | "github.com/reugn/go-quartz/quartz"
167 | )
168 |
169 | func main() {
170 | ctx, cancel := context.WithCancel(context.Background())
171 | defer cancel()
172 |
173 | // create a scheduler using the logger configuration option
174 | slogLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
175 | scheduler, _ := quartz.NewStdScheduler(quartz.WithLogger(logger.NewSlogLogger(ctx, slogLogger)))
176 |
177 | // start the scheduler
178 | scheduler.Start(ctx)
179 |
180 | // create jobs
181 | cronTrigger, _ := quartz.NewCronTrigger("1/5 * * * * *")
182 | shellJob := job.NewShellJob("ls -la")
183 |
184 | request, _ := http.NewRequest(http.MethodGet, "https://worldtimeapi.org/api/timezone/utc", nil)
185 | curlJob := job.NewCurlJob(request)
186 |
187 | functionJob := job.NewFunctionJob(func(_ context.Context) (int, error) { return 1, nil })
188 |
189 | // register the jobs with the scheduler
190 | _ = scheduler.ScheduleJob(quartz.NewJobDetail(shellJob, quartz.NewJobKey("shellJob")),
191 | cronTrigger)
192 | _ = scheduler.ScheduleJob(quartz.NewJobDetail(curlJob, quartz.NewJobKey("curlJob")),
193 | quartz.NewSimpleTrigger(7*time.Second))
194 | _ = scheduler.ScheduleJob(quartz.NewJobDetail(functionJob, quartz.NewJobKey("functionJob")),
195 | quartz.NewSimpleTrigger(5*time.Second))
196 |
197 | // stop the scheduler
198 | scheduler.Stop()
199 |
200 | // wait for all workers to exit
201 | scheduler.Wait(ctx)
202 | }
203 | ```
204 |
205 | See the examples directory for additional code samples.
206 |
207 | ## License
208 |
209 | Licensed under the MIT License.
210 |
--------------------------------------------------------------------------------
/examples/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 | "net/http"
8 | "os"
9 | "os/signal"
10 | "sync"
11 | "syscall"
12 | "time"
13 |
14 | "github.com/reugn/go-quartz/job"
15 | "github.com/reugn/go-quartz/logger"
16 | "github.com/reugn/go-quartz/quartz"
17 | )
18 |
19 | func main() {
20 | ctx, cancel := context.WithCancel(context.Background())
21 |
22 | go func() {
23 | sigch := make(chan os.Signal, 1)
24 | signal.Notify(sigch, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
25 | <-sigch
26 | cancel()
27 | }()
28 |
29 | var wg sync.WaitGroup
30 | wg.Add(2)
31 |
32 | go sampleJobs(ctx, &wg)
33 | go sampleScheduler(ctx, &wg)
34 |
35 | wg.Wait()
36 | }
37 |
38 | func sampleScheduler(ctx context.Context, wg *sync.WaitGroup) {
39 | defer wg.Done()
40 |
41 | slogLogger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
42 | Level: slog.Level(logger.LevelTrace),
43 | AddSource: true,
44 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
45 | if a.Key == slog.LevelKey {
46 | level := a.Value.Any().(slog.Level)
47 | if level == slog.Level(logger.LevelTrace) {
48 | a.Value = slog.StringValue("TRACE")
49 | }
50 | }
51 | return a
52 | },
53 | }))
54 | sched, err := quartz.NewStdScheduler(quartz.WithLogger(logger.NewSlogLogger(ctx, slogLogger)))
55 | if err != nil {
56 | fmt.Println(err)
57 | return
58 | }
59 |
60 | cronTrigger, err := quartz.NewCronTrigger("1/3 * * * * *")
61 | if err != nil {
62 | fmt.Println(err)
63 | return
64 | }
65 |
66 | cronJob := quartz.NewJobDetail(&PrintJob{"Cron job"}, quartz.NewJobKey("cronJob"))
67 | sched.Start(ctx)
68 |
69 | runOnceJobDetail := quartz.NewJobDetail(&PrintJob{"Ad hoc Job"}, quartz.NewJobKey("runOnceJob"))
70 | jobDetail1 := quartz.NewJobDetail(&PrintJob{"First job"}, quartz.NewJobKey("job1"))
71 | jobDetail2 := quartz.NewJobDetail(&PrintJob{"Second job"}, quartz.NewJobKey("job2"))
72 | jobDetail3 := quartz.NewJobDetail(&PrintJob{"Third job"}, quartz.NewJobKey("job3"))
73 | _ = sched.ScheduleJob(runOnceJobDetail, quartz.NewRunOnceTrigger(5*time.Second))
74 | _ = sched.ScheduleJob(jobDetail1, quartz.NewSimpleTrigger(12*time.Second))
75 | _ = sched.ScheduleJob(jobDetail2, quartz.NewSimpleTrigger(6*time.Second))
76 | _ = sched.ScheduleJob(jobDetail3, quartz.NewSimpleTrigger(3*time.Second))
77 | _ = sched.ScheduleJob(cronJob, cronTrigger)
78 |
79 | time.Sleep(10 * time.Second)
80 |
81 | scheduledJob, err := sched.GetScheduledJob(cronJob.JobKey())
82 | if err != nil {
83 | fmt.Println(err)
84 | return
85 | }
86 |
87 | fmt.Println(scheduledJob.Trigger().Description())
88 | jobKeys, _ := sched.GetJobKeys()
89 | fmt.Println("Before delete: ", jobKeys)
90 | _ = sched.DeleteJob(cronJob.JobKey())
91 | jobKeys, _ = sched.GetJobKeys()
92 | fmt.Println("After delete: ", jobKeys)
93 |
94 | time.Sleep(2 * time.Second)
95 |
96 | sched.Stop()
97 | sched.Wait(ctx)
98 | }
99 |
100 | func sampleJobs(ctx context.Context, wg *sync.WaitGroup) {
101 | defer wg.Done()
102 | sched, err := quartz.NewStdScheduler()
103 | if err != nil {
104 | fmt.Println(err)
105 | return
106 | }
107 |
108 | sched.Start(ctx)
109 |
110 | cronTrigger, err := quartz.NewCronTrigger("1/5 * * * * *")
111 | if err != nil {
112 | fmt.Println(err)
113 | return
114 | }
115 |
116 | shellJob := job.NewShellJob("ls -la")
117 | request, err := http.NewRequest(http.MethodGet, "https://worldtimeapi.org/api/timezone/utc", nil)
118 | if err != nil {
119 | fmt.Println(err)
120 | return
121 | }
122 |
123 | curlJob := job.NewCurlJob(request)
124 | functionJob := job.NewFunctionJobWithDesc(
125 | func(_ context.Context) (int, error) { return 42, nil },
126 | "42")
127 |
128 | shellJobDetail := quartz.NewJobDetail(shellJob, quartz.NewJobKey("shellJob"))
129 | curlJobDetail := quartz.NewJobDetail(curlJob, quartz.NewJobKey("curlJob"))
130 | functionJobDetail := quartz.NewJobDetail(functionJob, quartz.NewJobKey("functionJob"))
131 | _ = sched.ScheduleJob(shellJobDetail, cronTrigger)
132 | _ = sched.ScheduleJob(curlJobDetail, quartz.NewSimpleTrigger(7*time.Second))
133 | _ = sched.ScheduleJob(functionJobDetail, quartz.NewSimpleTrigger(3*time.Second))
134 |
135 | time.Sleep(10 * time.Second)
136 |
137 | fmt.Println(sched.GetJobKeys())
138 | fmt.Println(shellJob.Stdout())
139 |
140 | response, err := curlJob.DumpResponse(true)
141 | if err != nil {
142 | fmt.Println(err)
143 | } else {
144 | fmt.Println(string(response))
145 | }
146 | fmt.Printf("Function job result: %v\n", functionJob.Result())
147 |
148 | time.Sleep(2 * time.Second)
149 |
150 | sched.Stop()
151 | sched.Wait(ctx)
152 | }
153 |
--------------------------------------------------------------------------------
/examples/print_job.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/reugn/go-quartz/quartz"
8 | )
9 |
10 | // PrintJob implements the quartz.Job interface.
11 | type PrintJob struct {
12 | desc string
13 | }
14 |
15 | var _ quartz.Job = (*PrintJob)(nil)
16 |
17 | // Description returns the description of the PrintJob.
18 | func (pj *PrintJob) Description() string {
19 | return pj.desc
20 | }
21 |
22 | // Execute is called by a Scheduler when the Trigger associated with this job fires.
23 | func (pj *PrintJob) Execute(_ context.Context) error {
24 | fmt.Println("Executing " + pj.Description())
25 | return nil
26 | }
27 |
--------------------------------------------------------------------------------
/examples/queue/file_system.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "io/fs"
8 | "log"
9 | "math"
10 | "os"
11 | "strconv"
12 | "strings"
13 | "sync"
14 | "time"
15 |
16 | "github.com/reugn/go-quartz/logger"
17 | "github.com/reugn/go-quartz/quartz"
18 | )
19 |
20 | const (
21 | dataFolder = "./store"
22 | fileMode fs.FileMode = 0744
23 | )
24 |
25 | func init() {
26 | quartz.Sep = "_"
27 | }
28 |
29 | func main() {
30 | ctx, cancel := context.WithTimeout(context.Background(), 31*time.Second)
31 | defer cancel()
32 |
33 | stdLogger := log.New(os.Stdout, "", log.LstdFlags|log.Lmsgprefix|log.Lshortfile)
34 | l := logger.NewSimpleLogger(stdLogger, logger.LevelTrace)
35 |
36 | if _, err := os.Stat(dataFolder); os.IsNotExist(err) {
37 | if err := os.Mkdir(dataFolder, fileMode); err != nil {
38 | l.Warn("Failed to create data folder", "error", err)
39 | return
40 | }
41 | }
42 |
43 | l.Info("Starting scheduler")
44 | jobQueue := newJobQueue(l)
45 | scheduler, err := quartz.NewStdScheduler(
46 | quartz.WithOutdatedThreshold(time.Second), // considering file system I/O latency
47 | quartz.WithQueue(jobQueue, &sync.Mutex{}),
48 | quartz.WithLogger(l),
49 | )
50 | if err != nil {
51 | l.Error("Failed to create scheduler", "error", err)
52 | return
53 | }
54 |
55 | scheduler.Start(ctx)
56 |
57 | jobQueueSize, err := jobQueue.Size()
58 | if err != nil {
59 | l.Error("Failed to fetch job queue size", "error", err)
60 | return
61 | }
62 |
63 | if jobQueueSize == 0 {
64 | l.Info("Scheduling new jobs")
65 | jobDetail1 := quartz.NewJobDetail(newPrintJob(5, l), quartz.NewJobKey("job1"))
66 | if err := scheduler.ScheduleJob(jobDetail1, quartz.NewSimpleTrigger(5*time.Second)); err != nil {
67 | l.Warn("Failed to schedule job", "key", jobDetail1.JobKey().String(), "error", err)
68 | }
69 | jobDetail2 := quartz.NewJobDetail(newPrintJob(10, l), quartz.NewJobKey("job2"))
70 | if err := scheduler.ScheduleJob(jobDetail2, quartz.NewSimpleTrigger(10*time.Second)); err != nil {
71 | l.Warn("Failed to schedule job", "key", jobDetail2.JobKey().String(), "error", err)
72 | }
73 | } else {
74 | l.Info("Job queue is not empty")
75 | }
76 |
77 | <-ctx.Done()
78 |
79 | scheduledJobs, err := jobQueue.ScheduledJobs(nil)
80 | if err != nil {
81 | l.Error("Failed to fetch scheduled jobs", "error", err)
82 | return
83 | }
84 |
85 | jobNames := make([]string, 0, len(scheduledJobs))
86 | for _, job := range scheduledJobs {
87 | jobNames = append(jobNames, job.JobDetail().JobKey().String())
88 | }
89 |
90 | l.Info("Jobs in queue", "names", jobNames)
91 | }
92 |
93 | // printJob
94 | type printJob struct {
95 | seconds int
96 | logger logger.Logger
97 | }
98 |
99 | var _ quartz.Job = (*printJob)(nil)
100 |
101 | func newPrintJob(seconds int, logger logger.Logger) *printJob {
102 | return &printJob{
103 | seconds: seconds,
104 | logger: logger,
105 | }
106 | }
107 |
108 | func (job *printJob) Execute(_ context.Context) error {
109 | job.logger.Info(fmt.Sprintf("PrintJob: %d", job.seconds))
110 | return nil
111 | }
112 |
113 | func (job *printJob) Description() string {
114 | return fmt.Sprintf("PrintJob%s%d", quartz.Sep, job.seconds)
115 | }
116 |
117 | // scheduledPrintJob
118 | type scheduledPrintJob struct {
119 | jobDetail *quartz.JobDetail
120 | trigger quartz.Trigger
121 | nextRunTime int64
122 | }
123 |
124 | // serializedJob
125 | type serializedJob struct {
126 | Job string `json:"job"`
127 | JobKey string `json:"job_key"`
128 | Options *quartz.JobDetailOptions `json:"job_options"`
129 |
130 | Trigger string `json:"trigger"`
131 | NextRunTime int64 `json:"next_run_time"`
132 | }
133 |
134 | var _ quartz.ScheduledJob = (*scheduledPrintJob)(nil)
135 |
136 | func (job *scheduledPrintJob) JobDetail() *quartz.JobDetail { return job.jobDetail }
137 | func (job *scheduledPrintJob) Trigger() quartz.Trigger { return job.trigger }
138 | func (job *scheduledPrintJob) NextRunTime() int64 { return job.nextRunTime }
139 |
140 | // marshal returns the JSON encoding of the job.
141 | func marshal(job quartz.ScheduledJob) ([]byte, error) {
142 | var serialized serializedJob
143 | serialized.Job = job.JobDetail().Job().Description()
144 | serialized.JobKey = job.JobDetail().JobKey().String()
145 | serialized.Options = job.JobDetail().Options()
146 | serialized.Trigger = job.Trigger().Description()
147 | serialized.NextRunTime = job.NextRunTime()
148 |
149 | return json.Marshal(serialized)
150 | }
151 |
152 | // unmarshal parses the JSON-encoded job.
153 | func unmarshal(encoded []byte, l logger.Logger) (quartz.ScheduledJob, error) {
154 | var serialized serializedJob
155 | if err := json.Unmarshal(encoded, &serialized); err != nil {
156 | return nil, err
157 | }
158 |
159 | jobVals := strings.Split(serialized.Job, quartz.Sep)
160 | i, err := strconv.Atoi(jobVals[1])
161 | if err != nil {
162 | return nil, err
163 | }
164 |
165 | job := newPrintJob(i, l) // assuming we know the job type
166 | jobKeyVals := strings.Split(serialized.JobKey, quartz.Sep)
167 | jobKey := quartz.NewJobKeyWithGroup(jobKeyVals[1], jobKeyVals[0])
168 | jobDetail := quartz.NewJobDetailWithOptions(job, jobKey, serialized.Options)
169 | triggerOpts := strings.Split(serialized.Trigger, quartz.Sep)
170 | interval, _ := time.ParseDuration(triggerOpts[1])
171 | trigger := quartz.NewSimpleTrigger(interval) // assuming we know the trigger type
172 |
173 | return &scheduledPrintJob{
174 | jobDetail: jobDetail,
175 | trigger: trigger,
176 | nextRunTime: serialized.NextRunTime,
177 | }, nil
178 | }
179 |
180 | // jobQueue implements the quartz.JobQueue interface, using the file system
181 | // as the persistence layer.
182 | type jobQueue struct {
183 | mtx sync.Mutex
184 | logger logger.Logger
185 | }
186 |
187 | var _ quartz.JobQueue = (*jobQueue)(nil)
188 |
189 | // newJobQueue initializes and returns an empty jobQueue.
190 | func newJobQueue(logger logger.Logger) *jobQueue {
191 | return &jobQueue{logger: logger}
192 | }
193 |
194 | // Push inserts a new scheduled job to the queue.
195 | // This method is also used by the Scheduler to reschedule existing jobs that
196 | // have been dequeued for execution.
197 | func (jq *jobQueue) Push(job quartz.ScheduledJob) error {
198 | jq.mtx.Lock()
199 | defer jq.mtx.Unlock()
200 |
201 | jq.logger.Trace("Push job", "key", job.JobDetail().JobKey().String())
202 | serialized, err := marshal(job)
203 | if err != nil {
204 | return err
205 | }
206 | if err = os.WriteFile(fmt.Sprintf("%s/%d", dataFolder, job.NextRunTime()),
207 | serialized, fileMode); err != nil {
208 | jq.logger.Error("Failed to write job", "error", err)
209 | return err
210 | }
211 | return nil
212 | }
213 |
214 | // Pop removes and returns the next scheduled job from the queue.
215 | func (jq *jobQueue) Pop() (quartz.ScheduledJob, error) {
216 | jq.mtx.Lock()
217 | defer jq.mtx.Unlock()
218 |
219 | jq.logger.Trace("Pop")
220 | job, err := jq.findHead()
221 | if err == nil {
222 | if err = os.Remove(fmt.Sprintf("%s/%d", dataFolder, job.NextRunTime())); err != nil {
223 | jq.logger.Error("Failed to delete job", "error", err)
224 | return nil, err
225 | }
226 | return job, nil
227 | }
228 | jq.logger.Error("Failed to find job", "error", err)
229 | return nil, err
230 | }
231 |
232 | // Head returns the first scheduled job without removing it from the queue.
233 | func (jq *jobQueue) Head() (quartz.ScheduledJob, error) {
234 | jq.mtx.Lock()
235 | defer jq.mtx.Unlock()
236 |
237 | jq.logger.Trace("Head")
238 | job, err := jq.findHead()
239 | if err != nil {
240 | jq.logger.Error("Failed to find job", "error", err)
241 | }
242 | return job, err
243 | }
244 |
245 | func (jq *jobQueue) findHead() (quartz.ScheduledJob, error) {
246 | fileInfo, err := os.ReadDir(dataFolder)
247 | if err != nil {
248 | return nil, err
249 | }
250 | var lastUpdate int64 = math.MaxInt64
251 | for _, file := range fileInfo {
252 | if !file.IsDir() {
253 | nextTime, err := strconv.ParseInt(file.Name(), 10, 64)
254 | if err == nil && nextTime < lastUpdate {
255 | lastUpdate = nextTime
256 | }
257 | }
258 | }
259 | if lastUpdate == math.MaxInt64 {
260 | return nil, quartz.ErrJobNotFound
261 | }
262 | data, err := os.ReadFile(fmt.Sprintf("%s/%d", dataFolder, lastUpdate))
263 | if err != nil {
264 | return nil, err
265 | }
266 | job, err := unmarshal(data, jq.logger)
267 | if err != nil {
268 | return nil, err
269 | }
270 | return job, nil
271 | }
272 |
273 | // Get returns the scheduled job with the specified key without removing it
274 | // from the queue.
275 | func (jq *jobQueue) Get(jobKey *quartz.JobKey) (quartz.ScheduledJob, error) {
276 | jq.mtx.Lock()
277 | defer jq.mtx.Unlock()
278 |
279 | jq.logger.Trace("Get")
280 | fileInfo, err := os.ReadDir(dataFolder)
281 | if err != nil {
282 | return nil, err
283 | }
284 | for _, file := range fileInfo {
285 | if !file.IsDir() {
286 | data, err := os.ReadFile(fmt.Sprintf("%s/%s", dataFolder, file.Name()))
287 | if err == nil {
288 | job, err := unmarshal(data, jq.logger)
289 | if err == nil {
290 | if jobKey.Equals(job.JobDetail().JobKey()) {
291 | return job, nil
292 | }
293 | }
294 | }
295 | }
296 | }
297 | return nil, quartz.ErrJobNotFound
298 | }
299 |
300 | // Remove removes and returns the scheduled job with the specified key.
301 | func (jq *jobQueue) Remove(jobKey *quartz.JobKey) (quartz.ScheduledJob, error) {
302 | jq.mtx.Lock()
303 | defer jq.mtx.Unlock()
304 |
305 | jq.logger.Trace("Remove")
306 | fileInfo, err := os.ReadDir(dataFolder)
307 | if err != nil {
308 | return nil, err
309 | }
310 | for _, file := range fileInfo {
311 | if !file.IsDir() {
312 | path := fmt.Sprintf("%s/%s", dataFolder, file.Name())
313 | data, err := os.ReadFile(path)
314 | if err == nil {
315 | job, err := unmarshal(data, jq.logger)
316 | if err == nil {
317 | if jobKey.Equals(job.JobDetail().JobKey()) {
318 | if err = os.Remove(path); err == nil {
319 | return job, nil
320 | }
321 | }
322 | }
323 | }
324 | }
325 | }
326 | return nil, quartz.ErrJobNotFound
327 | }
328 |
329 | // ScheduledJobs returns the slice of all scheduled jobs in the queue.
330 | func (jq *jobQueue) ScheduledJobs(
331 | matchers []quartz.Matcher[quartz.ScheduledJob],
332 | ) ([]quartz.ScheduledJob, error) {
333 | jq.mtx.Lock()
334 | defer jq.mtx.Unlock()
335 |
336 | jq.logger.Trace("ScheduledJobs")
337 | var jobs []quartz.ScheduledJob
338 | fileInfo, err := os.ReadDir(dataFolder)
339 | if err != nil {
340 | return nil, err
341 | }
342 | for _, file := range fileInfo {
343 | if !file.IsDir() {
344 | data, err := os.ReadFile(fmt.Sprintf("%s/%s", dataFolder, file.Name()))
345 | if err == nil {
346 | job, err := unmarshal(data, jq.logger)
347 | if err == nil && isMatch(job, matchers) {
348 | jobs = append(jobs, job)
349 | }
350 | }
351 | }
352 | }
353 | return jobs, nil
354 | }
355 |
356 | func isMatch(job quartz.ScheduledJob, matchers []quartz.Matcher[quartz.ScheduledJob]) bool {
357 | for _, matcher := range matchers {
358 | // require all matchers to match the job
359 | if !matcher.IsMatch(job) {
360 | return false
361 | }
362 | }
363 | return true
364 | }
365 |
366 | // Size returns the size of the job queue.
367 | func (jq *jobQueue) Size() (int, error) {
368 | jq.mtx.Lock()
369 | defer jq.mtx.Unlock()
370 |
371 | jq.logger.Trace("Size")
372 | files, err := os.ReadDir(dataFolder)
373 | if err != nil {
374 | return 0, err
375 | }
376 | return len(files), nil
377 | }
378 |
379 | // Clear clears the job queue.
380 | func (jq *jobQueue) Clear() error {
381 | jq.mtx.Lock()
382 | defer jq.mtx.Unlock()
383 |
384 | jq.logger.Trace("Clear")
385 | return os.RemoveAll(dataFolder)
386 | }
387 |
--------------------------------------------------------------------------------
/examples/readme/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "net/http"
7 | "os"
8 | "time"
9 |
10 | "github.com/reugn/go-quartz/job"
11 | "github.com/reugn/go-quartz/logger"
12 | "github.com/reugn/go-quartz/quartz"
13 | )
14 |
15 | func main() {
16 | ctx, cancel := context.WithCancel(context.Background())
17 | defer cancel()
18 |
19 | // create a scheduler using the logger configuration option
20 | slogLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
21 | scheduler, _ := quartz.NewStdScheduler(quartz.WithLogger(logger.NewSlogLogger(ctx, slogLogger)))
22 |
23 | // start the scheduler
24 | scheduler.Start(ctx)
25 |
26 | // create jobs
27 | cronTrigger, _ := quartz.NewCronTrigger("1/5 * * * * *")
28 | shellJob := job.NewShellJob("ls -la")
29 |
30 | request, _ := http.NewRequest(http.MethodGet, "https://worldtimeapi.org/api/timezone/utc", nil)
31 | curlJob := job.NewCurlJob(request)
32 |
33 | functionJob := job.NewFunctionJob(func(_ context.Context) (int, error) { return 1, nil })
34 |
35 | // register the jobs with the scheduler
36 | _ = scheduler.ScheduleJob(quartz.NewJobDetail(shellJob, quartz.NewJobKey("shellJob")),
37 | cronTrigger)
38 | _ = scheduler.ScheduleJob(quartz.NewJobDetail(curlJob, quartz.NewJobKey("curlJob")),
39 | quartz.NewSimpleTrigger(7*time.Second))
40 | _ = scheduler.ScheduleJob(quartz.NewJobDetail(functionJob, quartz.NewJobKey("functionJob")),
41 | quartz.NewSimpleTrigger(5*time.Second))
42 |
43 | // stop the scheduler
44 | scheduler.Stop()
45 |
46 | // wait for all workers to exit
47 | scheduler.Wait(ctx)
48 | }
49 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/reugn/go-quartz
2 |
3 | go 1.21
4 |
--------------------------------------------------------------------------------
/internal/assert/assert.go:
--------------------------------------------------------------------------------
1 | package assert
2 |
3 | import (
4 | "errors"
5 | "reflect"
6 | "strings"
7 | "testing"
8 | )
9 |
10 | // Equal verifies equality of two objects.
11 | func Equal[T any](t *testing.T, a T, b T) {
12 | if !reflect.DeepEqual(a, b) {
13 | t.Helper()
14 | t.Fatalf("%v != %v", a, b)
15 | }
16 | }
17 |
18 | // NotEqual verifies objects are not equal.
19 | func NotEqual[T any](t *testing.T, a T, b T) {
20 | if reflect.DeepEqual(a, b) {
21 | t.Helper()
22 | t.Fatalf("%v == %v", a, b)
23 | }
24 | }
25 |
26 | // IsNil verifies that the object is nil.
27 | func IsNil(t *testing.T, obj any) {
28 | if obj != nil {
29 | value := reflect.ValueOf(obj)
30 | switch value.Kind() {
31 | case reflect.Ptr, reflect.Map, reflect.Slice,
32 | reflect.Interface, reflect.Func, reflect.Chan:
33 | if value.IsNil() {
34 | return
35 | }
36 | }
37 | t.Helper()
38 | t.Fatalf("%v is not nil", obj)
39 | }
40 | }
41 |
42 | // ErrorContains checks whether the given error contains the specified string.
43 | func ErrorContains(t *testing.T, err error, str string) {
44 | if err == nil {
45 | t.Helper()
46 | t.Fatalf("Error is nil")
47 | } else if !strings.Contains(err.Error(), str) {
48 | t.Helper()
49 | t.Fatalf("Error does not contain string: %s", str)
50 | }
51 | }
52 |
53 | // ErrorIs checks whether any error in err's tree matches target.
54 | func ErrorIs(t *testing.T, err error, target error) {
55 | if !errors.Is(err, target) {
56 | t.Helper()
57 | t.Fatalf("Error type mismatch: %v != %v", err, target)
58 | }
59 | }
60 |
61 | // Panics checks whether the given function panics.
62 | func Panics(t *testing.T, f func()) {
63 | t.Helper()
64 | defer func() {
65 | if r := recover(); r == nil {
66 | t.Helper()
67 | t.Fatalf("Function did not panic")
68 | }
69 | }()
70 | f()
71 | }
72 |
--------------------------------------------------------------------------------
/internal/csm/common_node.go:
--------------------------------------------------------------------------------
1 | package csm
2 |
3 | type CommonNode struct {
4 | value int
5 | min int
6 | max int
7 | values []int
8 | }
9 |
10 | var _ csmNode = (*CommonNode)(nil)
11 |
12 | func NewCommonNode(value, lowerBound, upperBound int, values []int) *CommonNode {
13 | return &CommonNode{value, lowerBound, upperBound, values}
14 | }
15 |
16 | func (n *CommonNode) Value() int {
17 | return n.value
18 | }
19 |
20 | func (n *CommonNode) Reset() {
21 | n.value = n.max
22 | n.Next()
23 | }
24 |
25 | func (n *CommonNode) Next() (overflowed bool) {
26 | if n.hasRange() {
27 | return n.nextInRange()
28 | }
29 |
30 | return n.next()
31 | }
32 |
33 | func (n *CommonNode) findForward() result {
34 | if !n.isValid() {
35 | if n.Next() {
36 | return overflowed
37 | }
38 | return advanced
39 | }
40 | return unchanged
41 | }
42 |
43 | func (n *CommonNode) hasRange() bool {
44 | return len(n.values) != 0
45 | }
46 |
47 | func (n *CommonNode) next() bool {
48 | n.value++
49 | if n.value > n.max {
50 | n.value = n.min
51 | return true
52 | }
53 | return false
54 | }
55 |
56 | func (n *CommonNode) nextInRange() bool {
57 | // find the next value in the range (assuming n.values is sorted)
58 | for _, value := range n.values {
59 | if value > n.value {
60 | n.value = value
61 | return false
62 | }
63 | }
64 |
65 | // the end of the values array is reached; set to the first valid value
66 | n.value = n.values[0]
67 | return true
68 | }
69 |
70 | func (n *CommonNode) isValid() bool {
71 | withinLimits := n.value >= n.min && n.value <= n.max
72 | if n.hasRange() {
73 | withinLimits = withinLimits && contains(n.values, n.value)
74 | }
75 | return withinLimits
76 | }
77 |
--------------------------------------------------------------------------------
/internal/csm/cron_state_machine.go:
--------------------------------------------------------------------------------
1 | package csm
2 |
3 | import "time"
4 |
5 | type NodeID int
6 |
7 | const (
8 | seconds NodeID = iota
9 | minutes
10 | hours
11 | days
12 | months
13 | years
14 | )
15 |
16 | type CronStateMachine struct {
17 | second csmNode
18 | minute csmNode
19 | hour csmNode
20 | day *DayNode
21 | month csmNode
22 | year csmNode
23 | }
24 |
25 | func NewCronStateMachine(second, minute, hour csmNode, day *DayNode, month, year csmNode) *CronStateMachine {
26 | return &CronStateMachine{second, minute, hour, day, month, year}
27 | }
28 |
29 | func (csm *CronStateMachine) Value() time.Time {
30 | return csm.ValueWithLocation(time.UTC)
31 | }
32 |
33 | func (csm *CronStateMachine) ValueWithLocation(loc *time.Location) time.Time {
34 | return time.Date(
35 | csm.year.Value(),
36 | time.Month(csm.month.Value()),
37 | csm.day.Value(),
38 | csm.hour.Value(),
39 | csm.minute.Value(),
40 | csm.second.Value(),
41 | 0, loc,
42 | )
43 | }
44 |
45 | func (csm *CronStateMachine) NextTriggerTime(loc *time.Location) time.Time {
46 | csm.findForward()
47 | return csm.ValueWithLocation(loc)
48 | }
49 |
--------------------------------------------------------------------------------
/internal/csm/day_node.go:
--------------------------------------------------------------------------------
1 | package csm
2 |
3 | import "time"
4 |
5 | const (
6 | NLastDayOfMonth = 1
7 | NWeekday = 2
8 | )
9 |
10 | type DayNode struct {
11 | c CommonNode
12 | weekdayValues []int
13 | n int
14 | month csmNode
15 | year csmNode
16 | }
17 |
18 | var _ csmNode = (*DayNode)(nil)
19 |
20 | func NewMonthDayNode(value, lowerBound, upperBound, n int, dayOfMonthValues []int,
21 | month, year csmNode) *DayNode {
22 | return &DayNode{
23 | c: CommonNode{value, lowerBound, upperBound, dayOfMonthValues},
24 | weekdayValues: make([]int, 0),
25 | n: n,
26 | month: month,
27 | year: year,
28 | }
29 | }
30 |
31 | func NewWeekDayNode(value, lowerBound, upperBound, n int, dayOfWeekValues []int,
32 | month, year csmNode) *DayNode {
33 | return &DayNode{
34 | c: CommonNode{value, lowerBound, upperBound, make([]int, 0)},
35 | weekdayValues: dayOfWeekValues,
36 | n: n,
37 | month: month,
38 | year: year,
39 | }
40 | }
41 |
42 | func (n *DayNode) Value() int {
43 | return n.c.Value()
44 | }
45 |
46 | func (n *DayNode) Reset() {
47 | n.c.value = n.c.min
48 | n.findForward()
49 | }
50 |
51 | func (n *DayNode) Next() (overflowed bool) {
52 | if n.isWeekday() {
53 | if n.n == 0 {
54 | return n.nextWeekday()
55 | }
56 | return n.nextWeekdayN()
57 | }
58 | if n.n == 0 {
59 | return n.nextDay()
60 | }
61 | return n.nextDayN()
62 | }
63 |
64 | func (n *DayNode) nextWeekday() (overflowed bool) {
65 | // the weekday of the previous scheduled time
66 | weekday := n.getWeekday()
67 |
68 | // the offset in days from the previous to the next day
69 | offset := 7 + n.weekdayValues[0] - weekday
70 | // find the next value in the range (assuming weekdays is sorted)
71 | for _, value := range n.weekdayValues {
72 | if value > weekday {
73 | offset = value - weekday
74 | break
75 | }
76 | }
77 |
78 | // if the end of the values array is reached set to the first valid value
79 | return n.addDays(offset)
80 | }
81 |
82 | func (n *DayNode) nextDay() (overflowed bool) {
83 | return n.c.Next()
84 | }
85 |
86 | func (n *DayNode) findForward() result {
87 | if !n.isValid() {
88 | if n.Next() {
89 | return overflowed
90 | }
91 | return advanced
92 | }
93 | return unchanged
94 | }
95 |
96 | func (n *DayNode) isValid() bool {
97 | withinLimits := n.isValidDay()
98 | if n.isWeekday() {
99 | withinLimits = withinLimits && n.isValidWeekday()
100 | }
101 | return withinLimits
102 | }
103 |
104 | func (n *DayNode) isValidWeekday() bool {
105 | return contains(n.weekdayValues, n.getWeekday())
106 | }
107 |
108 | func (n *DayNode) isValidDay() bool {
109 | return n.c.isValid() && n.c.value <= n.max()
110 | }
111 |
112 | func (n *DayNode) isWeekday() bool {
113 | return len(n.weekdayValues) != 0
114 | }
115 |
116 | func (n *DayNode) getWeekday() int {
117 | date := makeDateTime(n.year.Value(), n.month.Value(), n.c.value)
118 | return int(date.Weekday())
119 | }
120 |
121 | func (n *DayNode) addDays(offset int) (overflowed bool) {
122 | overflowed = n.Value()+offset > n.max()
123 | today := makeDateTime(n.year.Value(), n.month.Value(), n.c.value)
124 | newDate := today.AddDate(0, 0, offset)
125 | n.c.value = newDate.Day()
126 | return
127 | }
128 |
129 | func (n *DayNode) max() int {
130 | month := time.Month(n.month.Value())
131 | year := n.year.Value()
132 |
133 | if month == time.December {
134 | month = 1
135 | year++
136 | } else {
137 | month++
138 | }
139 |
140 | date := makeDateTime(year, int(month), 0)
141 | return date.Day()
142 | }
143 |
144 | func (n *DayNode) nextDayN() (overflowed bool) {
145 | switch {
146 | case n.n > 0 && n.n&NWeekday != 0:
147 | n.nextWeekdayOfMonth()
148 | default:
149 | n.nextLastDayOfMonth()
150 | }
151 | return
152 | }
153 |
154 | func (n *DayNode) nextWeekdayOfMonth() {
155 | year := n.year.Value()
156 | month := n.month.Value()
157 |
158 | monthLastDate := lastDayOfMonth(year, month)
159 | date := n.c.values[0]
160 | if date > monthLastDate || n.n&NLastDayOfMonth != 0 {
161 | date = monthLastDate
162 | }
163 |
164 | monthDate := makeDateTime(year, month, date)
165 | closest := closestWeekday(monthDate)
166 | if n.c.value >= closest {
167 | n.c.value = 0
168 | n.advanceMonth()
169 | n.nextWeekdayOfMonth()
170 | return
171 | }
172 |
173 | n.c.value = closest
174 | }
175 |
176 | func (n *DayNode) nextLastDayOfMonth() {
177 | year := n.year.Value()
178 | month := n.month.Value()
179 |
180 | firstDayOfMonth := makeDateTime(year, month, 1)
181 | offset := n.n
182 | if offset == NLastDayOfMonth {
183 | offset = 0
184 | }
185 | dayOfMonth := firstDayOfMonth.AddDate(0, 1, offset-1)
186 |
187 | if n.c.value >= dayOfMonth.Day() {
188 | n.c.value = 0
189 | n.advanceMonth()
190 | n.nextLastDayOfMonth()
191 | return
192 | }
193 |
194 | n.c.value = dayOfMonth.Day()
195 | }
196 |
197 | func (n *DayNode) nextWeekdayN() (overflowed bool) {
198 | n.c.value = n.getDayInMonth(n.daysOfWeekInMonth())
199 | return
200 | }
201 |
202 | func (n *DayNode) getDayInMonth(dates []int) int {
203 | if n.n > len(dates) {
204 | n.advanceMonth()
205 | return n.getDayInMonth(n.daysOfWeekInMonth())
206 | }
207 |
208 | var dayInMonth int
209 | if n.n > 0 {
210 | dayInMonth = dates[n.n-1]
211 | } else {
212 | dayInMonth = dates[len(dates)-1]
213 | }
214 |
215 | if n.c.value >= dayInMonth {
216 | n.c.value = 0
217 | n.advanceMonth()
218 | return n.getDayInMonth(n.daysOfWeekInMonth())
219 | }
220 |
221 | return dayInMonth
222 | }
223 |
224 | func (n *DayNode) advanceMonth() {
225 | if n.month.Next() {
226 | _ = n.year.Next()
227 | }
228 | }
229 |
230 | func (n *DayNode) daysOfWeekInMonth() []int {
231 | year := n.year.Value()
232 | month := n.month.Value()
233 |
234 | // the day of week specified for the node
235 | weekday := n.weekdayValues[0]
236 |
237 | dates := make([]int, 0, 5)
238 | // iterate through all the days of the month
239 | for day := 1; ; day++ {
240 | currentDate := makeDateTime(year, month, day)
241 | // stop if we have reached the next month
242 | if currentDate.Month() != time.Month(month) {
243 | break
244 | }
245 | // check if the current day is the required day of the week
246 | if int(currentDate.Weekday()) == weekday {
247 | dates = append(dates, day)
248 | }
249 | }
250 |
251 | return dates
252 | }
253 |
--------------------------------------------------------------------------------
/internal/csm/doc.go:
--------------------------------------------------------------------------------
1 | // Package csm is an internal package focused on solving a single task.
2 | // Given an arbitrary date and cron expression, what is the first following
3 | // instant that fits the expression?
4 | //
5 | // The method CronStateMachine.NextTriggerTime() (cron_state_machine.go)
6 | // computes the answer. The current solution is not proven to be mathematically correct.
7 | // However, it has been thoroughly validated with multiple complex test cases.
8 | //
9 | // A date can be though of as a mixed-radix number (https://en.wikipedia.org/wiki/Mixed_radix).
10 | // First, we must check from most significant (year) to least significant (second) for
11 | // any field that is invalid according to the cron expression, and move it forward to the
12 | // next valid value. This resets less significant fields and can overflow; advancing more
13 | // significant fields. This process is implemented in CronStateMachine.findForward()
14 | // (fn_find_forward.go).
15 | //
16 | // Second, if no fields are changed by this process, we must perform the smallest
17 | // possible step forward to find the next valid value. This involves moving forward
18 | // the least significant field (second), taking care to advance the next significant
19 | // field when the previous one overflows. This process is implemented in CronStateMachine.next()
20 | // (fn_next.go).
21 | //
22 | // NOTE: Some precautions must be taken as the "day" value does not have a constant radix.
23 | // It depends on the month and the year. January always has 30 days, while February 2024 has 29.
24 | // This is taken into account by the DayNode struct (day_node.go) and CronStateMachine.next()
25 | // (fn_next.go).
26 | package csm
27 |
--------------------------------------------------------------------------------
/internal/csm/fn_find_forward.go:
--------------------------------------------------------------------------------
1 | package csm
2 |
3 | func (csm *CronStateMachine) findForward() {
4 | // Initial find, checking from most to least significant
5 | nodes := []NodeID{years, months, days, hours, minutes, seconds}
6 | for _, nodeID := range nodes {
7 | node := csm.selectNode(nodeID)
8 | if ffresult := node.findForward(); ffresult != unchanged {
9 | csm.resetFrom(nodeID - 1)
10 | if ffresult == overflowed {
11 | csm.overflowFrom(nodeID + 1)
12 | }
13 | return
14 | }
15 | }
16 |
17 | // If no changes were applied, advance from least to most significant
18 | csm.next()
19 | }
20 |
21 | // Reset all nodes below and including this one
22 | func (csm *CronStateMachine) resetFrom(node NodeID) {
23 | chosenNode := csm.selectNode(node)
24 | if chosenNode == nil {
25 | return
26 | }
27 |
28 | chosenNode.Reset()
29 | csm.resetFrom(node - 1)
30 | }
31 |
32 | // Advance all nodes above and including this one
33 | func (csm *CronStateMachine) overflowFrom(node NodeID) {
34 | chosenNode := csm.selectNode(node)
35 | if chosenNode == nil {
36 | return
37 | }
38 |
39 | if chosenNode.Next() { // if overflows, keep recursing
40 | csm.overflowFrom(node + 1) // Overflow above
41 | } else {
42 | csm.resetFrom(node - 1) // Reset below
43 | }
44 | }
45 |
46 | // Select node from enum
47 | func (csm *CronStateMachine) selectNode(node NodeID) csmNode {
48 | switch node {
49 | case years:
50 | return csm.year
51 | case months:
52 | return csm.month
53 | case days:
54 | return csm.day
55 | case hours:
56 | return csm.hour
57 | case minutes:
58 | return csm.minute
59 | case seconds:
60 | return csm.second
61 | }
62 | return nil
63 | }
64 |
--------------------------------------------------------------------------------
/internal/csm/fn_next.go:
--------------------------------------------------------------------------------
1 | package csm
2 |
3 | func (csm *CronStateMachine) next() {
4 | if !csm.second.Next() {
5 | return
6 | }
7 | if !csm.minute.Next() {
8 | return
9 | }
10 | if !csm.hour.Next() {
11 | return
12 | }
13 |
14 | // Dates (dd-mm-yy) can be invalid in the case of leap years!
15 | // If an invalid date is detected, re-run the loop
16 | for next := true; next; next = !csm.isValidDate() {
17 | if !csm.day.Next() {
18 | continue
19 | }
20 | if !csm.month.Next() {
21 | if !csm.day.isValid() { // Can only happen with weekdays
22 | csm.day.Reset()
23 | }
24 | continue
25 | }
26 | if !csm.year.Next() {
27 | if !csm.day.isValid() { // Can only happen with weekdays
28 | csm.day.Reset()
29 | }
30 | continue
31 | }
32 | }
33 | }
34 |
35 | func (csm *CronStateMachine) isValidDate() bool {
36 | return csm.day.Value() <= csm.day.max()
37 | }
38 |
--------------------------------------------------------------------------------
/internal/csm/node.go:
--------------------------------------------------------------------------------
1 | package csm
2 |
3 | type csmNode interface {
4 | // Value returns the value held by the node.
5 | Value() int
6 |
7 | // Reset resets the node value to the minimum valid value.
8 | Reset()
9 |
10 | // Next changes the node value to the next valid value.
11 | // It returns true if the value overflowed and false otherwise.
12 | Next() bool
13 |
14 | // findForward checks if the current node value is valid.
15 | // If it is not valid, find the next valid value.
16 | findForward() result
17 | }
18 |
19 | type result int
20 |
21 | const (
22 | unchanged result = iota
23 | advanced
24 | overflowed
25 | )
26 |
--------------------------------------------------------------------------------
/internal/csm/util.go:
--------------------------------------------------------------------------------
1 | package csm
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | // contains returns true if the element is included in the slice.
8 | func contains[T comparable](slice []T, element T) bool {
9 | for _, e := range slice {
10 | if element == e {
11 | return true
12 | }
13 | }
14 | return false
15 | }
16 |
17 | // closestWeekday returns the day of the closest weekday within the month of
18 | // the given time t.
19 | func closestWeekday(t time.Time) int {
20 | if isWeekday(t) {
21 | return t.Day()
22 | }
23 |
24 | for i := 1; i <= 7; i++ {
25 | prevDay := t.AddDate(0, 0, -i)
26 | if prevDay.Month() == t.Month() && isWeekday(prevDay) {
27 | return prevDay.Day()
28 | }
29 |
30 | nextDay := t.AddDate(0, 0, i)
31 | if nextDay.Month() == t.Month() && isWeekday(nextDay) {
32 | return nextDay.Day()
33 | }
34 | }
35 |
36 | return t.Day()
37 | }
38 |
39 | func isWeekday(t time.Time) bool {
40 | return t.Weekday() != time.Saturday && t.Weekday() != time.Sunday
41 | }
42 |
43 | func lastDayOfMonth(year, month int) int {
44 | firstDayOfMonth := makeDateTime(year, month, 1)
45 | return firstDayOfMonth.AddDate(0, 1, -1).Day()
46 | }
47 |
48 | func makeDateTime(year, month, day int) time.Time {
49 | return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
50 | }
51 |
--------------------------------------------------------------------------------
/internal/mock/http_handler.go:
--------------------------------------------------------------------------------
1 | package mock
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/reugn/go-quartz/job"
7 | )
8 |
9 | type HTTPHandlerMock struct {
10 | DoFunc func(req *http.Request) (*http.Response, error)
11 | }
12 |
13 | func (m HTTPHandlerMock) Do(req *http.Request) (*http.Response, error) {
14 | return m.DoFunc(req)
15 | }
16 |
17 | var (
18 | HTTPHandlerOk job.HTTPHandler
19 | HTTPHandlerErr job.HTTPHandler
20 | )
21 |
22 | func init() {
23 | HTTPHandlerMockOk := struct{ HTTPHandlerMock }{}
24 | HTTPHandlerMockOk.DoFunc = func(request *http.Request) (*http.Response, error) {
25 | return &http.Response{
26 | StatusCode: 200,
27 | Request: request,
28 | }, nil
29 | }
30 | HTTPHandlerOk = job.HTTPHandler(HTTPHandlerMockOk)
31 |
32 | HTTPHandlerMockErr := struct{ HTTPHandlerMock }{}
33 | HTTPHandlerMockErr.DoFunc = func(request *http.Request) (*http.Response, error) {
34 | return &http.Response{
35 | StatusCode: 500,
36 | Request: request,
37 | }, nil
38 | }
39 | HTTPHandlerErr = job.HTTPHandler(HTTPHandlerMockErr)
40 | }
41 |
--------------------------------------------------------------------------------
/job/curl_job.go:
--------------------------------------------------------------------------------
1 | package job
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "net/http/httputil"
9 | "strings"
10 | "sync"
11 |
12 | "github.com/reugn/go-quartz/quartz"
13 | )
14 |
15 | // CurlJob represents a job that can be used to schedule HTTP requests.
16 | // It implements the [quartz.Job] interface.
17 | type CurlJob struct {
18 | mtx sync.Mutex
19 | httpClient HTTPHandler
20 | request *http.Request
21 | response *http.Response
22 | jobStatus Status
23 |
24 | once sync.Once
25 | description string
26 | callback func(context.Context, *CurlJob)
27 | }
28 |
29 | var _ quartz.Job = (*CurlJob)(nil)
30 |
31 | // HTTPHandler sends an HTTP request and returns an HTTP response, following
32 | // policy (such as redirects, cookies, auth) as configured on the implementing
33 | // HTTP client.
34 | type HTTPHandler interface {
35 | Do(req *http.Request) (*http.Response, error)
36 | }
37 |
38 | // CurlJobOptions represents optional parameters for constructing a [CurlJob].
39 | type CurlJobOptions struct {
40 | HTTPClient HTTPHandler
41 | Callback func(context.Context, *CurlJob)
42 | }
43 |
44 | // NewCurlJob returns a new [CurlJob] using the default HTTP client.
45 | func NewCurlJob(request *http.Request) *CurlJob {
46 | return NewCurlJobWithOptions(request, CurlJobOptions{HTTPClient: http.DefaultClient})
47 | }
48 |
49 | // NewCurlJobWithOptions returns a new [CurlJob] configured with [CurlJobOptions].
50 | func NewCurlJobWithOptions(request *http.Request, opts CurlJobOptions) *CurlJob {
51 | if opts.HTTPClient == nil {
52 | opts.HTTPClient = http.DefaultClient
53 | }
54 | return &CurlJob{
55 | httpClient: opts.HTTPClient,
56 | request: request,
57 | jobStatus: StatusNA,
58 | callback: opts.Callback,
59 | }
60 | }
61 |
62 | // Description returns the description of the CurlJob.
63 | func (cu *CurlJob) Description() string {
64 | cu.once.Do(func() {
65 | cu.description = formatRequest(cu.request)
66 | })
67 | return fmt.Sprintf("CurlJob%s%s", quartz.Sep, cu.description)
68 | }
69 |
70 | // DumpResponse returns the response of the job in its HTTP/1.x wire
71 | // representation.
72 | // If body is true, DumpResponse also returns the body.
73 | func (cu *CurlJob) DumpResponse(body bool) ([]byte, error) {
74 | cu.mtx.Lock()
75 | defer cu.mtx.Unlock()
76 | if cu.response != nil {
77 | return httputil.DumpResponse(cu.response, body)
78 | }
79 | return nil, errors.New("response is nil")
80 | }
81 |
82 | // JobStatus returns the status of the CurlJob.
83 | func (cu *CurlJob) JobStatus() Status {
84 | cu.mtx.Lock()
85 | defer cu.mtx.Unlock()
86 | return cu.jobStatus
87 | }
88 |
89 | func formatRequest(r *http.Request) string {
90 | var sb strings.Builder
91 | _, _ = fmt.Fprintf(&sb, "%v %v %v", r.Method, r.URL, r.Proto)
92 | for name, headers := range r.Header {
93 | for _, h := range headers {
94 | _, _ = fmt.Fprintf(&sb, "\n%v: %v", name, h)
95 | }
96 | }
97 | if r.ContentLength > 0 {
98 | _, _ = fmt.Fprintf(&sb, "\nContent Length: %d", r.ContentLength)
99 | }
100 | return sb.String()
101 | }
102 |
103 | // Execute is called by a Scheduler when the Trigger associated with this job fires.
104 | func (cu *CurlJob) Execute(ctx context.Context) error {
105 | cu.mtx.Lock()
106 | cu.request = cu.request.WithContext(ctx)
107 | var err error
108 | cu.response, err = cu.httpClient.Do(cu.request)
109 |
110 | // update job status based on HTTP response code
111 | if cu.response != nil && cu.response.StatusCode >= http.StatusOK &&
112 | cu.response.StatusCode < http.StatusBadRequest {
113 | cu.jobStatus = StatusOK
114 | } else {
115 | cu.jobStatus = StatusFailure
116 | }
117 | cu.mtx.Unlock()
118 |
119 | if cu.callback != nil {
120 | cu.callback(ctx, cu)
121 | }
122 | return err
123 | }
124 |
--------------------------------------------------------------------------------
/job/doc.go:
--------------------------------------------------------------------------------
1 | // Package job contains implementations of the quartz.Job interface.
2 | package job
3 |
--------------------------------------------------------------------------------
/job/function_job.go:
--------------------------------------------------------------------------------
1 | package job
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "sync"
7 |
8 | "github.com/reugn/go-quartz/quartz"
9 | )
10 |
11 | // Function represents a function which takes a [context.Context] as its
12 | // only argument and returns a generic type R and a possible error.
13 | type Function[R any] func(context.Context) (R, error)
14 |
15 | // FunctionJob represents a Job that invokes the passed [Function],
16 | // implements the [quartz.Job] interface.
17 | type FunctionJob[R any] struct {
18 | mtx sync.RWMutex
19 | function Function[R]
20 | description string
21 | result R
22 | err error
23 | jobStatus Status
24 | }
25 |
26 | var _ quartz.Job = (*FunctionJob[any])(nil)
27 |
28 | // NewFunctionJob returns a new [FunctionJob] with a generated description.
29 | func NewFunctionJob[R any](function Function[R]) *FunctionJob[R] {
30 | return NewFunctionJobWithDesc(
31 | function,
32 | fmt.Sprintf("FunctionJob%s%p", quartz.Sep, &function),
33 | )
34 | }
35 |
36 | // NewFunctionJobWithDesc returns a new [FunctionJob] with the specified
37 | // description.
38 | func NewFunctionJobWithDesc[R any](function Function[R],
39 | description string) *FunctionJob[R] {
40 | return &FunctionJob[R]{
41 | function: function,
42 | description: description,
43 | jobStatus: StatusNA,
44 | }
45 | }
46 |
47 | // Description returns the description of the FunctionJob.
48 | func (f *FunctionJob[R]) Description() string {
49 | return f.description
50 | }
51 |
52 | // Execute is called by a Scheduler when the Trigger associated with this job fires.
53 | // It invokes the held function, setting the results in result and error members.
54 | func (f *FunctionJob[R]) Execute(ctx context.Context) error {
55 | result, err := f.function(ctx)
56 | f.mtx.Lock()
57 | if err != nil {
58 | var zero R
59 | f.jobStatus = StatusFailure
60 | f.result, f.err = zero, err
61 | } else {
62 | f.jobStatus = StatusOK
63 | f.result, f.err = result, nil
64 | }
65 | f.mtx.Unlock()
66 | return err
67 | }
68 |
69 | // Result returns the result of the FunctionJob.
70 | func (f *FunctionJob[R]) Result() R {
71 | f.mtx.RLock()
72 | defer f.mtx.RUnlock()
73 | return f.result
74 | }
75 |
76 | // Error returns the error of the FunctionJob.
77 | func (f *FunctionJob[R]) Error() error {
78 | f.mtx.RLock()
79 | defer f.mtx.RUnlock()
80 | return f.err
81 | }
82 |
83 | // JobStatus returns the status of the FunctionJob.
84 | func (f *FunctionJob[R]) JobStatus() Status {
85 | f.mtx.RLock()
86 | defer f.mtx.RUnlock()
87 | return f.jobStatus
88 | }
89 |
--------------------------------------------------------------------------------
/job/function_job_test.go:
--------------------------------------------------------------------------------
1 | package job_test
2 |
3 | import (
4 | "context"
5 | "sync/atomic"
6 | "testing"
7 | "time"
8 |
9 | "github.com/reugn/go-quartz/internal/assert"
10 | "github.com/reugn/go-quartz/job"
11 | "github.com/reugn/go-quartz/quartz"
12 | )
13 |
14 | func TestFunctionJob(t *testing.T) {
15 | ctx, cancel := context.WithCancel(context.Background())
16 | defer cancel()
17 |
18 | var n atomic.Int32
19 | funcJob1 := job.NewFunctionJob(func(_ context.Context) (string, error) {
20 | n.Add(2)
21 | return "fired1", nil
22 | })
23 |
24 | funcJob2 := job.NewFunctionJob(func(_ context.Context) (*int, error) {
25 | n.Add(2)
26 | result := 42
27 | return &result, nil
28 | })
29 |
30 | sched, err := quartz.NewStdScheduler()
31 | assert.IsNil(t, err)
32 |
33 | sched.Start(ctx)
34 |
35 | assert.IsNil(t, sched.ScheduleJob(quartz.NewJobDetail(funcJob1,
36 | quartz.NewJobKey("funcJob1")),
37 | quartz.NewRunOnceTrigger(time.Millisecond*300)))
38 | assert.IsNil(t, sched.ScheduleJob(quartz.NewJobDetail(funcJob2,
39 | quartz.NewJobKey("funcJob2")),
40 | quartz.NewRunOnceTrigger(time.Millisecond*800)))
41 |
42 | time.Sleep(time.Second)
43 | assert.IsNil(t, sched.Clear())
44 | sched.Stop()
45 |
46 | assert.Equal(t, funcJob1.JobStatus(), job.StatusOK)
47 | assert.Equal(t, funcJob1.Result(), "fired1")
48 |
49 | assert.Equal(t, funcJob2.JobStatus(), job.StatusOK)
50 | assert.NotEqual(t, funcJob2.Result(), nil)
51 | assert.Equal(t, *funcJob2.Result(), 42)
52 |
53 | assert.Equal(t, n.Load(), 4)
54 | }
55 |
56 | func TestNewFunctionJob_WithDesc(t *testing.T) {
57 | jobDesc := "test job"
58 |
59 | funcJob1 := job.NewFunctionJobWithDesc(func(_ context.Context) (string, error) {
60 | return "fired1", nil
61 | }, jobDesc)
62 |
63 | funcJob2 := job.NewFunctionJobWithDesc(func(_ context.Context) (string, error) {
64 | return "fired2", nil
65 | }, jobDesc)
66 |
67 | assert.Equal(t, funcJob1.Description(), jobDesc)
68 | assert.Equal(t, funcJob2.Description(), jobDesc)
69 | }
70 |
71 | func TestFunctionJob_RespectsContext(t *testing.T) {
72 | var n int
73 | funcJob2 := job.NewFunctionJob(func(ctx context.Context) (bool, error) {
74 | timer := time.NewTimer(time.Hour)
75 | defer timer.Stop()
76 | select {
77 | case <-ctx.Done():
78 | n--
79 | return false, ctx.Err()
80 | case <-timer.C:
81 | n++
82 | return true, nil
83 | }
84 | })
85 |
86 | ctx, cancel := context.WithCancel(context.Background())
87 | defer cancel()
88 |
89 | sig := make(chan struct{})
90 | go func() { defer close(sig); _ = funcJob2.Execute(ctx) }()
91 |
92 | if n != 0 {
93 | t.Fatal("job should not have run yet")
94 | }
95 | cancel()
96 | <-sig
97 |
98 | if n != -1 {
99 | t.Fatal("job side effect should have reflected cancelation:", n)
100 | }
101 | assert.ErrorIs(t, funcJob2.Error(), context.Canceled)
102 | assert.Equal(t, funcJob2.Result(), false)
103 | }
104 |
--------------------------------------------------------------------------------
/job/isolated_job.go:
--------------------------------------------------------------------------------
1 | package job
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "sync/atomic"
7 |
8 | "github.com/reugn/go-quartz/quartz"
9 | )
10 |
11 | type isolatedJob struct {
12 | quartz.Job
13 | isRunning atomic.Bool
14 | }
15 |
16 | var _ quartz.Job = (*isolatedJob)(nil)
17 |
18 | // Execute is called by a Scheduler when the Trigger associated
19 | // with this job fires.
20 | func (j *isolatedJob) Execute(ctx context.Context) error {
21 | if wasRunning := j.isRunning.Swap(true); wasRunning {
22 | return errors.New("job is running")
23 | }
24 | defer j.isRunning.Store(false)
25 |
26 | return j.Job.Execute(ctx)
27 | }
28 |
29 | // NewIsolatedJob wraps a job object and ensures that only one
30 | // instance of the job's Execute method can be called at a time.
31 | func NewIsolatedJob(underlying quartz.Job) quartz.Job {
32 | return &isolatedJob{
33 | Job: underlying,
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/job/job_status.go:
--------------------------------------------------------------------------------
1 | package job
2 |
3 | // Status represents a Job status.
4 | type Status int8
5 |
6 | const (
7 | // StatusNA is the initial Job status.
8 | StatusNA Status = iota
9 |
10 | // StatusOK indicates that the Job completed successfully.
11 | StatusOK
12 |
13 | // StatusFailure indicates that the Job failed.
14 | StatusFailure
15 | )
16 |
--------------------------------------------------------------------------------
/job/job_test.go:
--------------------------------------------------------------------------------
1 | package job_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "math/rand"
8 | "net/http"
9 | "runtime"
10 | "strings"
11 | "sync/atomic"
12 | "testing"
13 | "time"
14 |
15 | "github.com/reugn/go-quartz/internal/assert"
16 | "github.com/reugn/go-quartz/internal/mock"
17 | "github.com/reugn/go-quartz/job"
18 | )
19 |
20 | //nolint:gosec
21 | func TestMultipleExecution(t *testing.T) {
22 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
23 | defer cancel()
24 | var n atomic.Int64
25 | job1 := job.NewIsolatedJob(job.NewFunctionJob(func(ctx context.Context) (bool, error) {
26 | n.Add(1)
27 | timer := time.NewTimer(time.Minute)
28 | defer timer.Stop()
29 | select {
30 | case <-ctx.Done():
31 | if err := ctx.Err(); errors.Is(err, context.DeadlineExceeded) {
32 | t.Error("should not have timed out")
33 | }
34 | case <-timer.C:
35 | t.Error("should not have reached timeout")
36 | }
37 |
38 | return false, ctx.Err()
39 | }))
40 |
41 | // start a bunch of threads that run jobs
42 | sig := make(chan struct{})
43 | for i := 0; i < runtime.NumCPU(); i++ {
44 | go func() {
45 | timer := time.NewTimer(0)
46 | defer timer.Stop()
47 | count := 0
48 | defer func() {
49 | if count == 0 {
50 | t.Error("should run at least once")
51 | }
52 | }()
53 | for {
54 | count++
55 | select {
56 | case <-timer.C:
57 | // sleep for a jittered amount of
58 | // time, less than 11ms
59 | _ = job1.Execute(ctx)
60 | case <-ctx.Done():
61 | return
62 | case <-sig:
63 | return
64 | }
65 | timer.Reset(1 + time.Duration(rand.Int63n(10))*time.Millisecond)
66 | }
67 | }()
68 | }
69 |
70 | // confirm regularly that only a single job execution has occurred
71 | ticker := time.NewTicker(2 * time.Millisecond)
72 | loop:
73 | for i := 0; i < 1000; i++ {
74 | select {
75 | case <-ticker.C:
76 | if n.Load() != 1 {
77 | t.Error("only one job should run")
78 | }
79 | case <-ctx.Done():
80 | t.Error("should not have reached timeout")
81 | break loop
82 | }
83 | }
84 |
85 | // stop all the adding threads without canceling the context
86 | close(sig)
87 | if n.Load() != 1 {
88 | t.Error("only one job should run")
89 | }
90 | }
91 |
92 | var worldtimeapiURL = "https://worldtimeapi.org/api/timezone/utc"
93 |
94 | func TestCurlJob(t *testing.T) {
95 | request, err := http.NewRequest(http.MethodGet, worldtimeapiURL, nil)
96 | assert.IsNil(t, err)
97 |
98 | tests := []struct {
99 | name string
100 | request *http.Request
101 | opts job.CurlJobOptions
102 | expectedStatus job.Status
103 | }{
104 | {
105 | name: "HTTP 200 OK",
106 | request: request,
107 | opts: job.CurlJobOptions{HTTPClient: mock.HTTPHandlerOk},
108 | expectedStatus: job.StatusOK,
109 | },
110 | {
111 | name: "HTTP 500 Internal Server Error",
112 | request: request,
113 | opts: job.CurlJobOptions{HTTPClient: mock.HTTPHandlerErr},
114 | expectedStatus: job.StatusFailure,
115 | },
116 | }
117 | for _, tt := range tests {
118 | t.Run(tt.name, func(t *testing.T) {
119 | httpJob := job.NewCurlJobWithOptions(tt.request, tt.opts)
120 | _ = httpJob.Execute(context.Background())
121 | assert.Equal(t, httpJob.JobStatus(), tt.expectedStatus)
122 | })
123 | }
124 | }
125 |
126 | func TestCurlJob_DumpResponse(t *testing.T) {
127 | request, err := http.NewRequest(http.MethodGet, worldtimeapiURL, nil)
128 | assert.IsNil(t, err)
129 | httpJob := job.NewCurlJob(request)
130 | response, err := httpJob.DumpResponse(false)
131 | assert.IsNil(t, response)
132 | assert.ErrorContains(t, err, "response is nil")
133 | }
134 |
135 | func TestCurlJob_Description(t *testing.T) {
136 | postRequest, err := http.NewRequest(
137 | http.MethodPost,
138 | worldtimeapiURL,
139 | strings.NewReader("{\"a\":1}"),
140 | )
141 | assert.IsNil(t, err)
142 | postRequest.Header = http.Header{
143 | "Content-Type": {"application/json"},
144 | }
145 | getRequest, err := http.NewRequest(
146 | http.MethodGet,
147 | worldtimeapiURL,
148 | nil,
149 | )
150 | assert.IsNil(t, err)
151 |
152 | tests := []struct {
153 | name string
154 | request *http.Request
155 | expectedDescription string
156 | }{
157 | {
158 | name: "POST with headers and body",
159 | request: postRequest,
160 | expectedDescription: "CurlJob::" +
161 | fmt.Sprintf("POST %s HTTP/1.1\n", worldtimeapiURL) +
162 | "Content-Type: application/json\n" +
163 | "Content Length: 7",
164 | },
165 | {
166 | name: "Get request",
167 | request: getRequest,
168 | expectedDescription: "CurlJob::" +
169 | fmt.Sprintf("GET %s HTTP/1.1", worldtimeapiURL),
170 | },
171 | }
172 |
173 | for _, tt := range tests {
174 | t.Run(tt.name, func(t *testing.T) {
175 | opts := job.CurlJobOptions{HTTPClient: http.DefaultClient}
176 | httpJob := job.NewCurlJobWithOptions(tt.request, opts)
177 | assert.Equal(t, httpJob.Description(), tt.expectedDescription)
178 | })
179 | }
180 | }
181 |
182 | func TestShellJob_Execute(t *testing.T) {
183 | type args struct {
184 | Cmd string
185 | ExitCode int
186 | Result string
187 | Stdout string
188 | Stderr string
189 | }
190 |
191 | tests := []struct {
192 | name string
193 | args args
194 | }{
195 | {
196 | name: "test stdout",
197 | args: args{
198 | Cmd: "echo -n ok",
199 | ExitCode: 0,
200 | Stdout: "ok",
201 | Stderr: "",
202 | },
203 | },
204 | {
205 | name: "test stderr",
206 | args: args{
207 | Cmd: "echo -n err >&2",
208 | ExitCode: 0,
209 | Stdout: "",
210 | Stderr: "err",
211 | },
212 | },
213 | {
214 | name: "test combine",
215 | args: args{
216 | Cmd: "echo -n ok && sleep 0.01 && echo -n err >&2",
217 | ExitCode: 0,
218 | Stdout: "ok",
219 | Stderr: "err",
220 | },
221 | },
222 | }
223 | for _, tt := range tests {
224 | t.Run(tt.name, func(t *testing.T) {
225 | sh := job.NewShellJob(tt.args.Cmd)
226 | _ = sh.Execute(context.Background())
227 |
228 | assert.Equal(t, tt.args.ExitCode, sh.ExitCode())
229 | assert.Equal(t, tt.args.Stderr, sh.Stderr())
230 | assert.Equal(t, tt.args.Stdout, sh.Stdout())
231 | assert.Equal(t, job.StatusOK, sh.JobStatus())
232 | assert.Equal(t, fmt.Sprintf("ShellJob::%s", tt.args.Cmd), sh.Description())
233 | })
234 | }
235 |
236 | // invalid command
237 | stdoutShell := "invalid_command"
238 | sh := job.NewShellJob(stdoutShell)
239 | _ = sh.Execute(context.Background())
240 | assert.Equal(t, 127, sh.ExitCode())
241 | // the return value is different under different platforms.
242 | }
243 |
244 | func TestShellJob_WithCallback(t *testing.T) {
245 | stdoutShell := "echo -n ok"
246 | resultChan := make(chan string, 1)
247 | shJob := job.NewShellJobWithCallback(
248 | stdoutShell,
249 | func(_ context.Context, job *job.ShellJob) {
250 | resultChan <- job.Stdout()
251 | },
252 | )
253 | _ = shJob.Execute(context.Background())
254 |
255 | assert.Equal(t, "", shJob.Stderr())
256 | assert.Equal(t, "ok", shJob.Stdout())
257 | assert.Equal(t, "ok", <-resultChan)
258 | }
259 |
260 | func TestCurlJob_WithCallback(t *testing.T) {
261 | request, err := http.NewRequest(http.MethodGet, worldtimeapiURL, nil)
262 | assert.IsNil(t, err)
263 |
264 | resultChan := make(chan job.Status, 1)
265 | opts := job.CurlJobOptions{
266 | HTTPClient: mock.HTTPHandlerOk,
267 | Callback: func(_ context.Context, job *job.CurlJob) {
268 | resultChan <- job.JobStatus()
269 | },
270 | }
271 | curlJob := job.NewCurlJobWithOptions(request, opts)
272 | _ = curlJob.Execute(context.Background())
273 |
274 | assert.Equal(t, job.StatusOK, <-resultChan)
275 | }
276 |
--------------------------------------------------------------------------------
/job/shell_job.go:
--------------------------------------------------------------------------------
1 | package job
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "io"
8 | "os/exec"
9 | "sync"
10 |
11 | "github.com/reugn/go-quartz/quartz"
12 | )
13 |
14 | // ShellJob represents a shell command Job, implements the [quartz.Job] interface.
15 | // The command will be executed using bash if available; otherwise, sh will be used.
16 | // Consider the interpreter type and target environment when formulating commands
17 | // for execution.
18 | type ShellJob struct {
19 | mtx sync.Mutex
20 | cmd string
21 | exitCode int
22 | stdout string
23 | stderr string
24 | jobStatus Status
25 | callback func(context.Context, *ShellJob)
26 | }
27 |
28 | var _ quartz.Job = (*ShellJob)(nil)
29 |
30 | // NewShellJob returns a new [ShellJob] for the given command.
31 | func NewShellJob(cmd string) *ShellJob {
32 | return &ShellJob{
33 | cmd: cmd,
34 | jobStatus: StatusNA,
35 | }
36 | }
37 |
38 | // NewShellJobWithCallback returns a new [ShellJob] with the given callback function.
39 | func NewShellJobWithCallback(cmd string, f func(context.Context, *ShellJob)) *ShellJob {
40 | return &ShellJob{
41 | cmd: cmd,
42 | jobStatus: StatusNA,
43 | callback: f,
44 | }
45 | }
46 |
47 | // Description returns the description of the ShellJob.
48 | func (sh *ShellJob) Description() string {
49 | return fmt.Sprintf("ShellJob%s%s", quartz.Sep, sh.cmd)
50 | }
51 |
52 | var (
53 | shellOnce = sync.Once{}
54 | shellPath = "bash"
55 | )
56 |
57 | func getShell() string {
58 | shellOnce.Do(func() {
59 | _, err := exec.LookPath("/bin/bash")
60 | // if bash binary is not found, use `sh`.
61 | if err != nil {
62 | shellPath = "sh"
63 | }
64 | })
65 | return shellPath
66 | }
67 |
68 | // Execute is called by a Scheduler when the Trigger associated with this job fires.
69 | func (sh *ShellJob) Execute(ctx context.Context) error {
70 | shell := getShell()
71 |
72 | var stdout, stderr bytes.Buffer
73 | cmd := exec.CommandContext(ctx, shell, "-c", sh.cmd)
74 | cmd.Stdout = io.Writer(&stdout)
75 | cmd.Stderr = io.Writer(&stderr)
76 |
77 | err := cmd.Run() // run the command
78 |
79 | sh.mtx.Lock()
80 | sh.stdout, sh.stderr = stdout.String(), stderr.String()
81 | sh.exitCode = cmd.ProcessState.ExitCode()
82 |
83 | if err != nil {
84 | sh.jobStatus = StatusFailure
85 | } else {
86 | sh.jobStatus = StatusOK
87 | }
88 | sh.mtx.Unlock()
89 |
90 | if sh.callback != nil {
91 | sh.callback(ctx, sh)
92 | }
93 | return err
94 | }
95 |
96 | // ExitCode returns the exit code of the ShellJob.
97 | func (sh *ShellJob) ExitCode() int {
98 | sh.mtx.Lock()
99 | defer sh.mtx.Unlock()
100 | return sh.exitCode
101 | }
102 |
103 | // Stdout returns the captured stdout output of the ShellJob.
104 | func (sh *ShellJob) Stdout() string {
105 | sh.mtx.Lock()
106 | defer sh.mtx.Unlock()
107 | return sh.stdout
108 | }
109 |
110 | // Stderr returns the captured stderr output of the ShellJob.
111 | func (sh *ShellJob) Stderr() string {
112 | sh.mtx.Lock()
113 | defer sh.mtx.Unlock()
114 | return sh.stderr
115 | }
116 |
117 | // JobStatus returns the status of the ShellJob.
118 | func (sh *ShellJob) JobStatus() Status {
119 | sh.mtx.Lock()
120 | defer sh.mtx.Unlock()
121 | return sh.jobStatus
122 | }
123 |
--------------------------------------------------------------------------------
/logger/doc.go:
--------------------------------------------------------------------------------
1 | // Package logger contains logging utilities for the module.
2 | package logger
3 |
--------------------------------------------------------------------------------
/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | // Logger is an interface for handling structured log records at different
4 | // severity levels.
5 | type Logger interface {
6 | Trace(msg string, args ...any)
7 | Debug(msg string, args ...any)
8 | Info(msg string, args ...any)
9 | Warn(msg string, args ...any)
10 | Error(msg string, args ...any)
11 | }
12 |
13 | // NoOpLogger satisfies the Logger interface and discards all log messages.
14 | type NoOpLogger struct{}
15 |
16 | var _ Logger = (*NoOpLogger)(nil)
17 |
18 | func (NoOpLogger) Trace(_ string, _ ...any) {}
19 | func (NoOpLogger) Debug(_ string, _ ...any) {}
20 | func (NoOpLogger) Info(_ string, _ ...any) {}
21 | func (NoOpLogger) Warn(_ string, _ ...any) {}
22 | func (NoOpLogger) Error(_ string, _ ...any) {}
23 |
--------------------------------------------------------------------------------
/logger/logger_test.go:
--------------------------------------------------------------------------------
1 | package logger_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "io"
7 | "log"
8 | "log/slog"
9 | "testing"
10 |
11 | "github.com/reugn/go-quartz/internal/assert"
12 | l "github.com/reugn/go-quartz/logger"
13 | )
14 |
15 | func TestLogger(t *testing.T) {
16 | t.Parallel()
17 | tests := []struct {
18 | name string
19 | supplier func(*bytes.Buffer, l.Level) l.Logger
20 | }{
21 | {
22 | name: "simple",
23 | supplier: func(b *bytes.Buffer, level l.Level) l.Logger {
24 | stdLogger := log.New(b, "", log.LstdFlags)
25 | return l.NewSimpleLogger(stdLogger, level)
26 | },
27 | },
28 | {
29 | name: "slog",
30 | supplier: func(b *bytes.Buffer, level l.Level) l.Logger {
31 | slogLogger := slog.New(slog.NewTextHandler(b, &slog.HandlerOptions{
32 | Level: slog.Level(level),
33 | AddSource: true,
34 | }))
35 | var ctx context.Context // test nil context
36 | return l.NewSlogLogger(ctx, slogLogger)
37 | },
38 | },
39 | }
40 |
41 | for _, tt := range tests {
42 | test := tt
43 | t.Run(test.name, func(t *testing.T) {
44 | t.Parallel()
45 | var b bytes.Buffer
46 | logger := test.supplier(&b, l.LevelInfo)
47 |
48 | logger.Trace("Trace")
49 | assertEmpty(t, &b)
50 |
51 | logger.Debug("Debug")
52 | assertEmpty(t, &b)
53 |
54 | logger.Info("Info")
55 | assertNotEmpty(t, &b)
56 |
57 | b.Reset()
58 | assertEmpty(t, &b)
59 |
60 | logger.Warn("Warn", "error", "err1")
61 | assertNotEmpty(t, &b)
62 |
63 | b.Reset()
64 | assertEmpty(t, &b)
65 |
66 | logger.Error("Error", "error")
67 | assertNotEmpty(t, &b)
68 |
69 | b.Reset()
70 | assertEmpty(t, &b)
71 |
72 | logger = test.supplier(&b, l.LevelTrace)
73 |
74 | logger.Trace("Trace")
75 | assertNotEmpty(t, &b)
76 |
77 | b.Reset()
78 | assertEmpty(t, &b)
79 |
80 | logger.Debug("Debug")
81 | assertNotEmpty(t, &b)
82 |
83 | b.Reset()
84 | assertEmpty(t, &b)
85 |
86 | logger = test.supplier(&b, l.LevelOff)
87 |
88 | logger.Error("Error")
89 | assertEmpty(t, &b)
90 | })
91 | }
92 | }
93 |
94 | func TestLogger_SlogPanic(t *testing.T) {
95 | assert.Panics(t, func() {
96 | l.NewSlogLogger(context.Background(), nil)
97 | })
98 | }
99 |
100 | func TestLogger_NoOp(_ *testing.T) {
101 | logger := l.NoOpLogger{}
102 |
103 | logger.Trace("Trace")
104 | logger.Debug("Debug")
105 | logger.Info("Info")
106 | logger.Warn("Warn")
107 | logger.Error("Error")
108 | }
109 |
110 | func assertEmpty(t *testing.T, r io.Reader) {
111 | t.Helper()
112 | logMsg := readAll(t, r)
113 | if logMsg != "" {
114 | t.Fatalf("log msg is not empty: %s", logMsg)
115 | }
116 | }
117 |
118 | func assertNotEmpty(t *testing.T, r io.Reader) {
119 | t.Helper()
120 | logMsg := readAll(t, r)
121 | if logMsg == "" {
122 | t.Fatal("log msg is empty")
123 | }
124 | }
125 |
126 | func readAll(t *testing.T, r io.Reader) string {
127 | t.Helper()
128 | data, err := io.ReadAll(r)
129 | if err != nil {
130 | t.Fatal(err)
131 | }
132 | return string(data)
133 | }
134 |
--------------------------------------------------------------------------------
/logger/simple_logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strings"
7 | )
8 |
9 | // A Level is the importance or severity of a log event.
10 | // The higher the level, the more important or severe the event.
11 | type Level int
12 |
13 | // Log levels.
14 | const (
15 | LevelTrace Level = -8
16 | LevelDebug Level = -4
17 | LevelInfo Level = 0
18 | LevelWarn Level = 4
19 | LevelError Level = 8
20 | LevelOff Level = 12
21 | )
22 |
23 | // SimpleLogger prefixes.
24 | const (
25 | tracePrefix = "TRACE "
26 | debugPrefix = "DEBUG "
27 | infoPrefix = "INFO "
28 | warnPrefix = "WARN "
29 | errorPrefix = "ERROR "
30 | )
31 |
32 | // SimpleLogger implements the [Logger] interface.
33 | type SimpleLogger struct {
34 | logger *log.Logger
35 | level Level
36 | }
37 |
38 | var _ Logger = (*SimpleLogger)(nil)
39 |
40 | // NewSimpleLogger returns a new [SimpleLogger].
41 | func NewSimpleLogger(logger *log.Logger, level Level) *SimpleLogger {
42 | return &SimpleLogger{
43 | logger: logger,
44 | level: level,
45 | }
46 | }
47 |
48 | // Trace logs at the trace level.
49 | func (l *SimpleLogger) Trace(msg string, args ...any) {
50 | if l.enabled(LevelTrace) {
51 | l.logger.SetPrefix(tracePrefix)
52 | _ = l.logger.Output(2, formatMessage(msg, args))
53 | }
54 | }
55 |
56 | // Debug logs at the debug level.
57 | func (l *SimpleLogger) Debug(msg string, args ...any) {
58 | if l.enabled(LevelDebug) {
59 | l.logger.SetPrefix(debugPrefix)
60 | _ = l.logger.Output(2, formatMessage(msg, args))
61 | }
62 | }
63 |
64 | // Info logs at the info level.
65 | func (l *SimpleLogger) Info(msg string, args ...any) {
66 | if l.enabled(LevelInfo) {
67 | l.logger.SetPrefix(infoPrefix)
68 | _ = l.logger.Output(2, formatMessage(msg, args))
69 | }
70 | }
71 |
72 | // Warn logs at the warn level.
73 | func (l *SimpleLogger) Warn(msg string, args ...any) {
74 | if l.enabled(LevelWarn) {
75 | l.logger.SetPrefix(warnPrefix)
76 | _ = l.logger.Output(2, formatMessage(msg, args))
77 | }
78 | }
79 |
80 | // Error logs at the error level.
81 | func (l *SimpleLogger) Error(msg string, args ...any) {
82 | if l.enabled(LevelError) {
83 | l.logger.SetPrefix(errorPrefix)
84 | _ = l.logger.Output(2, formatMessage(msg, args))
85 | }
86 | }
87 |
88 | // enabled reports whether the SimpleLogger handles records at the given level.
89 | func (l *SimpleLogger) enabled(level Level) bool {
90 | return level >= l.level
91 | }
92 |
93 | func formatMessage(msg string, args []any) string {
94 | var b strings.Builder
95 | _, _ = fmt.Fprintf(&b, "msg=%s", msg)
96 |
97 | n := len(args)
98 | for i := 0; i < n; i += 2 {
99 | if i+1 < n {
100 | _, _ = fmt.Fprintf(&b, ", %s=%v", args[i], args[i+1])
101 | } else {
102 | _, _ = fmt.Fprintf(&b, ", %v", args[i])
103 | }
104 | }
105 |
106 | return b.String()
107 | }
108 |
--------------------------------------------------------------------------------
/logger/slog_logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "runtime"
7 | "time"
8 | )
9 |
10 | // SlogLogger implements the [Logger] interface, providing a structured logging
11 | // mechanism by delegating log operations to the standard library's slog package.
12 | //
13 | // In addition to the default slog levels, it introduces the Trace (Debug-4) level.
14 | // To change the string representation of the Trace log level, use the ReplaceAttr
15 | // field in [slog.HandlerOptions], e.g.
16 | //
17 | // myLogger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
18 | // Level: slog.Level(logger.LevelTrace),
19 | // AddSource: true,
20 | // ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
21 | // if a.Key == slog.LevelKey {
22 | // level := a.Value.Any().(slog.Level)
23 | // if level == slog.Level(logger.LevelTrace) {
24 | // a.Value = slog.StringValue("TRACE")
25 | // }
26 | // }
27 | // return a
28 | // },
29 | // }))
30 | type SlogLogger struct {
31 | ctx context.Context
32 | logger *slog.Logger
33 | }
34 |
35 | var _ Logger = (*SlogLogger)(nil)
36 |
37 | // NewSlogLogger returns a new [SlogLogger].
38 | // It will panic if the logger is nil.
39 | func NewSlogLogger(ctx context.Context, logger *slog.Logger) *SlogLogger {
40 | if logger == nil {
41 | panic("nil logger")
42 | }
43 | if ctx == nil {
44 | ctx = context.Background()
45 | }
46 | return &SlogLogger{
47 | ctx: ctx,
48 | logger: logger,
49 | }
50 | }
51 |
52 | // Trace logs at the trace level.
53 | func (l *SlogLogger) Trace(msg string, args ...any) {
54 | l.log(slog.Level(LevelTrace), msg, args...)
55 | }
56 |
57 | // Debug logs at the debug level.
58 | func (l *SlogLogger) Debug(msg string, args ...any) {
59 | l.log(slog.LevelDebug, msg, args...)
60 | }
61 |
62 | // Info logs at the info level.
63 | func (l *SlogLogger) Info(msg string, args ...any) {
64 | l.log(slog.LevelInfo, msg, args...)
65 | }
66 |
67 | // Warn logs at the warn level.
68 | func (l *SlogLogger) Warn(msg string, args ...any) {
69 | l.log(slog.LevelWarn, msg, args...)
70 | }
71 |
72 | // Error logs at the error level.
73 | func (l *SlogLogger) Error(msg string, args ...any) {
74 | l.log(slog.LevelError, msg, args...)
75 | }
76 |
77 | // log is the low-level logging method, obtaining the caller's PC for context.
78 | func (l *SlogLogger) log(level slog.Level, msg string, args ...any) {
79 | if !l.logger.Enabled(l.ctx, level) {
80 | return
81 | }
82 |
83 | // skip [runtime.Callers, this function, this function's caller]
84 | var pcs [1]uintptr
85 | runtime.Callers(3, pcs[:])
86 | pc := pcs[0]
87 |
88 | r := slog.NewRecord(time.Now(), level, msg, pc)
89 | r.Add(args...)
90 |
91 | _ = l.logger.Handler().Handle(l.ctx, r)
92 | }
93 |
--------------------------------------------------------------------------------
/matcher/doc.go:
--------------------------------------------------------------------------------
1 | // Package matcher contains standard implementations of the quartz.Matcher interface.
2 | package matcher
3 |
--------------------------------------------------------------------------------
/matcher/job_group.go:
--------------------------------------------------------------------------------
1 | //nolint:dupl
2 | package matcher
3 |
4 | import (
5 | "github.com/reugn/go-quartz/quartz"
6 | )
7 |
8 | // JobGroup implements the quartz.Matcher interface with the type argument
9 | // quartz.ScheduledJob, matching jobs by their group name.
10 | // It has public fields to allow predicate pushdown in custom quartz.JobQueue
11 | // implementations.
12 | type JobGroup struct {
13 | Operator *StringOperator // uses a pointer to compare with standard operators
14 | Pattern string
15 | }
16 |
17 | var _ quartz.Matcher[quartz.ScheduledJob] = (*JobGroup)(nil)
18 |
19 | // NewJobGroup returns a new JobGroup matcher given the string operator and pattern.
20 | func NewJobGroup(operator *StringOperator, pattern string) quartz.Matcher[quartz.ScheduledJob] {
21 | return &JobGroup{
22 | Operator: operator,
23 | Pattern: pattern,
24 | }
25 | }
26 |
27 | // JobGroupEquals returns a new JobGroup, matching jobs whose group name is identical
28 | // to the given string pattern.
29 | func JobGroupEquals(pattern string) quartz.Matcher[quartz.ScheduledJob] {
30 | return NewJobGroup(&StringEquals, pattern)
31 | }
32 |
33 | // JobGroupStartsWith returns a new JobGroup, matching jobs whose group name starts
34 | // with the given string pattern.
35 | func JobGroupStartsWith(pattern string) quartz.Matcher[quartz.ScheduledJob] {
36 | return NewJobGroup(&StringStartsWith, pattern)
37 | }
38 |
39 | // JobGroupEndsWith returns a new JobGroup, matching jobs whose group name ends
40 | // with the given string pattern.
41 | func JobGroupEndsWith(pattern string) quartz.Matcher[quartz.ScheduledJob] {
42 | return NewJobGroup(&StringEndsWith, pattern)
43 | }
44 |
45 | // JobGroupContains returns a new JobGroup, matching jobs whose group name contains
46 | // the given string pattern.
47 | func JobGroupContains(pattern string) quartz.Matcher[quartz.ScheduledJob] {
48 | return NewJobGroup(&StringContains, pattern)
49 | }
50 |
51 | // IsMatch evaluates JobGroup matcher on the given job.
52 | func (g *JobGroup) IsMatch(job quartz.ScheduledJob) bool {
53 | return (*g.Operator)(job.JobDetail().JobKey().Group(), g.Pattern)
54 | }
55 |
--------------------------------------------------------------------------------
/matcher/job_matcher_test.go:
--------------------------------------------------------------------------------
1 | package matcher_test
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/reugn/go-quartz/internal/assert"
8 | "github.com/reugn/go-quartz/job"
9 | "github.com/reugn/go-quartz/matcher"
10 | "github.com/reugn/go-quartz/quartz"
11 | )
12 |
13 | func TestMatcher_JobAll(t *testing.T) {
14 | sched, err := quartz.NewStdScheduler()
15 | assert.IsNil(t, err)
16 |
17 | dummy := job.NewFunctionJob(func(_ context.Context) (bool, error) {
18 | return true, nil
19 | })
20 | cron, err := quartz.NewCronTrigger("@daily")
21 | assert.IsNil(t, err)
22 |
23 | jobKeys := []*quartz.JobKey{
24 | quartz.NewJobKey("job_monitor"),
25 | quartz.NewJobKey("job_update"),
26 | quartz.NewJobKeyWithGroup("job_monitor", "group_monitor"),
27 | quartz.NewJobKeyWithGroup("job_update", "group_update"),
28 | }
29 |
30 | for _, jobKey := range jobKeys {
31 | err := sched.ScheduleJob(quartz.NewJobDetail(dummy, jobKey), cron)
32 | assert.IsNil(t, err)
33 | }
34 | sched.Start(context.Background())
35 |
36 | assert.Equal(t, jobCount(sched, matcher.JobActive()), 4)
37 | assert.Equal(t, jobCount(sched, matcher.JobPaused()), 0)
38 |
39 | assert.Equal(t, jobCount(sched, matcher.JobGroupEquals(quartz.DefaultGroup)), 2)
40 | assert.Equal(t, jobCount(sched, matcher.JobGroupContains("_")), 2)
41 | assert.Equal(t, jobCount(sched, matcher.JobGroupStartsWith("group_")), 2)
42 | assert.Equal(t, jobCount(sched, matcher.JobGroupEndsWith("_update")), 1)
43 |
44 | assert.Equal(t, jobCount(sched, matcher.JobNameEquals("job_monitor")), 2)
45 | assert.Equal(t, jobCount(sched, matcher.JobNameContains("_")), 4)
46 | assert.Equal(t, jobCount(sched, matcher.JobNameStartsWith("job_")), 4)
47 | assert.Equal(t, jobCount(sched, matcher.JobNameEndsWith("_update")), 2)
48 |
49 | // multiple matchers
50 | assert.Equal(t, jobCount(sched,
51 | matcher.JobNameEquals("job_monitor"),
52 | matcher.JobGroupEquals(quartz.DefaultGroup),
53 | matcher.JobActive(),
54 | ), 1)
55 |
56 | assert.Equal(t, jobCount(sched,
57 | matcher.JobNameEquals("job_monitor"),
58 | matcher.JobGroupEquals(quartz.DefaultGroup),
59 | matcher.JobPaused(),
60 | ), 0)
61 |
62 | // no matchers
63 | assert.Equal(t, jobCount(sched), 4)
64 |
65 | err = sched.PauseJob(quartz.NewJobKey("job_monitor"))
66 | assert.IsNil(t, err)
67 |
68 | assert.Equal(t, jobCount(sched, matcher.JobActive()), 3)
69 | assert.Equal(t, jobCount(sched, matcher.JobPaused()), 1)
70 |
71 | sched.Stop()
72 | }
73 |
74 | func TestMatcher_JobSwitchType(t *testing.T) {
75 | tests := []struct {
76 | name string
77 | m quartz.Matcher[quartz.ScheduledJob]
78 | }{
79 | {
80 | name: "job-active",
81 | m: matcher.JobActive(),
82 | },
83 | {
84 | name: "job-group-equals",
85 | m: matcher.JobGroupEquals("group1"),
86 | },
87 | {
88 | name: "job-name-contains",
89 | m: matcher.JobNameContains("name"),
90 | },
91 | }
92 | for _, tt := range tests {
93 | t.Run(tt.name, func(t *testing.T) {
94 | switch jm := tt.m.(type) {
95 | case *matcher.JobStatus:
96 | assert.Equal(t, jm.Suspended, false)
97 | case *matcher.JobGroup:
98 | if jm.Operator != &matcher.StringEquals {
99 | t.Fatal("JobGroup unexpected operator")
100 | }
101 | case *matcher.JobName:
102 | if jm.Operator != &matcher.StringContains {
103 | t.Fatal("JobName unexpected operator")
104 | }
105 | default:
106 | t.Fatal("Unexpected matcher type")
107 | }
108 | })
109 | }
110 | }
111 |
112 | func TestMatcher_CustomStringOperator(t *testing.T) {
113 | var op matcher.StringOperator = func(_, _ string) bool { return true }
114 | assert.NotEqual(t, matcher.NewJobGroup(&op, "group1"), nil)
115 | }
116 |
117 | func jobCount(sched quartz.Scheduler, matchers ...quartz.Matcher[quartz.ScheduledJob]) int {
118 | keys, _ := sched.GetJobKeys(matchers...)
119 | return len(keys)
120 | }
121 |
--------------------------------------------------------------------------------
/matcher/job_name.go:
--------------------------------------------------------------------------------
1 | //nolint:dupl
2 | package matcher
3 |
4 | import (
5 | "github.com/reugn/go-quartz/quartz"
6 | )
7 |
8 | // JobName implements the quartz.Matcher interface with the type argument
9 | // quartz.ScheduledJob, matching jobs by their name.
10 | // It has public fields to allow predicate pushdown in custom quartz.JobQueue
11 | // implementations.
12 | type JobName struct {
13 | Operator *StringOperator // uses a pointer to compare with standard operators
14 | Pattern string
15 | }
16 |
17 | var _ quartz.Matcher[quartz.ScheduledJob] = (*JobName)(nil)
18 |
19 | // NewJobName returns a new JobName matcher given the string operator and pattern.
20 | func NewJobName(operator *StringOperator, pattern string) quartz.Matcher[quartz.ScheduledJob] {
21 | return &JobName{
22 | Operator: operator,
23 | Pattern: pattern,
24 | }
25 | }
26 |
27 | // JobNameEquals returns a new JobName, matching jobs whose name is identical
28 | // to the given string pattern.
29 | func JobNameEquals(pattern string) quartz.Matcher[quartz.ScheduledJob] {
30 | return NewJobName(&StringEquals, pattern)
31 | }
32 |
33 | // JobNameStartsWith returns a new JobName, matching jobs whose name starts
34 | // with the given string pattern.
35 | func JobNameStartsWith(pattern string) quartz.Matcher[quartz.ScheduledJob] {
36 | return NewJobName(&StringStartsWith, pattern)
37 | }
38 |
39 | // JobNameEndsWith returns a new JobName, matching jobs whose name ends
40 | // with the given string pattern.
41 | func JobNameEndsWith(pattern string) quartz.Matcher[quartz.ScheduledJob] {
42 | return NewJobName(&StringEndsWith, pattern)
43 | }
44 |
45 | // JobNameContains returns a new JobName, matching jobs whose name contains
46 | // the given string pattern.
47 | func JobNameContains(pattern string) quartz.Matcher[quartz.ScheduledJob] {
48 | return NewJobName(&StringContains, pattern)
49 | }
50 |
51 | // IsMatch evaluates JobName matcher on the given job.
52 | func (n *JobName) IsMatch(job quartz.ScheduledJob) bool {
53 | return (*n.Operator)(job.JobDetail().JobKey().Name(), n.Pattern)
54 | }
55 |
--------------------------------------------------------------------------------
/matcher/job_status.go:
--------------------------------------------------------------------------------
1 | package matcher
2 |
3 | import (
4 | "github.com/reugn/go-quartz/quartz"
5 | )
6 |
7 | // JobStatus implements the quartz.Matcher interface with the type argument
8 | // quartz.ScheduledJob, matching jobs by their status.
9 | // It has public fields to allow predicate pushdown in custom quartz.JobQueue
10 | // implementations.
11 | type JobStatus struct {
12 | Suspended bool
13 | }
14 |
15 | var _ quartz.Matcher[quartz.ScheduledJob] = (*JobStatus)(nil)
16 |
17 | // JobActive returns a matcher to match active jobs.
18 | func JobActive() quartz.Matcher[quartz.ScheduledJob] {
19 | return &JobStatus{false}
20 | }
21 |
22 | // JobPaused returns a matcher to match paused jobs.
23 | func JobPaused() quartz.Matcher[quartz.ScheduledJob] {
24 | return &JobStatus{true}
25 | }
26 |
27 | // IsMatch evaluates JobStatus matcher on the given job.
28 | func (s *JobStatus) IsMatch(job quartz.ScheduledJob) bool {
29 | return job.JobDetail().Options().Suspended == s.Suspended
30 | }
31 |
--------------------------------------------------------------------------------
/matcher/string_operator.go:
--------------------------------------------------------------------------------
1 | package matcher
2 |
3 | import "strings"
4 |
5 | // StringOperator is a function to equate two strings.
6 | type StringOperator func(string, string) bool
7 |
8 | // String operators.
9 | var (
10 | StringEquals StringOperator = stringsEqual
11 | StringStartsWith StringOperator = strings.HasPrefix
12 | StringEndsWith StringOperator = strings.HasSuffix
13 | StringContains StringOperator = strings.Contains
14 | )
15 |
16 | func stringsEqual(source, target string) bool {
17 | return source == target
18 | }
19 |
--------------------------------------------------------------------------------
/quartz/cron.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "sort"
7 | "strconv"
8 | "strings"
9 | "time"
10 | )
11 |
12 | // CronTrigger implements the [Trigger] interface.
13 | // Used to fire a Job at given moments in time, defined with Unix 'cron-like' schedule definitions.
14 | //
15 | // Examples:
16 | //
17 | // Expression Meaning
18 | // "0 0 12 * * ?" Fire at 12pm (noon) every day
19 | // "0 15 10 ? * *" Fire at 10:15am every day
20 | // "0 15 10 * * ?" Fire at 10:15am every day
21 | // "0 15 10 * * ? *" Fire at 10:15am every day
22 | // "0 * 14 * * ?" Fire every minute starting at 2pm and ending at 2:59pm, every day
23 | // "0 0/5 14 * * ?" Fire every 5 minutes starting at 2pm and ending at 2:55pm, every day
24 | // "0 0/5 14,18 * * ?" Fire every 5 minutes starting at 2pm and ending at 2:55pm,
25 | // AND fire every 5 minutes starting at 6pm and ending at 6:55pm, every day
26 | // "0 0-5 14 * * ?" Fire every minute starting at 2pm and ending at 2:05pm, every day
27 | // "0 10,44 14 ? 3 WED" Fire at 2:10pm and at 2:44pm every Wednesday in the month of March.
28 | // "0 15 10 ? * MON-FRI" Fire at 10:15am every Monday, Tuesday, Wednesday, Thursday and Friday
29 | // "0 15 10 15 * ?" Fire at 10:15am on the 15th day of every month
30 | // "0 15 10 ? * 6L" Fire at 10:15am on the last Friday of every month
31 | // "0 15 10 ? * 6#3" Fire at 10:15am on the third Friday of every month
32 | // "0 15 10 L * ?" Fire at 10:15am on the last day of every month
33 | // "0 15 10 L-2 * ?" Fire at 10:15am on the 2nd-to-last last day of every month
34 | type CronTrigger struct {
35 | expression string
36 | fields []*cronField
37 | lastDefined int
38 | location *time.Location
39 | }
40 |
41 | // Verify CronTrigger satisfies the Trigger interface.
42 | var _ Trigger = (*CronTrigger)(nil)
43 |
44 | // NewCronTrigger returns a new [CronTrigger] using the UTC location.
45 | func NewCronTrigger(expression string) (*CronTrigger, error) {
46 | return NewCronTriggerWithLoc(expression, time.UTC)
47 | }
48 |
49 | // NewCronTriggerWithLoc returns a new [CronTrigger] with the given [time.Location].
50 | func NewCronTriggerWithLoc(expression string, location *time.Location) (*CronTrigger, error) {
51 | if location == nil {
52 | return nil, newIllegalArgumentError("location is nil")
53 | }
54 | expression = trimCronExpression(expression)
55 | fields, err := parseCronExpression(expression)
56 | if err != nil {
57 | return nil, err
58 | }
59 | lastDefined := -1
60 | for i, field := range fields {
61 | if len(field.values) > 0 {
62 | lastDefined = i
63 | }
64 | }
65 | // full wildcard expression
66 | if lastDefined == -1 {
67 | fields[0].values, _ = fillRangeValues(0, 59)
68 | }
69 |
70 | return &CronTrigger{
71 | expression: expression,
72 | fields: fields,
73 | lastDefined: lastDefined,
74 | location: location,
75 | }, nil
76 | }
77 |
78 | // NextFireTime returns the next time at which the CronTrigger is scheduled to fire.
79 | func (ct *CronTrigger) NextFireTime(prev int64) (int64, error) {
80 | prevTime := time.Unix(prev/int64(time.Second), 0).In(ct.location)
81 | // build a CronStateMachine and run once
82 | csm := newCSMFromFields(prevTime, ct.fields)
83 | nextDateTime := csm.NextTriggerTime(prevTime.Location())
84 | if nextDateTime.Before(prevTime) || nextDateTime.Equal(prevTime) {
85 | return 0, ErrTriggerExpired
86 | }
87 | return nextDateTime.UnixNano(), nil
88 | }
89 |
90 | // Description returns the description of the cron trigger.
91 | func (ct *CronTrigger) Description() string {
92 | return fmt.Sprintf("CronTrigger%s%s%s%s", Sep, ct.expression, Sep, ct.location)
93 | }
94 |
95 | // cronField represents a parsed cron expression field.
96 | type cronField struct {
97 | // stores the parsed and sorted numeric values for the field
98 | values []int
99 | // n is used to store special values for the day-of-month
100 | // and day-of-week fields
101 | n int
102 | }
103 |
104 | // newCronField returns a new cronField.
105 | func newCronField(values []int) *cronField {
106 | return &cronField{values: values}
107 | }
108 |
109 | // newCronFieldN returns a new cronField with the provided n.
110 | func newCronFieldN(values []int, n int) *cronField {
111 | return &cronField{values: values, n: n}
112 | }
113 |
114 | // add increments each element of the underlying values slice by the given delta.
115 | func (cf *cronField) add(delta int) {
116 | for i := range cf.values {
117 | cf.values[i] += delta
118 | }
119 | }
120 |
121 | // String is the cronField fmt.Stringer implementation.
122 | func (cf *cronField) String() string {
123 | return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(cf.values)), ","), "[]")
124 | }
125 |
126 | // boundary represents inclusive range boundaries for cron field values.
127 | type boundary struct {
128 | lower int
129 | upper int
130 | }
131 |
132 | var (
133 | months = []string{"0", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"}
134 | days = []string{"0", "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}
135 |
136 | // the pre-defined cron expressions
137 | special = map[string]string{
138 | "@yearly": "0 0 0 1 1 *",
139 | "@monthly": "0 0 0 1 * *",
140 | "@weekly": "0 0 0 * * 1",
141 | "@daily": "0 0 0 * * *",
142 | "@hourly": "0 0 * * * *",
143 | }
144 | )
145 |
146 | // ValidateCronExpression validates a cron expression string.
147 | // A valid expression consists of the following fields:
148 | //
149 | //
150 | //
151 | // where the field is optional.
152 | // See the cron expression format table in the readme file for supported special characters.
153 | func ValidateCronExpression(expression string) error {
154 | _, err := parseCronExpression(trimCronExpression(expression))
155 | return err
156 | }
157 |
158 | // parseCronExpression parses a cron expression string.
159 | func parseCronExpression(expression string) ([]*cronField, error) {
160 | var tokens []string
161 | if value, ok := special[expression]; ok {
162 | tokens = strings.Split(value, " ")
163 | } else {
164 | tokens = strings.Split(expression, " ")
165 | }
166 | length := len(tokens)
167 | if length < 6 || length > 7 {
168 | return nil, newCronParseError("invalid expression length")
169 | }
170 | if length == 6 {
171 | tokens = append(tokens, "*")
172 | }
173 | if (tokens[3] != "?" && tokens[3] != "*") && (tokens[5] != "?" && tokens[5] != "*") {
174 | return nil, newCronParseError("day field set twice")
175 | }
176 |
177 | return buildCronField(tokens)
178 | }
179 |
180 | var whitespacePattern = regexp.MustCompile(`\s+`)
181 |
182 | func trimCronExpression(expression string) string {
183 | return strings.TrimSpace(whitespacePattern.ReplaceAllString(expression, " "))
184 | }
185 |
186 | func buildCronField(tokens []string) ([]*cronField, error) {
187 | var err error
188 | fields := make([]*cronField, 7)
189 | // second field
190 | fields[0], err = parseField(tokens[0], boundary{0, 59}, nil)
191 | if err != nil {
192 | return nil, err
193 | }
194 | // minute field
195 | fields[1], err = parseField(tokens[1], boundary{0, 59}, nil)
196 | if err != nil {
197 | return nil, err
198 | }
199 | // hour field
200 | fields[2], err = parseField(tokens[2], boundary{0, 23}, nil)
201 | if err != nil {
202 | return nil, err
203 | }
204 | // day-of-month field
205 | fields[3], err = parseDayOfMonthField(tokens[3], boundary{1, 31}, nil)
206 | if err != nil {
207 | return nil, err
208 | }
209 | // month field
210 | fields[4], err = parseField(tokens[4], boundary{1, 12}, months)
211 | if err != nil {
212 | return nil, err
213 | }
214 | // day-of-week field
215 | fields[5], err = parseDayOfWeekField(tokens[5], boundary{1, 7}, days)
216 | if err != nil {
217 | return nil, err
218 | }
219 | fields[5].add(-1)
220 | // year field
221 | fields[6], err = parseField(tokens[6], boundary{1970, 1970 * 2}, nil)
222 | if err != nil {
223 | return nil, err
224 | }
225 |
226 | return fields, nil
227 | }
228 |
229 | func parseField(field string, bound boundary, names []string) (*cronField, error) {
230 | // any value
231 | if field == "*" || field == "?" {
232 | return newCronField([]int{}), nil
233 | }
234 | // list values
235 | if strings.ContainsRune(field, listRune) {
236 | return parseListField(field, bound, names)
237 | }
238 | // step values
239 | if strings.ContainsRune(field, stepRune) {
240 | return parseStepField(field, bound, names)
241 | }
242 | // range values
243 | if strings.ContainsRune(field, rangeRune) {
244 | return parseRangeField(field, bound, names)
245 | }
246 | // simple value
247 | numeric, err := normalize(field, names)
248 | if err != nil {
249 | return nil, err
250 | }
251 | if inScope(numeric, bound.lower, bound.upper) {
252 | return newCronField([]int{numeric}), nil
253 | }
254 |
255 | return nil, newInvalidCronFieldError("numeric", field)
256 | }
257 |
258 | var (
259 | cronLastMonthDayRegex = regexp.MustCompile(`^L(-[0-9]+)?$`)
260 | cronWeekdayRegex = regexp.MustCompile(`^[0-9]+W$`)
261 |
262 | cronLastWeekdayRegex = regexp.MustCompile(`^[a-zA-Z0-9]*L$`)
263 | cronHashRegex = regexp.MustCompile(`^[a-zA-Z0-9]+#[0-9]+$`)
264 | )
265 |
266 | func parseDayOfMonthField(field string, bound boundary, names []string) (*cronField, error) {
267 | if strings.ContainsRune(field, lastRune) && cronLastMonthDayRegex.MatchString(field) {
268 | if field == string(lastRune) {
269 | return newCronFieldN([]int{}, cronLastDayOfMonthN), nil
270 | }
271 | values := strings.Split(field, string(rangeRune))
272 | if len(values) != 2 {
273 | return nil, newInvalidCronFieldError("last", field)
274 | }
275 | n, err := strconv.Atoi(values[1])
276 | if err != nil || !inScope(n, bound.lower, bound.upper) {
277 | return nil, newInvalidCronFieldError("last", field)
278 | }
279 | return newCronFieldN([]int{}, -n), nil
280 | }
281 |
282 | if strings.ContainsRune(field, weekdayRune) {
283 | if field == fmt.Sprintf("%c%c", lastRune, weekdayRune) {
284 | return newCronFieldN([]int{0}, cronLastDayOfMonthN|cronWeekdayN), nil
285 | }
286 |
287 | if cronWeekdayRegex.MatchString(field) {
288 | day := strings.TrimSuffix(field, string(weekdayRune))
289 | if day == "" {
290 | return nil, newInvalidCronFieldError("weekday", field)
291 | }
292 | dayOfMonth, err := strconv.Atoi(day)
293 | if err != nil || !inScope(dayOfMonth, bound.lower, bound.upper) {
294 | return nil, newInvalidCronFieldError("weekday", field)
295 | }
296 | return newCronFieldN([]int{dayOfMonth}, cronWeekdayN), nil
297 | }
298 | }
299 |
300 | return parseField(field, bound, names)
301 | }
302 |
303 | func parseDayOfWeekField(field string, bound boundary, names []string) (*cronField, error) {
304 | if strings.ContainsRune(field, lastRune) && cronLastWeekdayRegex.MatchString(field) {
305 | day := strings.TrimSuffix(field, string(lastRune))
306 | if day == "" { // Saturday
307 | return newCronFieldN([]int{7}, -1), nil
308 | }
309 | dayOfWeek, err := normalize(day, names)
310 | if err != nil || !inScope(dayOfWeek, bound.lower, bound.upper) {
311 | return nil, newInvalidCronFieldError("last", field)
312 | }
313 | return newCronFieldN([]int{dayOfWeek}, -1), nil
314 | }
315 |
316 | if strings.ContainsRune(field, hashRune) && cronHashRegex.MatchString(field) {
317 | values := strings.Split(field, string(hashRune))
318 | if len(values) != 2 {
319 | return nil, newInvalidCronFieldError("hash", field)
320 | }
321 | dayOfWeek, err := normalize(values[0], names)
322 | if err != nil || !inScope(dayOfWeek, bound.lower, bound.upper) {
323 | return nil, newInvalidCronFieldError("hash", field)
324 | }
325 | n, err := strconv.Atoi(values[1])
326 | if err != nil || !inScope(n, 1, 5) {
327 | return nil, newInvalidCronFieldError("hash", field)
328 | }
329 | return newCronFieldN([]int{dayOfWeek}, n), nil
330 | }
331 |
332 | return parseField(field, bound, names)
333 | }
334 |
335 | func parseListField(field string, bound boundary, names []string) (*cronField, error) {
336 | t := strings.Split(field, string(listRune))
337 | values, stepValues := extractStepValues(t)
338 | values, rangeValues := extractRangeValues(values)
339 | listValues, err := translateLiterals(names, values)
340 | if err != nil {
341 | return nil, err
342 | }
343 | for _, v := range stepValues {
344 | stepField, err := parseStepField(v, bound, names)
345 | if err != nil {
346 | return nil, err
347 | }
348 | listValues = append(listValues, stepField.values...)
349 | }
350 | for _, v := range rangeValues {
351 | rangeField, err := parseRangeField(v, bound, names)
352 | if err != nil {
353 | return nil, err
354 | }
355 | listValues = append(listValues, rangeField.values...)
356 | }
357 |
358 | sort.Ints(listValues)
359 | return newCronField(listValues), nil
360 | }
361 |
362 | func parseRangeField(field string, bound boundary, names []string) (*cronField, error) {
363 | t := strings.Split(field, string(rangeRune))
364 | if len(t) != 2 {
365 | return nil, newInvalidCronFieldError("range", field)
366 | }
367 | from, err := normalize(t[0], names)
368 | if err != nil {
369 | return nil, err
370 | }
371 | to, err := normalize(t[1], names)
372 | if err != nil {
373 | return nil, err
374 | }
375 | if !inScope(from, bound.lower, bound.upper) || !inScope(to, bound.lower, bound.upper) {
376 | return nil, newInvalidCronFieldError("range", field)
377 | }
378 | rangeValues, err := fillRangeValues(from, to)
379 | if err != nil {
380 | return nil, err
381 | }
382 |
383 | return newCronField(rangeValues), nil
384 | }
385 |
386 | func parseStepField(field string, bound boundary, names []string) (*cronField, error) {
387 | t := strings.Split(field, string(stepRune))
388 | if len(t) != 2 {
389 | return nil, newInvalidCronFieldError("step", field)
390 | }
391 | to := bound.upper
392 | var (
393 | from int
394 | err error
395 | )
396 | switch {
397 | case t[0] == "*":
398 | from = bound.lower
399 | case strings.ContainsRune(t[0], rangeRune):
400 | trange := strings.Split(t[0], string(rangeRune))
401 | if len(trange) != 2 {
402 | return nil, newInvalidCronFieldError("step", field)
403 | }
404 | from, err = normalize(trange[0], names)
405 | if err != nil {
406 | return nil, err
407 | }
408 | to, err = normalize(trange[1], names)
409 | if err != nil {
410 | return nil, err
411 | }
412 | default:
413 | from, err = normalize(t[0], names)
414 | if err != nil {
415 | return nil, err
416 | }
417 | }
418 |
419 | step, err := strconv.Atoi(t[1])
420 | if err != nil {
421 | return nil, newInvalidCronFieldError("step", field)
422 | }
423 | if !inScope(from, bound.lower, bound.upper) || !inScope(step, 1, bound.upper) ||
424 | !inScope(to, bound.lower, bound.upper) {
425 | return nil, newInvalidCronFieldError("step", field)
426 | }
427 |
428 | stepValues, err := fillStepValues(from, step, to)
429 | if err != nil {
430 | return nil, err
431 | }
432 |
433 | return newCronField(stepValues), nil
434 | }
435 |
--------------------------------------------------------------------------------
/quartz/cron_test.go:
--------------------------------------------------------------------------------
1 | package quartz_test
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "testing"
7 | "time"
8 |
9 | "github.com/reugn/go-quartz/internal/assert"
10 | "github.com/reugn/go-quartz/quartz"
11 | )
12 |
13 | func TestCronExpression(t *testing.T) {
14 | t.Parallel()
15 | tests := []struct {
16 | expression string
17 | expected string
18 | }{
19 | {
20 | expression: "10/20 15 14 5-10 * ? *",
21 | expected: "Sat Mar 9 14:15:30 2024",
22 | },
23 | {
24 | expression: "10 5,7,9 14-16 * * ? *",
25 | expected: "Sat Jan 6 15:07:10 2024",
26 | },
27 | {
28 | expression: "0 5,7,9 14/2 ? * WED,Sat *",
29 | expected: "Sat Jan 13 16:07:00 2024",
30 | },
31 | {
32 | expression: "* * * * * ? *",
33 | expected: "Mon Jan 1 12:00:50 2024",
34 | },
35 | {
36 | expression: "0 0 14/2 ? * mon/3 *",
37 | expected: "Thu Feb 1 22:00:00 2024",
38 | },
39 | {
40 | expression: "0 5-9 14/2 ? * 3-5 *",
41 | expected: "Wed Jan 3 22:09:00 2024",
42 | },
43 | {
44 | expression: "*/3 */51 */12 */2 */4 ? *",
45 | expected: "Wed Jan 3 00:00:30 2024",
46 | },
47 | {
48 | expression: "*/15 * * ? * 1-7",
49 | expected: "Mon Jan 1 12:12:30 2024",
50 | },
51 | {
52 | expression: "10,20 10,20 10,20 10,20 6,12 ?",
53 | expected: "Wed Dec 10 10:10:20 2025",
54 | },
55 | {
56 | expression: "10,20 10,20 10,20 ? 6,12 3,6",
57 | expected: "Tue Jun 25 10:10:20 2024",
58 | },
59 | {
60 | expression: "0 0 0 ? 4,6 SAT,MON",
61 | expected: "Mon Jun 22 00:00:00 2026",
62 | },
63 | {
64 | expression: "0 0 0 29 2 ?",
65 | expected: "Fri Feb 29 00:00:00 2228",
66 | },
67 | {
68 | expression: "0 0 0 1 5 ? 2023/2",
69 | expected: "Sat May 1 00:00:00 2123",
70 | },
71 | {
72 | expression: "0 0 0-2,5,7-9,21-22 * * *", // mixed range
73 | expected: "Sun Jan 7 02:00:00 2024",
74 | },
75 | {
76 | expression: "0 0 0 ? * SUN,TUE-WED,Fri-Sat", // mixed range
77 | expected: "Sun Mar 10 00:00:00 2024",
78 | },
79 | {
80 | expression: "0 0 5-11/2 * * *", // step with range
81 | expected: "Sun Jan 14 07:00:00 2024",
82 | },
83 | {
84 | expression: "0 0 1,5-11/3 * * *", // step with range
85 | expected: "Sun Jan 14 05:00:00 2024",
86 | },
87 | }
88 |
89 | prev := time.Date(2024, 1, 1, 12, 00, 00, 00, time.UTC).UnixNano()
90 | for _, tt := range tests {
91 | test := tt
92 | t.Run(test.expression, func(t *testing.T) {
93 | t.Parallel()
94 | cronTrigger, err := quartz.NewCronTrigger(test.expression)
95 | assert.IsNil(t, err)
96 | result, _ := iterate(prev, cronTrigger, 50)
97 | assert.Equal(t, result, test.expected)
98 | })
99 | }
100 | }
101 |
102 | func TestCronExpressionExpired(t *testing.T) {
103 | t.Parallel()
104 | prev := time.Date(2023, 4, 22, 12, 00, 00, 00, time.UTC).UnixNano()
105 | cronTrigger, err := quartz.NewCronTrigger("0 0 0 1 1 ? 2023")
106 | assert.IsNil(t, err)
107 | _, err = cronTrigger.NextFireTime(prev)
108 | assert.ErrorIs(t, err, quartz.ErrTriggerExpired)
109 | }
110 |
111 | func TestCronExpressionWithLoc(t *testing.T) {
112 | t.Parallel()
113 | tests := []struct {
114 | expression string
115 | expected string
116 | }{
117 | {
118 | expression: "0 5 22-23 * * Sun *",
119 | expected: "Mon Oct 16 03:05:00 2023",
120 | },
121 | {
122 | expression: "0 0 10 * * Sun *",
123 | expected: "Sun Apr 7 14:00:00 2024",
124 | },
125 | }
126 |
127 | loc, err := time.LoadLocation("America/New_York")
128 | assert.IsNil(t, err)
129 | prev := time.Date(2023, 4, 29, 12, 00, 00, 00, loc).UnixNano()
130 | for _, tt := range tests {
131 | test := tt
132 | t.Run(test.expression, func(t *testing.T) {
133 | t.Parallel()
134 | cronTrigger, err := quartz.NewCronTriggerWithLoc(test.expression, loc)
135 | assert.IsNil(t, err)
136 | result, _ := iterate(prev, cronTrigger, 50)
137 | assert.Equal(t, result, test.expected)
138 | })
139 | }
140 | }
141 |
142 | func TestCronExpressionDaysOfWeek(t *testing.T) {
143 | t.Parallel()
144 | tests := []struct {
145 | dayOfWeek string
146 | expected string
147 | }{
148 | {
149 | dayOfWeek: "Sun",
150 | expected: "Sun Apr 21 00:00:00 2019",
151 | },
152 | {
153 | dayOfWeek: "Mon",
154 | expected: "Mon Apr 22 00:00:00 2019",
155 | },
156 | {
157 | dayOfWeek: "Tue",
158 | expected: "Tue Apr 23 00:00:00 2019",
159 | },
160 | {
161 | dayOfWeek: "Wed",
162 | expected: "Wed Apr 24 00:00:00 2019",
163 | },
164 | {
165 | dayOfWeek: "Thu",
166 | expected: "Thu Apr 18 00:00:00 2019",
167 | },
168 | {
169 | dayOfWeek: "Fri",
170 | expected: "Fri Apr 19 00:00:00 2019",
171 | },
172 | {
173 | dayOfWeek: "Sat",
174 | expected: "Sat Apr 20 00:00:00 2019",
175 | },
176 | }
177 |
178 | for i, tt := range tests {
179 | n, test := i, tt
180 | t.Run(test.dayOfWeek, func(t *testing.T) {
181 | t.Parallel()
182 | assertDayOfWeek(t, test.dayOfWeek, test.expected)
183 | assertDayOfWeek(t, strconv.Itoa(n+1), test.expected)
184 | })
185 | }
186 | }
187 |
188 | func assertDayOfWeek(t *testing.T, dayOfWeek, expected string) {
189 | t.Helper()
190 | const prev = int64(1555524000000000000) // Wed Apr 17 18:00:00 2019
191 | expression := fmt.Sprintf("0 0 0 * * %s", dayOfWeek)
192 | cronTrigger, err := quartz.NewCronTrigger(expression)
193 | assert.IsNil(t, err)
194 | nextFireTime, err := cronTrigger.NextFireTime(prev)
195 | assert.IsNil(t, err)
196 | actual := time.Unix(nextFireTime/int64(time.Second), 0).UTC().Format(readDateLayout)
197 | assert.Equal(t, actual, expected)
198 | }
199 |
200 | func TestCronExpressionSpecial(t *testing.T) {
201 | t.Parallel()
202 | tests := []struct {
203 | expression string
204 | expected string
205 | }{
206 | {
207 | expression: "@yearly",
208 | expected: "Sat Jan 1 00:00:00 2124",
209 | },
210 | {
211 | expression: "@monthly",
212 | expected: "Sat May 1 00:00:00 2032",
213 | },
214 | {
215 | expression: "@weekly",
216 | expected: "Sun Nov 30 00:00:00 2025",
217 | },
218 | {
219 | expression: "@daily",
220 | expected: "Wed Apr 10 00:00:00 2024",
221 | },
222 | {
223 | expression: "@hourly",
224 | expected: "Fri Jan 5 16:00:00 2024",
225 | },
226 | }
227 |
228 | prev := time.Date(2024, 1, 1, 12, 00, 00, 00, time.UTC).UnixNano()
229 | for _, tt := range tests {
230 | test := tt
231 | t.Run(test.expression, func(t *testing.T) {
232 | t.Parallel()
233 | cronTrigger, err := quartz.NewCronTrigger(test.expression)
234 | assert.IsNil(t, err)
235 | result, _ := iterate(prev, cronTrigger, 100)
236 | assert.Equal(t, result, test.expected)
237 | })
238 | }
239 | }
240 |
241 | func TestCronExpressionDayOfMonth(t *testing.T) {
242 | t.Parallel()
243 | tests := []struct {
244 | expression string
245 | expected string
246 | }{
247 | {
248 | expression: "0 15 10 L * ?",
249 | expected: "Mon Mar 31 10:15:00 2025",
250 | },
251 | {
252 | expression: "0 15 10 L-5 * ?",
253 | expected: "Wed Mar 26 10:15:00 2025",
254 | },
255 | {
256 | expression: "0 15 10 15W * ?",
257 | expected: "Fri Mar 14 10:15:00 2025",
258 | },
259 | {
260 | expression: "0 15 10 1W 1/2 ?",
261 | expected: "Wed Jul 1 10:15:00 2026",
262 | },
263 | {
264 | expression: "0 15 10 31W * ?",
265 | expected: "Mon Mar 31 10:15:00 2025",
266 | },
267 | {
268 | expression: "0 15 10 LW * ?",
269 | expected: "Mon Mar 31 10:15:00 2025",
270 | },
271 | }
272 |
273 | prev := time.Date(2024, 1, 1, 12, 00, 00, 00, time.UTC).UnixNano()
274 | for _, tt := range tests {
275 | test := tt
276 | t.Run(test.expression, func(t *testing.T) {
277 | t.Parallel()
278 | cronTrigger, err := quartz.NewCronTrigger(test.expression)
279 | assert.IsNil(t, err)
280 | result, _ := iterate(prev, cronTrigger, 15)
281 | assert.Equal(t, result, test.expected)
282 | })
283 | }
284 | }
285 |
286 | func TestCronExpressionDayOfWeek(t *testing.T) {
287 | t.Parallel()
288 | tests := []struct {
289 | expression string
290 | expected string
291 | }{
292 | {
293 | expression: "0 15 10 ? * L",
294 | expected: "Sat Oct 26 10:15:00 2024",
295 | },
296 | {
297 | expression: "0 15 10 ? * 5L",
298 | expected: "Thu Oct 31 10:15:00 2024",
299 | },
300 | {
301 | expression: "0 15 10 ? * THUL",
302 | expected: "Thu Oct 31 10:15:00 2024",
303 | },
304 | {
305 | expression: "0 15 10 ? * 2#1",
306 | expected: "Mon Nov 4 10:15:00 2024",
307 | },
308 | {
309 | expression: "0 15 10 ? * MON#1",
310 | expected: "Mon Nov 4 10:15:00 2024",
311 | },
312 | {
313 | expression: "0 15 10 ? * 3#5",
314 | expected: "Tue Mar 31 10:15:00 2026",
315 | },
316 | {
317 | expression: "0 15 10 ? * Tue#5",
318 | expected: "Tue Mar 31 10:15:00 2026",
319 | },
320 | {
321 | expression: "0 15 10 ? * 7#5",
322 | expected: "Sat May 30 10:15:00 2026",
323 | },
324 | {
325 | expression: "0 15 10 ? * sat#5",
326 | expected: "Sat May 30 10:15:00 2026",
327 | },
328 | }
329 |
330 | prev := time.Date(2024, 1, 1, 12, 00, 00, 00, time.UTC).UnixNano()
331 | for _, tt := range tests {
332 | test := tt
333 | t.Run(test.expression, func(t *testing.T) {
334 | t.Parallel()
335 | cronTrigger, err := quartz.NewCronTrigger(test.expression)
336 | assert.IsNil(t, err)
337 | result, _ := iterate(prev, cronTrigger, 10)
338 | assert.Equal(t, result, test.expected)
339 | })
340 | }
341 | }
342 |
343 | func TestCronExpressionInvalidLength(t *testing.T) {
344 | t.Parallel()
345 | _, err := quartz.NewCronTrigger("0 0 0 * *")
346 | assert.ErrorContains(t, err, "invalid expression length")
347 | }
348 |
349 | func TestCronTriggerNilLocationError(t *testing.T) {
350 | t.Parallel()
351 | _, err := quartz.NewCronTriggerWithLoc("@daily", nil)
352 | assert.ErrorContains(t, err, "location is nil")
353 | }
354 |
355 | func TestCronExpressionDescription(t *testing.T) {
356 | t.Parallel()
357 | expression := "0 0 0 29 2 ?"
358 | cronTrigger, err := quartz.NewCronTrigger(expression)
359 | assert.IsNil(t, err)
360 | assert.Equal(t, cronTrigger.Description(), fmt.Sprintf("CronTrigger::%s::UTC", expression))
361 | }
362 |
363 | func TestCronExpressionValidate(t *testing.T) {
364 | t.Parallel()
365 | assert.IsNil(t, quartz.ValidateCronExpression("@monthly"))
366 | assert.NotEqual(t, quartz.ValidateCronExpression(""), nil)
367 | }
368 |
369 | func TestCronExpressionTrim(t *testing.T) {
370 | t.Parallel()
371 | expression := " 0 0 10 * * Sun * "
372 | assert.IsNil(t, quartz.ValidateCronExpression(expression))
373 | trigger, err := quartz.NewCronTrigger(expression)
374 | assert.IsNil(t, err)
375 | assert.Equal(t, trigger.Description(), "CronTrigger::0 0 10 * * Sun *::UTC")
376 |
377 | expression = " \t\n 0 0 10 * * Sun \n* \r\n "
378 | assert.IsNil(t, quartz.ValidateCronExpression(expression))
379 | trigger, err = quartz.NewCronTrigger(expression)
380 | assert.IsNil(t, err)
381 | assert.Equal(t, trigger.Description(), "CronTrigger::0 0 10 * * Sun *::UTC")
382 | }
383 |
384 | const readDateLayout = "Mon Jan 2 15:04:05 2006"
385 |
386 | func iterate(prev int64, cronTrigger *quartz.CronTrigger, iterations int) (string, error) {
387 | var err error
388 | for i := 0; i < iterations; i++ {
389 | prev, err = cronTrigger.NextFireTime(prev)
390 | // log.Print(time.Unix(prev/int64(time.Second), 0).UTC().Format(readDateLayout))
391 | if err != nil {
392 | fmt.Println(err)
393 | return "", err
394 | }
395 | }
396 | return time.Unix(prev/int64(time.Second), 0).UTC().Format(readDateLayout), nil
397 | }
398 |
399 | func TestCronExpressionParseError(t *testing.T) {
400 | t.Parallel()
401 | tests := []string{
402 | "-1 * * * * *",
403 | "X * * * * *",
404 | "* X * * * *",
405 | "* * X * * *",
406 | "* * * X * *",
407 | "* * * * X *",
408 | "* * * * * X",
409 | "* * * * * * X",
410 | "1,X/1 * * * * *",
411 | "1,X-1 * * * * *",
412 | "1-2-3 * * * * *",
413 | "X-2 * * * * *",
414 | "1-X * * * * *",
415 | "100-200 * * * * *",
416 | "1/2/3 * * * * *",
417 | "1-2-3/4 * * * * *",
418 | "X-2/4 * * * * *",
419 | "1-X/4 * * * * *",
420 | "X/4 * * * * *",
421 | "*/X * * * * *",
422 | "200/100 * * * * *",
423 | "0 5,7 14 1 * Sun *", // day field set twice
424 | "0 5,7 14 ? * 2#6 *",
425 | "0 5,7 14 ? * 2#4,4L *",
426 | "0 0 0 * * -1#1",
427 | "0 0 0 ? * 1#-1",
428 | "0 0 0 ? * #1",
429 | "0 0 0 ? * 1#",
430 | "0 0 0 * * a#2",
431 | "0 0 0 * * 50#2",
432 | "0 5,7 14 ? * 8L *",
433 | "0 5,7 14 ? * -1L *",
434 | "0 5,7 14 ? * 0L *",
435 | "0 15 10 W * ?",
436 | "0 15 10 0W * ?",
437 | "0 15 10 32W * ?",
438 | "0 15 10 W15 * ?",
439 | "0 15 10 L- * ?",
440 | "0 15 10 L-a * ?",
441 | "0 15 10 L-32 * ?",
442 | "0 15 10 WL * ?",
443 | }
444 |
445 | for _, tt := range tests {
446 | test := tt
447 | t.Run(test, func(t *testing.T) {
448 | t.Parallel()
449 | _, err := quartz.NewCronTrigger(test)
450 | assert.ErrorIs(t, err, quartz.ErrCronParse)
451 | })
452 | }
453 | }
454 |
--------------------------------------------------------------------------------
/quartz/csm.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "time"
5 |
6 | CSM "github.com/reugn/go-quartz/internal/csm"
7 | )
8 |
9 | const (
10 | cronLastDayOfMonthN = CSM.NLastDayOfMonth
11 | cronWeekdayN = CSM.NWeekday
12 | )
13 |
14 | func newCSMFromFields(prev time.Time, fields []*cronField) *CSM.CronStateMachine {
15 | year := CSM.NewCommonNode(prev.Year(), 0, 999999, fields[6].values)
16 | month := CSM.NewCommonNode(int(prev.Month()), 1, 12, fields[4].values)
17 | var day *CSM.DayNode
18 | if len(fields[5].values) != 0 {
19 | day = CSM.NewWeekDayNode(prev.Day(), 1, 31, fields[5].n, fields[5].values, month, year)
20 | } else {
21 | day = CSM.NewMonthDayNode(prev.Day(), 1, 31, fields[3].n, fields[3].values, month, year)
22 | }
23 | hour := CSM.NewCommonNode(prev.Hour(), 0, 59, fields[2].values)
24 | minute := CSM.NewCommonNode(prev.Minute(), 0, 59, fields[1].values)
25 | second := CSM.NewCommonNode(prev.Second(), 0, 59, fields[0].values)
26 |
27 | csm := CSM.NewCronStateMachine(second, minute, hour, day, month, year)
28 | return csm
29 | }
30 |
--------------------------------------------------------------------------------
/quartz/doc.go:
--------------------------------------------------------------------------------
1 | // Package quartz contains core components of the scheduling library.
2 | package quartz
3 |
--------------------------------------------------------------------------------
/quartz/error.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | )
7 |
8 | // Errors
9 | var (
10 | ErrIllegalArgument = errors.New("illegal argument")
11 | ErrCronParse = errors.New("parse cron expression")
12 | ErrTriggerExpired = errors.New("trigger has expired")
13 |
14 | ErrIllegalState = errors.New("illegal state")
15 | ErrQueueEmpty = errors.New("queue is empty")
16 | ErrJobNotFound = errors.New("job not found")
17 | ErrJobAlreadyExists = errors.New("job already exists")
18 | ErrJobIsSuspended = errors.New("job is suspended")
19 | ErrJobIsActive = errors.New("job is active")
20 | )
21 |
22 | // newIllegalArgumentError returns an illegal argument error with a custom
23 | // error message, which unwraps to ErrIllegalArgument.
24 | func newIllegalArgumentError(message string) error {
25 | return fmt.Errorf("%w: %s", ErrIllegalArgument, message)
26 | }
27 |
28 | // newCronParseError returns a cron parse error with a custom error message,
29 | // which unwraps to ErrCronParse.
30 | func newCronParseError(message string) error {
31 | return fmt.Errorf("%w: %s", ErrCronParse, message)
32 | }
33 |
34 | // newIllegalStateError returns an illegal state error specifying it with err.
35 | func newIllegalStateError(err error) error {
36 | return fmt.Errorf("%w: %w", ErrIllegalState, err)
37 | }
38 |
--------------------------------------------------------------------------------
/quartz/error_test.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/reugn/go-quartz/internal/assert"
8 | )
9 |
10 | func TestError_IllegalArgument(t *testing.T) {
11 | message := "argument is nil"
12 | err := newIllegalArgumentError(message)
13 |
14 | assert.ErrorIs(t, err, ErrIllegalArgument)
15 | assert.Equal(t, err.Error(), fmt.Sprintf("%s: %s", ErrIllegalArgument, message))
16 | }
17 |
18 | func TestError_CronParse(t *testing.T) {
19 | message := "invalid field"
20 | err := newCronParseError(message)
21 |
22 | assert.ErrorIs(t, err, ErrCronParse)
23 | assert.Equal(t, err.Error(), fmt.Sprintf("%s: %s", ErrCronParse, message))
24 | }
25 |
26 | func TestError_IllegalState(t *testing.T) {
27 | err := newIllegalStateError(ErrJobAlreadyExists)
28 |
29 | assert.ErrorIs(t, err, ErrIllegalState)
30 | assert.ErrorIs(t, err, ErrJobAlreadyExists)
31 | assert.Equal(t, err.Error(), fmt.Sprintf("%s: %s", ErrIllegalState, ErrJobAlreadyExists))
32 | }
33 |
--------------------------------------------------------------------------------
/quartz/job.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "context"
5 | )
6 |
7 | // Job represents an interface to be implemented by structs which
8 | // represent a task to be performed.
9 | // Some Job implementations can be found in the job package.
10 | type Job interface {
11 | // Execute is called by a Scheduler when the Trigger associated
12 | // with this job fires.
13 | Execute(context.Context) error
14 |
15 | // Description returns the description of the Job.
16 | Description() string
17 | }
18 |
--------------------------------------------------------------------------------
/quartz/job_detail.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | // JobDetailOptions represents additional JobDetail properties.
8 | type JobDetailOptions struct {
9 | // MaxRetries is the maximum number of retries before aborting the
10 | // current job execution.
11 | // Default: 0.
12 | MaxRetries int
13 |
14 | // RetryInterval is the fixed time interval between retry attempts.
15 | // Default: 1 second.
16 | RetryInterval time.Duration
17 |
18 | // Replace indicates whether the job should replace an existing job
19 | // with the same key.
20 | // Default: false.
21 | Replace bool
22 |
23 | // Suspended indicates whether the job is paused.
24 | // Default: false.
25 | Suspended bool
26 | }
27 |
28 | // NewDefaultJobDetailOptions returns a new instance of JobDetailOptions
29 | // with the default values.
30 | func NewDefaultJobDetailOptions() *JobDetailOptions {
31 | return &JobDetailOptions{ // using explicit default values for visibility
32 | MaxRetries: 0,
33 | RetryInterval: time.Second,
34 | Replace: false,
35 | Suspended: false,
36 | }
37 | }
38 |
39 | // JobDetail conveys the detail properties of a given Job instance.
40 | type JobDetail struct {
41 | job Job
42 | jobKey *JobKey
43 | opts *JobDetailOptions
44 | }
45 |
46 | // NewJobDetail creates and returns a new JobDetail.
47 | func NewJobDetail(job Job, jobKey *JobKey) *JobDetail {
48 | return NewJobDetailWithOptions(job, jobKey, NewDefaultJobDetailOptions())
49 | }
50 |
51 | // NewJobDetailWithOptions creates and returns a new JobDetail configured as specified.
52 | func NewJobDetailWithOptions(job Job, jobKey *JobKey, opts *JobDetailOptions) *JobDetail {
53 | return &JobDetail{
54 | job: job,
55 | jobKey: jobKey,
56 | opts: opts,
57 | }
58 | }
59 |
60 | // Job returns job.
61 | func (jd *JobDetail) Job() Job {
62 | return jd.job
63 | }
64 |
65 | // JobKey returns jobKey.
66 | func (jd *JobDetail) JobKey() *JobKey {
67 | return jd.jobKey
68 | }
69 |
70 | // Options returns opts.
71 | func (jd *JobDetail) Options() *JobDetailOptions {
72 | return jd.opts
73 | }
74 |
--------------------------------------------------------------------------------
/quartz/job_detail_test.go:
--------------------------------------------------------------------------------
1 | package quartz_test
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/reugn/go-quartz/internal/assert"
8 | "github.com/reugn/go-quartz/job"
9 | "github.com/reugn/go-quartz/quartz"
10 | )
11 |
12 | func TestJobDetail(t *testing.T) {
13 | job := job.NewShellJob("ls -la")
14 | jobKey := quartz.NewJobKey("job")
15 | jobDetail := quartz.NewJobDetail(job, jobKey)
16 |
17 | assert.Equal(t, jobDetail.Job(), quartz.Job(job))
18 | assert.Equal(t, jobDetail.JobKey(), jobKey)
19 | assert.Equal(t, jobDetail.Options(), quartz.NewDefaultJobDetailOptions())
20 | }
21 |
22 | func TestJobDetailWithOptions(t *testing.T) {
23 | job := job.NewShellJob("ls -la")
24 | jobKey := quartz.NewJobKey("job")
25 | opts := quartz.NewDefaultJobDetailOptions()
26 | opts.MaxRetries = 3
27 | opts.RetryInterval = 100 * time.Millisecond
28 | jobDetail := quartz.NewJobDetailWithOptions(job, jobKey, opts)
29 |
30 | assert.Equal(t, jobDetail.Job(), quartz.Job(job))
31 | assert.Equal(t, jobDetail.JobKey(), jobKey)
32 | assert.Equal(t, jobDetail.Options(), opts)
33 | }
34 |
--------------------------------------------------------------------------------
/quartz/job_key.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import "fmt"
4 |
5 | const (
6 | DefaultGroup = "default"
7 | )
8 |
9 | // JobKey represents the identifier of a scheduled job.
10 | // Keys are composed of both a name and group, and the name must be unique
11 | // within the group.
12 | // If only a name is specified then the default group name will be used.
13 | type JobKey struct {
14 | name string
15 | group string
16 | }
17 |
18 | // NewJobKey returns a new NewJobKey using the given name.
19 | func NewJobKey(name string) *JobKey {
20 | return &JobKey{
21 | name: name,
22 | group: DefaultGroup,
23 | }
24 | }
25 |
26 | // NewJobKeyWithGroup returns a new NewJobKey using the given name and group.
27 | func NewJobKeyWithGroup(name, group string) *JobKey {
28 | if group == "" { // use default if empty
29 | group = DefaultGroup
30 | }
31 | return &JobKey{
32 | name: name,
33 | group: group,
34 | }
35 | }
36 |
37 | // String returns string representation of the JobKey.
38 | func (jobKey *JobKey) String() string {
39 | return fmt.Sprintf("%s%s%s", jobKey.group, Sep, jobKey.name)
40 | }
41 |
42 | // Equals indicates whether some other JobKey is "equal to" this one.
43 | func (jobKey *JobKey) Equals(that *JobKey) bool {
44 | return jobKey.name == that.name &&
45 | jobKey.group == that.group
46 | }
47 |
48 | // Name returns the name of the JobKey.
49 | func (jobKey *JobKey) Name() string {
50 | return jobKey.name
51 | }
52 |
53 | // Group returns the group of the JobKey.
54 | func (jobKey *JobKey) Group() string {
55 | return jobKey.group
56 | }
57 |
--------------------------------------------------------------------------------
/quartz/job_key_test.go:
--------------------------------------------------------------------------------
1 | package quartz_test
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/reugn/go-quartz/internal/assert"
8 | "github.com/reugn/go-quartz/quartz"
9 | )
10 |
11 | func TestJobKey(t *testing.T) {
12 | name := "jobName"
13 | jobKey1 := quartz.NewJobKey(name)
14 | assert.Equal(t, jobKey1.Name(), name)
15 | assert.Equal(t, jobKey1.Group(), quartz.DefaultGroup)
16 | assert.Equal(t, jobKey1.String(), fmt.Sprintf("%s::%s", quartz.DefaultGroup, name))
17 |
18 | jobKey2 := quartz.NewJobKeyWithGroup(name, "")
19 | assert.Equal(t, jobKey2.Name(), name)
20 | assert.Equal(t, jobKey2.Group(), quartz.DefaultGroup)
21 | assert.Equal(t, jobKey2.String(), fmt.Sprintf("%s::%s", quartz.DefaultGroup, name))
22 |
23 | if !jobKey1.Equals(jobKey2) {
24 | t.Fatal("job keys must be equal")
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/quartz/matcher.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | // Matcher represents a predicate (boolean-valued function) of one argument.
4 | // Matchers can be used in various Scheduler API methods to select the entities
5 | // that should be operated.
6 | // Standard Matcher implementations are located in the matcher package.
7 | type Matcher[T any] interface {
8 | // IsMatch evaluates this matcher on the given argument.
9 | IsMatch(T) bool
10 | }
11 |
--------------------------------------------------------------------------------
/quartz/queue.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "container/heap"
5 | "sync"
6 | )
7 |
8 | // scheduledJob represents a scheduled job.
9 | // It implements the ScheduledJob interface.
10 | type scheduledJob struct {
11 | job *JobDetail
12 | trigger Trigger
13 | priority int64 // job priority, backed by its next run time.
14 | }
15 |
16 | var _ ScheduledJob = (*scheduledJob)(nil)
17 |
18 | // JobDetail returns the details of the scheduled job.
19 | func (scheduled *scheduledJob) JobDetail() *JobDetail {
20 | return scheduled.job
21 | }
22 |
23 | // Trigger returns the trigger associated with the scheduled job.
24 | func (scheduled *scheduledJob) Trigger() Trigger {
25 | return scheduled.trigger
26 | }
27 |
28 | // NextRunTime returns the next run epoch time for the scheduled job.
29 | func (scheduled *scheduledJob) NextRunTime() int64 {
30 | return scheduled.priority
31 | }
32 |
33 | // JobQueue represents the job queue used by the scheduler.
34 | // The default jobQueue implementation uses an in-memory priority queue that orders
35 | // scheduled jobs by their next execution time, when the job with the closest time
36 | // being removed and returned first.
37 | // An alternative implementation can be provided for customization, e.g. to support
38 | // persistent storage.
39 | // The implementation is required to be thread safe.
40 | type JobQueue interface {
41 | // Push inserts a new scheduled job to the queue.
42 | // This method is also used by the Scheduler to reschedule existing jobs that
43 | // have been dequeued for execution.
44 | Push(job ScheduledJob) error
45 |
46 | // Pop removes and returns the next to run scheduled job from the queue.
47 | // Implementations should return an error wrapping ErrQueueEmpty if the
48 | // queue is empty.
49 | Pop() (ScheduledJob, error)
50 |
51 | // Head returns the first scheduled job without removing it from the queue.
52 | // Implementations should return an error wrapping ErrQueueEmpty if the
53 | // queue is empty.
54 | Head() (ScheduledJob, error)
55 |
56 | // Get returns the scheduled job with the specified key without removing it
57 | // from the queue.
58 | Get(jobKey *JobKey) (ScheduledJob, error)
59 |
60 | // Remove removes and returns the scheduled job with the specified key.
61 | Remove(jobKey *JobKey) (ScheduledJob, error)
62 |
63 | // ScheduledJobs returns a slice of scheduled jobs in the queue.
64 | // The matchers parameter acts as a filter to build the resulting list.
65 | // For a job to be returned in the result slice, it must satisfy all the
66 | // specified matchers. Empty matchers return all scheduled jobs in the queue.
67 | //
68 | // Custom queue implementations may consider using pattern matching on the
69 | // specified matchers to create a predicate pushdown effect and optimize queries
70 | // to filter data at the data source, e.g.
71 | //
72 | // switch m := jobMatcher.(type) {
73 | // case *matcher.JobStatus:
74 | // // ... WHERE status = m.Suspended
75 | // case *matcher.JobGroup:
76 | // if m.Operator == &matcher.StringEquals {
77 | // // ... WHERE group_name = m.Pattern
78 | // }
79 | // }
80 | ScheduledJobs([]Matcher[ScheduledJob]) ([]ScheduledJob, error)
81 |
82 | // Size returns the size of the job queue.
83 | Size() (int, error)
84 |
85 | // Clear clears the job queue.
86 | Clear() error
87 | }
88 |
89 | // priorityQueue implements the heap.Interface.
90 | type priorityQueue []*scheduledJob
91 |
92 | var _ heap.Interface = (*priorityQueue)(nil)
93 |
94 | // Len returns the priorityQueue length.
95 | func (pq priorityQueue) Len() int {
96 | return len(pq)
97 | }
98 |
99 | // Less is the items less comparator.
100 | func (pq priorityQueue) Less(i, j int) bool {
101 | return pq[i].priority < pq[j].priority
102 | }
103 |
104 | // Swap exchanges the indexes of the items.
105 | func (pq priorityQueue) Swap(i, j int) {
106 | pq[i], pq[j] = pq[j], pq[i]
107 | }
108 |
109 | // Push implements the heap.Interface.Push.
110 | // Adds an element at index Len().
111 | func (pq *priorityQueue) Push(element interface{}) {
112 | item := element.(*scheduledJob)
113 | *pq = append(*pq, item)
114 | }
115 |
116 | // Pop implements the heap.Interface.Pop.
117 | // Removes and returns the element at Len() - 1.
118 | func (pq *priorityQueue) Pop() interface{} {
119 | old := *pq
120 | n := len(old)
121 | item := old[n-1]
122 | *pq = old[0 : n-1]
123 | return item
124 | }
125 |
126 | // jobQueue implements the JobQueue interface by using an in-memory
127 | // priority queue as the storage layer.
128 | type jobQueue struct {
129 | mtx sync.Mutex
130 | delegate priorityQueue
131 | }
132 |
133 | var _ JobQueue = (*jobQueue)(nil)
134 |
135 | // NewJobQueue initializes and returns an empty jobQueue.
136 | func NewJobQueue() JobQueue {
137 | return &jobQueue{
138 | delegate: priorityQueue{},
139 | }
140 | }
141 |
142 | // Push inserts a new scheduled job to the queue.
143 | // This method is also used by the Scheduler to reschedule existing jobs that
144 | // have been dequeued for execution.
145 | func (jq *jobQueue) Push(job ScheduledJob) error {
146 | jq.mtx.Lock()
147 | defer jq.mtx.Unlock()
148 | scheduledJobs := jq.scheduledJobs()
149 | for i, scheduled := range scheduledJobs {
150 | if scheduled.JobDetail().jobKey.Equals(job.JobDetail().jobKey) {
151 | if job.JobDetail().opts.Replace {
152 | heap.Remove(&jq.delegate, i)
153 | break
154 | }
155 | return newIllegalStateError(ErrJobAlreadyExists)
156 | }
157 | }
158 | heap.Push(&jq.delegate, job)
159 | return nil
160 | }
161 |
162 | // Pop removes and returns the next scheduled job from the queue.
163 | func (jq *jobQueue) Pop() (ScheduledJob, error) {
164 | jq.mtx.Lock()
165 | defer jq.mtx.Unlock()
166 | if len(jq.delegate) == 0 {
167 | return nil, newIllegalStateError(ErrQueueEmpty)
168 | }
169 | return heap.Pop(&jq.delegate).(ScheduledJob), nil
170 | }
171 |
172 | // Head returns the first scheduled job without removing it from the queue.
173 | func (jq *jobQueue) Head() (ScheduledJob, error) {
174 | jq.mtx.Lock()
175 | defer jq.mtx.Unlock()
176 | if len(jq.delegate) == 0 {
177 | return nil, newIllegalStateError(ErrQueueEmpty)
178 | }
179 | return jq.delegate[0], nil
180 | }
181 |
182 | // Get returns the scheduled job with the specified key without removing it
183 | // from the queue.
184 | func (jq *jobQueue) Get(jobKey *JobKey) (ScheduledJob, error) {
185 | jq.mtx.Lock()
186 | defer jq.mtx.Unlock()
187 | for _, scheduled := range jq.delegate {
188 | if scheduled.JobDetail().jobKey.Equals(jobKey) {
189 | return scheduled, nil
190 | }
191 | }
192 | return nil, newIllegalStateError(ErrJobNotFound)
193 | }
194 |
195 | // Remove removes and returns the scheduled job with the specified key.
196 | func (jq *jobQueue) Remove(jobKey *JobKey) (ScheduledJob, error) {
197 | jq.mtx.Lock()
198 | defer jq.mtx.Unlock()
199 | scheduledJobs := jq.scheduledJobs()
200 | for i, scheduled := range scheduledJobs {
201 | if scheduled.JobDetail().jobKey.Equals(jobKey) {
202 | return heap.Remove(&jq.delegate, i).(ScheduledJob), nil
203 | }
204 | }
205 | return nil, newIllegalStateError(ErrJobNotFound)
206 | }
207 |
208 | // ScheduledJobs returns a slice of scheduled jobs in the queue.
209 | // For a job to be returned, it must satisfy all the specified matchers.
210 | // Given an empty matchers it returns all scheduled jobs.
211 | func (jq *jobQueue) ScheduledJobs(matchers []Matcher[ScheduledJob]) ([]ScheduledJob, error) {
212 | jq.mtx.Lock()
213 | defer jq.mtx.Unlock()
214 | if len(matchers) == 0 {
215 | return jq.scheduledJobs(), nil
216 | }
217 | matchedJobs := make([]ScheduledJob, 0)
218 | JobLoop:
219 | for _, job := range jq.delegate {
220 | for _, matcher := range matchers {
221 | // require all matchers to match the job
222 | if !matcher.IsMatch(job) {
223 | continue JobLoop
224 | }
225 | }
226 | matchedJobs = append(matchedJobs, job)
227 | }
228 | return matchedJobs, nil
229 | }
230 |
231 | // scheduledJobs returns all scheduled jobs.
232 | func (jq *jobQueue) scheduledJobs() []ScheduledJob {
233 | scheduledJobs := make([]ScheduledJob, len(jq.delegate))
234 | for i, job := range jq.delegate {
235 | scheduledJobs[i] = ScheduledJob(job)
236 | }
237 | return scheduledJobs
238 | }
239 |
240 | // Size returns the size of the job queue.
241 | func (jq *jobQueue) Size() (int, error) {
242 | jq.mtx.Lock()
243 | defer jq.mtx.Unlock()
244 | return len(jq.delegate), nil
245 | }
246 |
247 | // Clear clears the job queue.
248 | func (jq *jobQueue) Clear() error {
249 | jq.mtx.Lock()
250 | defer jq.mtx.Unlock()
251 | jq.delegate = priorityQueue{}
252 | return nil
253 | }
254 |
--------------------------------------------------------------------------------
/quartz/queue_test.go:
--------------------------------------------------------------------------------
1 | package quartz_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/reugn/go-quartz/internal/assert"
7 | "github.com/reugn/go-quartz/quartz"
8 | )
9 |
10 | func TestQueueErrors(t *testing.T) {
11 | t.Parallel()
12 | queue := quartz.NewJobQueue()
13 | jobKey := quartz.NewJobKey("job1")
14 |
15 | var err error
16 | _, err = queue.Pop()
17 | assert.ErrorIs(t, err, quartz.ErrQueueEmpty)
18 |
19 | _, err = queue.Head()
20 | assert.ErrorIs(t, err, quartz.ErrQueueEmpty)
21 |
22 | _, err = queue.Get(jobKey)
23 | assert.ErrorIs(t, err, quartz.ErrJobNotFound)
24 |
25 | _, err = queue.Remove(jobKey)
26 | assert.ErrorIs(t, err, quartz.ErrJobNotFound)
27 | }
28 |
--------------------------------------------------------------------------------
/quartz/scheduler.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "math"
7 | "sync"
8 | "time"
9 |
10 | "github.com/reugn/go-quartz/logger"
11 | )
12 |
13 | // ScheduledJob represents a scheduled Job with the Trigger associated
14 | // with it and the next run epoch time.
15 | type ScheduledJob interface {
16 | JobDetail() *JobDetail
17 | Trigger() Trigger
18 | NextRunTime() int64
19 | }
20 |
21 | // Scheduler represents a Job orchestrator.
22 | // Schedulers are responsible for executing Jobs when their associated
23 | // Triggers fire (when their scheduled time arrives).
24 | type Scheduler interface {
25 | // Start starts the scheduler. The scheduler will run until
26 | // the Stop method is called or the context is canceled. Use
27 | // the Wait method to block until all running jobs have completed.
28 | Start(context.Context)
29 |
30 | // IsStarted determines whether the scheduler has been started.
31 | IsStarted() bool
32 |
33 | // ScheduleJob schedules a job using the provided trigger.
34 | ScheduleJob(jobDetail *JobDetail, trigger Trigger) error
35 |
36 | // GetJobKeys returns the keys of scheduled jobs.
37 | // For a job key to be returned, the job must satisfy all of the
38 | // matchers specified.
39 | // Given no matchers, it returns the keys of all scheduled jobs.
40 | GetJobKeys(...Matcher[ScheduledJob]) ([]*JobKey, error)
41 |
42 | // GetScheduledJob returns the scheduled job with the specified key.
43 | GetScheduledJob(jobKey *JobKey) (ScheduledJob, error)
44 |
45 | // DeleteJob removes the job with the specified key from the
46 | // scheduler's execution queue.
47 | DeleteJob(jobKey *JobKey) error
48 |
49 | // PauseJob suspends the job with the specified key from being
50 | // executed by the scheduler.
51 | PauseJob(jobKey *JobKey) error
52 |
53 | // ResumeJob restarts the suspended job with the specified key.
54 | ResumeJob(jobKey *JobKey) error
55 |
56 | // Clear removes all of the scheduled jobs.
57 | Clear() error
58 |
59 | // Wait blocks until the scheduler stops running and all jobs
60 | // have returned. Wait will return when the context passed to
61 | // it has expired. Until the context passed to start is
62 | // cancelled or Stop is called directly.
63 | Wait(context.Context)
64 |
65 | // Stop shutdowns the scheduler.
66 | Stop()
67 | }
68 |
69 | // StdScheduler implements the [Scheduler] interface.
70 | type StdScheduler struct {
71 | mtx sync.RWMutex
72 | wg sync.WaitGroup
73 |
74 | interrupt chan struct{}
75 | cancel context.CancelFunc
76 | feeder chan ScheduledJob
77 | dispatch chan ScheduledJob
78 | started bool
79 |
80 | queue JobQueue
81 | queueLocker sync.Locker
82 |
83 | opts SchedulerConfig
84 | logger logger.Logger
85 | }
86 |
87 | var _ Scheduler = (*StdScheduler)(nil)
88 |
89 | type SchedulerConfig struct {
90 | // When true, the scheduler will run jobs synchronously, waiting
91 | // for each execution instance of the job to return before starting
92 | // the next execution. Running with this option effectively serializes
93 | // all job execution.
94 | BlockingExecution bool
95 |
96 | // When greater than 0, all jobs will be dispatched to a pool of
97 | // goroutines of WorkerLimit size to limit the total number of processes
98 | // usable by the scheduler. If all worker threads are in use, job
99 | // scheduling will wait till a job can be dispatched.
100 | // If BlockingExecution is set, then WorkerLimit is ignored.
101 | WorkerLimit int
102 |
103 | // When the scheduler attempts to execute a job, if the time elapsed
104 | // since the job's scheduled execution time is less than or equal to the
105 | // configured threshold, the scheduler will execute the job.
106 | // Otherwise, the job will be rescheduled as outdated. By default,
107 | // NewStdScheduler sets the threshold to 100ms.
108 | //
109 | // As a rule of thumb, your OutdatedThreshold should always be
110 | // greater than 0, but less than the shortest interval used by
111 | // your job or jobs.
112 | OutdatedThreshold time.Duration
113 |
114 | // This retry interval will be used if the scheduler fails to
115 | // calculate the next time to interrupt for job execution. By default,
116 | // the NewStdScheduler constructor sets this interval to 100
117 | // milliseconds. Changing the default value may be beneficial when
118 | // using a custom implementation of the JobQueue, where operations
119 | // may timeout or fail.
120 | RetryInterval time.Duration
121 |
122 | // MisfiredChan allows the creation of event listeners to handle jobs that
123 | // have failed to be executed on time and have been skipped by the scheduler.
124 | //
125 | // Misfires can occur due to insufficient resources or scheduler downtime.
126 | // Adjust OutdatedThreshold to establish an acceptable delay time and
127 | // ensure regular job execution.
128 | MisfiredChan chan ScheduledJob
129 | }
130 |
131 | // SchedulerOpt is a functional option type used to configure an [StdScheduler].
132 | type SchedulerOpt func(*StdScheduler) error
133 |
134 | // WithBlockingExecution configures the scheduler to use blocking execution.
135 | // In blocking execution mode, jobs are executed synchronously in the scheduler's
136 | // main loop.
137 | func WithBlockingExecution() SchedulerOpt {
138 | return func(c *StdScheduler) error {
139 | c.opts.BlockingExecution = true
140 | return nil
141 | }
142 | }
143 |
144 | // WithWorkerLimit configures the number of worker goroutines for concurrent job execution.
145 | // This option is only used when blocking execution is disabled. If blocking execution
146 | // is enabled, this setting will be ignored. The workerLimit must be non-negative.
147 | func WithWorkerLimit(workerLimit int) SchedulerOpt {
148 | return func(c *StdScheduler) error {
149 | if workerLimit < 0 {
150 | return newIllegalArgumentError("workerLimit must be non-negative")
151 | }
152 | c.opts.WorkerLimit = workerLimit
153 | return nil
154 | }
155 | }
156 |
157 | // WithOutdatedThreshold configures the time duration after which a scheduled job is
158 | // considered outdated.
159 | func WithOutdatedThreshold(outdatedThreshold time.Duration) SchedulerOpt {
160 | return func(c *StdScheduler) error {
161 | c.opts.OutdatedThreshold = outdatedThreshold
162 | return nil
163 | }
164 | }
165 |
166 | // WithRetryInterval configures the time interval the scheduler waits before
167 | // retrying to determine the next execution time for a job.
168 | func WithRetryInterval(retryInterval time.Duration) SchedulerOpt {
169 | return func(c *StdScheduler) error {
170 | c.opts.RetryInterval = retryInterval
171 | return nil
172 | }
173 | }
174 |
175 | // WithMisfiredChan configures the channel to which misfired jobs are sent.
176 | // A misfired job is a job that the scheduler was unable to execute according to
177 | // its trigger schedule. If a channel is provided, misfired jobs are sent to it.
178 | func WithMisfiredChan(misfiredChan chan ScheduledJob) SchedulerOpt {
179 | return func(c *StdScheduler) error {
180 | if misfiredChan == nil {
181 | return newIllegalArgumentError("misfiredChan is nil")
182 | }
183 | c.opts.MisfiredChan = misfiredChan
184 | return nil
185 | }
186 | }
187 |
188 | // WithQueue configures the scheduler's job queue.
189 | // Custom [JobQueue] and [sync.Locker] implementations can be provided to manage scheduled
190 | // jobs which allows for persistent storage in distributed mode.
191 | // A standard in-memory queue and a [sync.Mutex] are used by default.
192 | func WithQueue(queue JobQueue, queueLocker sync.Locker) SchedulerOpt {
193 | return func(c *StdScheduler) error {
194 | if queue == nil {
195 | return newIllegalArgumentError("queue is nil")
196 | }
197 | if queueLocker == nil {
198 | return newIllegalArgumentError("queueLocker is nil")
199 | }
200 | c.queue = queue
201 | c.queueLocker = queueLocker
202 | return nil
203 | }
204 | }
205 |
206 | // WithLogger configures the logger used by the scheduler for logging messages.
207 | // This enables the use of a custom logger implementation that satisfies the
208 | // [logger.Logger] interface.
209 | func WithLogger(logger logger.Logger) SchedulerOpt {
210 | return func(c *StdScheduler) error {
211 | if logger == nil {
212 | return newIllegalArgumentError("logger is nil")
213 | }
214 | c.logger = logger
215 | return nil
216 | }
217 | }
218 |
219 | // NewStdScheduler returns a new [StdScheduler] configured using the provided
220 | // functional options.
221 | //
222 | // The following options are available for configuring the scheduler:
223 | //
224 | // - WithBlockingExecution()
225 | // - WithWorkerLimit(workerLimit int)
226 | // - WithOutdatedThreshold(outdatedThreshold time.Duration)
227 | // - WithRetryInterval(retryInterval time.Duration)
228 | // - WithMisfiredChan(misfiredChan chan ScheduledJob)
229 | // - WithQueue(queue JobQueue, queueLocker sync.Locker)
230 | // - WithLogger(logger logger.Logger)
231 | //
232 | // Example usage:
233 | //
234 | // scheduler, err := quartz.NewStdScheduler(
235 | // quartz.WithOutdatedThreshold(time.Second),
236 | // quartz.WithLogger(myLogger),
237 | // )
238 | func NewStdScheduler(opts ...SchedulerOpt) (Scheduler, error) {
239 | // default scheduler configuration
240 | config := SchedulerConfig{
241 | OutdatedThreshold: 100 * time.Millisecond,
242 | RetryInterval: 100 * time.Millisecond,
243 | }
244 |
245 | // initialize the scheduler with default values
246 | scheduler := &StdScheduler{
247 | interrupt: make(chan struct{}, 1),
248 | feeder: make(chan ScheduledJob),
249 | dispatch: make(chan ScheduledJob),
250 | queue: NewJobQueue(),
251 | queueLocker: &sync.Mutex{},
252 | opts: config,
253 | logger: logger.NoOpLogger{},
254 | }
255 |
256 | // apply functional options to configure the scheduler
257 | for _, opt := range opts {
258 | if err := opt(scheduler); err != nil {
259 | return nil, err
260 | }
261 | }
262 |
263 | return scheduler, nil
264 | }
265 |
266 | // ScheduleJob schedules a Job using the provided Trigger.
267 | func (sched *StdScheduler) ScheduleJob(
268 | jobDetail *JobDetail,
269 | trigger Trigger,
270 | ) error {
271 | if jobDetail == nil {
272 | return newIllegalArgumentError("jobDetail is nil")
273 | }
274 | if jobDetail.jobKey == nil {
275 | return newIllegalArgumentError("jobDetail.jobKey is nil")
276 | }
277 | if jobDetail.jobKey.name == "" {
278 | return newIllegalArgumentError("empty key name is not allowed")
279 | }
280 | if trigger == nil {
281 | return newIllegalArgumentError("trigger is nil")
282 | }
283 |
284 | nextRunTime := int64(math.MaxInt64)
285 | var err error
286 | if !jobDetail.opts.Suspended {
287 | nextRunTime, err = trigger.NextFireTime(NowNano())
288 | if err != nil {
289 | return err
290 | }
291 | }
292 | toSchedule := &scheduledJob{
293 | job: jobDetail,
294 | trigger: trigger,
295 | priority: nextRunTime,
296 | }
297 |
298 | sched.queueLocker.Lock()
299 | defer sched.queueLocker.Unlock()
300 |
301 | if err = sched.queue.Push(toSchedule); err == nil {
302 | sched.logger.Debug("Successfully added job", "key", jobDetail.jobKey.String())
303 | if sched.IsStarted() {
304 | sched.Reset()
305 | }
306 | }
307 | return err
308 | }
309 |
310 | // Start starts the StdScheduler execution loop.
311 | func (sched *StdScheduler) Start(ctx context.Context) {
312 | sched.mtx.Lock()
313 | defer sched.mtx.Unlock()
314 |
315 | if sched.started {
316 | sched.logger.Info("Scheduler is already running")
317 | return
318 | }
319 |
320 | ctx, sched.cancel = context.WithCancel(ctx)
321 | go func() { <-ctx.Done(); sched.Stop() }()
322 |
323 | // start scheduler execution loop
324 | sched.wg.Add(1)
325 | go sched.startExecutionLoop(ctx)
326 |
327 | // starts worker pool if configured
328 | sched.startWorkers(ctx)
329 |
330 | sched.started = true
331 | }
332 |
333 | // Wait blocks until the scheduler shuts down.
334 | func (sched *StdScheduler) Wait(ctx context.Context) {
335 | sig := make(chan struct{})
336 | go func() { defer close(sig); sched.wg.Wait() }()
337 | select {
338 | case <-ctx.Done():
339 | case <-sig:
340 | }
341 | }
342 |
343 | // IsStarted determines whether the scheduler has been started.
344 | func (sched *StdScheduler) IsStarted() bool {
345 | sched.mtx.RLock()
346 | defer sched.mtx.RUnlock()
347 |
348 | return sched.started
349 | }
350 |
351 | // GetJobKeys returns the keys of scheduled jobs.
352 | // For a job key to be returned, the job must satisfy all of the matchers specified.
353 | // Given no matchers, it returns the keys of all scheduled jobs.
354 | func (sched *StdScheduler) GetJobKeys(matchers ...Matcher[ScheduledJob]) ([]*JobKey, error) {
355 | sched.queueLocker.Lock()
356 | defer sched.queueLocker.Unlock()
357 |
358 | scheduledJobs, err := sched.queue.ScheduledJobs(matchers)
359 | if err != nil {
360 | return nil, err
361 | }
362 |
363 | keys := make([]*JobKey, 0, len(scheduledJobs))
364 | for _, scheduled := range scheduledJobs {
365 | keys = append(keys, scheduled.JobDetail().jobKey)
366 | }
367 | return keys, nil
368 | }
369 |
370 | // GetScheduledJob returns the ScheduledJob with the specified key.
371 | func (sched *StdScheduler) GetScheduledJob(jobKey *JobKey) (ScheduledJob, error) {
372 | if jobKey == nil {
373 | return nil, newIllegalArgumentError("jobKey is nil")
374 | }
375 |
376 | sched.queueLocker.Lock()
377 | defer sched.queueLocker.Unlock()
378 |
379 | return sched.queue.Get(jobKey)
380 | }
381 |
382 | // DeleteJob removes the Job with the specified key if present.
383 | func (sched *StdScheduler) DeleteJob(jobKey *JobKey) error {
384 | if jobKey == nil {
385 | return newIllegalArgumentError("jobKey is nil")
386 | }
387 |
388 | sched.queueLocker.Lock()
389 | defer sched.queueLocker.Unlock()
390 |
391 | _, err := sched.queue.Remove(jobKey)
392 | if err == nil {
393 | sched.logger.Debug("Successfully deleted job", "key", jobKey.String())
394 | if sched.IsStarted() {
395 | sched.Reset()
396 | }
397 | }
398 | return err
399 | }
400 |
401 | // PauseJob suspends the job with the specified key from being
402 | // executed by the scheduler.
403 | func (sched *StdScheduler) PauseJob(jobKey *JobKey) error {
404 | if jobKey == nil {
405 | return newIllegalArgumentError("jobKey is nil")
406 | }
407 |
408 | sched.queueLocker.Lock()
409 | defer sched.queueLocker.Unlock()
410 |
411 | job, err := sched.queue.Get(jobKey)
412 | if err != nil {
413 | return err
414 | }
415 | if job.JobDetail().opts.Suspended {
416 | return newIllegalStateError(ErrJobIsSuspended)
417 | }
418 |
419 | job, err = sched.queue.Remove(jobKey)
420 | if err == nil {
421 | job.JobDetail().opts.Suspended = true
422 | paused := &scheduledJob{
423 | job: job.JobDetail(),
424 | trigger: job.Trigger(),
425 | priority: int64(math.MaxInt64),
426 | }
427 | if err = sched.queue.Push(paused); err == nil {
428 | sched.logger.Debug("Successfully paused job", "key", jobKey.String())
429 | if sched.IsStarted() {
430 | sched.Reset()
431 | }
432 | }
433 | }
434 | return err
435 | }
436 |
437 | // ResumeJob restarts the suspended job with the specified key.
438 | func (sched *StdScheduler) ResumeJob(jobKey *JobKey) error {
439 | if jobKey == nil {
440 | return newIllegalArgumentError("jobKey is nil")
441 | }
442 |
443 | sched.queueLocker.Lock()
444 | defer sched.queueLocker.Unlock()
445 |
446 | job, err := sched.queue.Get(jobKey)
447 | if err != nil {
448 | return err
449 | }
450 | if !job.JobDetail().opts.Suspended {
451 | return newIllegalStateError(ErrJobIsActive)
452 | }
453 |
454 | job, err = sched.queue.Remove(jobKey)
455 | if err == nil {
456 | job.JobDetail().opts.Suspended = false
457 | var nextRunTime int64
458 | nextRunTime, err = job.Trigger().NextFireTime(NowNano())
459 | if err != nil {
460 | return err
461 | }
462 | resumed := &scheduledJob{
463 | job: job.JobDetail(),
464 | trigger: job.Trigger(),
465 | priority: nextRunTime,
466 | }
467 | if err = sched.queue.Push(resumed); err == nil {
468 | sched.logger.Debug("Successfully resumed job", "key", jobKey.String())
469 | if sched.IsStarted() {
470 | sched.Reset()
471 | }
472 | }
473 | }
474 | return err
475 | }
476 |
477 | // Clear removes all of the scheduled jobs.
478 | func (sched *StdScheduler) Clear() error {
479 | sched.queueLocker.Lock()
480 | defer sched.queueLocker.Unlock()
481 |
482 | // reset the job queue
483 | err := sched.queue.Clear()
484 | if err == nil {
485 | sched.logger.Debug("Successfully cleared job queue")
486 | if sched.IsStarted() {
487 | sched.Reset()
488 | }
489 | }
490 | return err
491 | }
492 |
493 | // Stop exits the StdScheduler execution loop.
494 | func (sched *StdScheduler) Stop() {
495 | sched.mtx.Lock()
496 | defer sched.mtx.Unlock()
497 |
498 | if !sched.started {
499 | sched.logger.Info("Scheduler is not running")
500 | return
501 | }
502 |
503 | sched.logger.Info("Closing the scheduler")
504 | sched.cancel()
505 | sched.started = false
506 | }
507 |
508 | func (sched *StdScheduler) startExecutionLoop(ctx context.Context) {
509 | defer sched.wg.Done()
510 | const maxTimerDuration = time.Duration(1<<63 - 1)
511 | timer := time.NewTimer(maxTimerDuration)
512 | for {
513 | queueSize, err := sched.queue.Size()
514 | switch {
515 | case err != nil:
516 | sched.logger.Error("Failed to fetch queue size", "error", err)
517 | timer.Reset(sched.opts.RetryInterval)
518 | case queueSize == 0:
519 | sched.logger.Trace("Queue is empty")
520 | timer.Reset(maxTimerDuration)
521 | default:
522 | timer.Reset(sched.calculateNextTick())
523 | }
524 | select {
525 | case <-timer.C:
526 | sched.logger.Trace("Tick")
527 | sched.executeAndReschedule(ctx)
528 |
529 | case <-sched.interrupt:
530 | sched.logger.Trace("Interrupted waiting for next tick")
531 | timer.Stop()
532 |
533 | case <-ctx.Done():
534 | sched.logger.Info("Exit the execution loop")
535 | timer.Stop()
536 | return
537 | }
538 | }
539 | }
540 |
541 | func (sched *StdScheduler) startWorkers(ctx context.Context) {
542 | if !sched.opts.BlockingExecution && sched.opts.WorkerLimit > 0 {
543 | sched.logger.Debug("Starting scheduler workers", "n", sched.opts.WorkerLimit)
544 | for i := 0; i < sched.opts.WorkerLimit; i++ {
545 | sched.wg.Add(1)
546 | go func() {
547 | defer sched.wg.Done()
548 | for {
549 | select {
550 | case <-ctx.Done():
551 | return
552 | case scheduled := <-sched.dispatch:
553 | sched.executeWithRetries(ctx, scheduled.JobDetail())
554 | }
555 | }
556 | }()
557 | }
558 | }
559 | }
560 |
561 | func (sched *StdScheduler) calculateNextTick() time.Duration {
562 | var nextTickDuration time.Duration
563 | scheduledJob, err := sched.queue.Head()
564 | if err != nil {
565 | if errors.Is(err, ErrQueueEmpty) {
566 | sched.logger.Debug("Queue is empty")
567 | return nextTickDuration
568 | }
569 | sched.logger.Error("Failed to calculate next tick", "error", err)
570 | return sched.opts.RetryInterval
571 | }
572 |
573 | nextRunTime := scheduledJob.NextRunTime()
574 | now := NowNano()
575 | if nextRunTime > now {
576 | nextTickDuration = time.Duration(nextRunTime - now)
577 | }
578 | sched.logger.Trace("Next tick", "job", scheduledJob.JobDetail().jobKey.String(),
579 | "after", nextTickDuration)
580 |
581 | return nextTickDuration
582 | }
583 |
584 | func (sched *StdScheduler) executeAndReschedule(ctx context.Context) {
585 | // fetch a job for processing
586 | scheduled, valid := sched.fetchAndReschedule()
587 |
588 | // execute the job
589 | if valid {
590 | sched.logger.Debug("Job is about to be executed",
591 | "key", scheduled.JobDetail().jobKey.String())
592 | switch {
593 | case sched.opts.BlockingExecution:
594 | sched.executeWithRetries(ctx, scheduled.JobDetail())
595 | case sched.opts.WorkerLimit > 0:
596 | select {
597 | case sched.dispatch <- scheduled:
598 | case <-ctx.Done():
599 | return
600 | }
601 | default:
602 | sched.wg.Add(1)
603 | go func() {
604 | defer sched.wg.Done()
605 | sched.executeWithRetries(ctx, scheduled.JobDetail())
606 | }()
607 | }
608 | }
609 | }
610 |
611 | func (sched *StdScheduler) executeWithRetries(ctx context.Context, jobDetail *JobDetail) {
612 | // recover from unhandled panics that may occur during job execution
613 | defer func() {
614 | if err := recover(); err != nil {
615 | sched.logger.Error("Job panicked", "key", jobDetail.jobKey.String(),
616 | "error", err)
617 | }
618 | }()
619 |
620 | err := jobDetail.job.Execute(ctx)
621 | if err == nil {
622 | return
623 | }
624 | retryLoop:
625 | for i := 1; i <= jobDetail.opts.MaxRetries; i++ {
626 | timer := time.NewTimer(jobDetail.opts.RetryInterval)
627 | select {
628 | case <-timer.C:
629 | case <-ctx.Done():
630 | timer.Stop()
631 | break retryLoop
632 | }
633 | sched.logger.Trace("Job retry", "key", jobDetail.jobKey.String(), "attempt", i)
634 | err = jobDetail.job.Execute(ctx)
635 | if err == nil {
636 | break
637 | }
638 | }
639 | if err != nil {
640 | sched.logger.Warn("Job terminated", "key", jobDetail.jobKey.String(), "error", err)
641 | }
642 | }
643 |
644 | func (sched *StdScheduler) validateJob(job ScheduledJob) (bool, func() (int64, error)) {
645 | if job.JobDetail().opts.Suspended {
646 | return false, func() (int64, error) { return math.MaxInt64, nil }
647 | }
648 |
649 | now := NowNano()
650 | if job.NextRunTime() < now-sched.opts.OutdatedThreshold.Nanoseconds() {
651 | duration := time.Duration(now - job.NextRunTime())
652 | sched.logger.Info("Job is outdated", "key", job.JobDetail().jobKey.String(),
653 | "duration", duration)
654 | select {
655 | case sched.opts.MisfiredChan <- job:
656 | default:
657 | }
658 | return false, func() (int64, error) { return job.Trigger().NextFireTime(now) }
659 | } else if job.NextRunTime() > now {
660 | sched.logger.Debug("Job is not due to run yet",
661 | "key", job.JobDetail().jobKey.String())
662 | return false, func() (int64, error) { return job.NextRunTime(), nil }
663 | }
664 |
665 | return true, func() (int64, error) {
666 | return job.Trigger().NextFireTime(job.NextRunTime())
667 | }
668 | }
669 |
670 | func (sched *StdScheduler) fetchAndReschedule() (ScheduledJob, bool) {
671 | sched.queueLocker.Lock()
672 | defer sched.queueLocker.Unlock()
673 |
674 | // fetch a job for processing
675 | job, err := sched.queue.Pop()
676 | if err != nil {
677 | if errors.Is(err, ErrQueueEmpty) {
678 | sched.logger.Debug("Queue is empty")
679 | } else {
680 | sched.logger.Error("Failed to fetch a job from the queue", "error", err)
681 | }
682 | return nil, false
683 | }
684 |
685 | // validate the job
686 | valid, nextRunTimeExtractor := sched.validateJob(job)
687 |
688 | // calculate next run time for the job
689 | nextRunTime, err := nextRunTimeExtractor()
690 | if err != nil {
691 | sched.logger.Info("Job exited the execution loop",
692 | "key", job.JobDetail().jobKey.String(), "error", err)
693 | return job, valid
694 | }
695 |
696 | // reschedule the job
697 | toSchedule := &scheduledJob{
698 | job: job.JobDetail(),
699 | trigger: job.Trigger(),
700 | priority: nextRunTime,
701 | }
702 | if err := sched.queue.Push(toSchedule); err != nil {
703 | sched.logger.Error("Failed to reschedule job",
704 | "key", toSchedule.JobDetail().jobKey.String(), "error", err)
705 | } else {
706 | sched.logger.Trace("Successfully rescheduled job",
707 | "key", toSchedule.JobDetail().jobKey.String())
708 | sched.Reset()
709 | }
710 |
711 | return job, valid
712 | }
713 |
714 | // Reset is called internally to recalculate the closest job timing when there
715 | // is an update to the job queue by the scheduler. In cluster mode with a shared
716 | // queue, it can be triggered manually to synchronize with remote changes if one
717 | // of the schedulers fails.
718 | func (sched *StdScheduler) Reset() {
719 | select {
720 | case sched.interrupt <- struct{}{}:
721 | default:
722 | }
723 | }
724 |
--------------------------------------------------------------------------------
/quartz/scheduler_test.go:
--------------------------------------------------------------------------------
1 | package quartz_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log"
8 | "net/http"
9 | "os"
10 | "runtime"
11 | "sync"
12 | "sync/atomic"
13 | "testing"
14 | "time"
15 |
16 | "github.com/reugn/go-quartz/internal/assert"
17 | "github.com/reugn/go-quartz/internal/mock"
18 | "github.com/reugn/go-quartz/job"
19 | "github.com/reugn/go-quartz/logger"
20 | "github.com/reugn/go-quartz/matcher"
21 | "github.com/reugn/go-quartz/quartz"
22 | )
23 |
24 | func TestScheduler(t *testing.T) {
25 | t.Parallel()
26 |
27 | ctx, cancel := context.WithCancel(context.Background())
28 | defer cancel()
29 |
30 | stdLogger := log.New(os.Stdout, "", log.LstdFlags|log.Lmsgprefix|log.Lshortfile)
31 | l := logger.NewSimpleLogger(stdLogger, logger.LevelTrace)
32 | sched, err := quartz.NewStdScheduler(
33 | quartz.WithLogger(l),
34 | quartz.WithQueue(quartz.NewJobQueue(), &sync.Mutex{}),
35 | )
36 | assert.IsNil(t, err)
37 |
38 | var jobKeys [4]*quartz.JobKey
39 |
40 | shellJob := job.NewShellJob("ls -la")
41 | jobKeys[0] = quartz.NewJobKey("shellJob")
42 |
43 | request, err := http.NewRequest(http.MethodGet, "https://worldtimeapi.org/api/timezone/utc", nil)
44 | assert.IsNil(t, err)
45 |
46 | curlJob := job.NewCurlJobWithOptions(request, job.CurlJobOptions{HTTPClient: mock.HTTPHandlerOk})
47 | jobKeys[1] = quartz.NewJobKey("curlJob")
48 |
49 | errShellJob := job.NewShellJob("ls -z")
50 | jobKeys[2] = quartz.NewJobKey("errShellJob")
51 |
52 | request, err = http.NewRequest(http.MethodGet, "http://", nil)
53 | assert.IsNil(t, err)
54 | errCurlJob := job.NewCurlJob(request)
55 | jobKeys[3] = quartz.NewJobKey("errCurlJob")
56 |
57 | sched.Start(ctx)
58 | assert.Equal(t, sched.IsStarted(), true)
59 |
60 | err = sched.ScheduleJob(quartz.NewJobDetail(shellJob, jobKeys[0]),
61 | quartz.NewSimpleTrigger(time.Millisecond*700))
62 | assert.IsNil(t, err)
63 | err = sched.ScheduleJob(quartz.NewJobDetail(curlJob, jobKeys[1]),
64 | quartz.NewRunOnceTrigger(time.Millisecond))
65 | assert.IsNil(t, err)
66 | err = sched.ScheduleJob(quartz.NewJobDetail(errShellJob, jobKeys[2]),
67 | quartz.NewRunOnceTrigger(time.Millisecond))
68 | assert.IsNil(t, err)
69 | err = sched.ScheduleJob(quartz.NewJobDetail(errCurlJob, jobKeys[3]),
70 | quartz.NewSimpleTrigger(time.Millisecond*800))
71 | assert.IsNil(t, err)
72 |
73 | time.Sleep(time.Second)
74 | scheduledJobKeys, err := sched.GetJobKeys()
75 | assert.IsNil(t, err)
76 | assert.Equal(t, scheduledJobKeys, []*quartz.JobKey{jobKeys[0], jobKeys[3]})
77 |
78 | _, err = sched.GetScheduledJob(jobKeys[0])
79 | assert.IsNil(t, err)
80 |
81 | err = sched.DeleteJob(jobKeys[0]) // shellJob key
82 | assert.IsNil(t, err)
83 |
84 | nonExistentJobKey := quartz.NewJobKey("NA")
85 | _, err = sched.GetScheduledJob(nonExistentJobKey)
86 | assert.ErrorIs(t, err, quartz.ErrJobNotFound)
87 |
88 | err = sched.DeleteJob(nonExistentJobKey)
89 | assert.ErrorIs(t, err, quartz.ErrJobNotFound)
90 |
91 | scheduledJobKeys, err = sched.GetJobKeys()
92 | assert.IsNil(t, err)
93 | assert.Equal(t, len(scheduledJobKeys), 1)
94 | assert.Equal(t, scheduledJobKeys, []*quartz.JobKey{jobKeys[3]})
95 |
96 | _ = sched.Clear()
97 | assert.Equal(t, jobCount(sched), 0)
98 | sched.Stop()
99 | _, err = curlJob.DumpResponse(true)
100 | assert.IsNil(t, err)
101 | assert.Equal(t, shellJob.JobStatus(), job.StatusOK)
102 | assert.Equal(t, curlJob.JobStatus(), job.StatusOK)
103 | assert.Equal(t, errShellJob.JobStatus(), job.StatusFailure)
104 | assert.Equal(t, errCurlJob.JobStatus(), job.StatusFailure)
105 | }
106 |
107 | func TestScheduler_BlockingSemantics(t *testing.T) {
108 | t.Parallel()
109 |
110 | for _, tt := range []string{"Blocking", "NonBlocking", "WorkerSmall", "WorkerLarge"} {
111 | tt := tt
112 | t.Run(tt, func(t *testing.T) {
113 | t.Parallel()
114 | var (
115 | workerLimit int
116 | opt quartz.SchedulerOpt
117 | )
118 | switch tt {
119 | case "Blocking":
120 | opt = quartz.WithBlockingExecution()
121 | case "NonBlocking":
122 | case "WorkerSmall":
123 | workerLimit = 4
124 | opt = quartz.WithWorkerLimit(workerLimit)
125 | case "WorkerLarge":
126 | workerLimit = 16
127 | opt = quartz.WithWorkerLimit(workerLimit)
128 | default:
129 | t.Fatal("unknown semantic:", tt)
130 | }
131 |
132 | opts := []quartz.SchedulerOpt{quartz.WithOutdatedThreshold(10 * time.Millisecond)}
133 | if opt != nil {
134 | opts = append(opts, opt)
135 | }
136 |
137 | sched, err := quartz.NewStdScheduler(opts...)
138 | assert.IsNil(t, err)
139 |
140 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
141 | defer cancel()
142 | sched.Start(ctx)
143 |
144 | var n atomic.Int64
145 | timerJob := quartz.NewJobDetail(
146 | job.NewFunctionJob(func(ctx context.Context) (bool, error) {
147 | n.Add(1)
148 | timer := time.NewTimer(time.Hour)
149 | defer timer.Stop()
150 | select {
151 | case <-timer.C:
152 | return false, nil
153 | case <-ctx.Done():
154 | return true, nil
155 | }
156 | }),
157 | quartz.NewJobKey("timerJob"),
158 | )
159 | err = sched.ScheduleJob(
160 | timerJob,
161 | quartz.NewSimpleTrigger(20*time.Millisecond),
162 | )
163 | if err != nil {
164 | t.Fatalf("Failed to schedule job, err: %s", err)
165 | }
166 | ticker := time.NewTicker(100 * time.Millisecond)
167 | <-ticker.C
168 | if n.Load() == 0 {
169 | t.Error("job should have run at least once")
170 | }
171 |
172 | switch tt {
173 | case "Blocking":
174 | BLOCKING:
175 | for iters := 0; iters < 100; iters++ {
176 | iters++
177 | select {
178 | case <-ctx.Done():
179 | break BLOCKING
180 | case <-ticker.C:
181 | num := n.Load()
182 | if num != 1 {
183 | t.Error("job should have only run once", num)
184 | }
185 | }
186 | }
187 | case "NonBlocking":
188 | var lastN int64
189 | NONBLOCKING:
190 | for iters := 0; iters < 100; iters++ {
191 | select {
192 | case <-ctx.Done():
193 | break NONBLOCKING
194 | case <-ticker.C:
195 | num := n.Load()
196 | if num <= lastN {
197 | t.Errorf("on iter %d n did not increase %d",
198 | iters, num,
199 | )
200 | }
201 | lastN = num
202 | }
203 | }
204 | case "WorkerSmall", "WorkerLarge":
205 | WORKERS:
206 | for iters := 0; iters < 100; iters++ {
207 | select {
208 | case <-ctx.Done():
209 | break WORKERS
210 | case <-ticker.C:
211 | num := n.Load()
212 | if num > int64(workerLimit) {
213 | t.Errorf("on iter %d n %d was more than limit %d",
214 | iters, num, workerLimit,
215 | )
216 | }
217 | }
218 | }
219 | default:
220 | t.Fatal("unknown test:", tt)
221 | }
222 | })
223 | }
224 | }
225 |
226 | func TestScheduler_Cancel(t *testing.T) {
227 | hourJob := func(ctx context.Context) (bool, error) {
228 | timer := time.NewTimer(time.Hour)
229 | defer timer.Stop()
230 | select {
231 | case <-ctx.Done():
232 | return false, ctx.Err()
233 | case <-timer.C:
234 | return true, nil
235 | }
236 | }
237 | for _, tt := range []string{"context", "stop"} {
238 | // give the go runtime to exit many threads
239 | // before the second case.
240 | time.Sleep(time.Millisecond)
241 | t.Run("CloseMethod_"+tt, func(t *testing.T) {
242 | ctx, cancel := context.WithCancel(context.Background())
243 | defer cancel()
244 |
245 | waitCtx, waitCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
246 | defer waitCancel()
247 |
248 | startingRoutines := runtime.NumGoroutine()
249 |
250 | sched, err := quartz.NewStdScheduler()
251 | assert.IsNil(t, err)
252 | sched.Start(ctx)
253 |
254 | time.Sleep(5 * time.Millisecond)
255 | noopRoutines := runtime.NumGoroutine()
256 | if startingRoutines >= noopRoutines {
257 | t.Error("should have started more threads",
258 | startingRoutines,
259 | noopRoutines,
260 | )
261 | }
262 |
263 | for i := 0; i < 100; i++ {
264 | functionJob := quartz.NewJobDetail(job.NewFunctionJob(hourJob),
265 | quartz.NewJobKey(fmt.Sprintf("functionJob_%d", i)))
266 | if err := sched.ScheduleJob(
267 | functionJob,
268 | quartz.NewSimpleTrigger(100*time.Millisecond),
269 | ); err != nil {
270 | t.Errorf("could not add job %d, %s", i, err.Error())
271 | }
272 | }
273 |
274 | runningRoutines := runtime.NumGoroutine()
275 | if runningRoutines < noopRoutines {
276 | t.Error("number of running routines should not decrease",
277 | noopRoutines,
278 | runningRoutines,
279 | )
280 | }
281 | switch tt {
282 | case "context":
283 | cancel()
284 | case "stop":
285 | sched.Stop()
286 | time.Sleep(time.Millisecond) // trigger context switch
287 | default:
288 | t.Fatal("unknown test", tt)
289 | }
290 |
291 | // should not have timed out before we get to this point
292 | if err := waitCtx.Err(); err != nil {
293 | t.Fatal("test took too long")
294 | }
295 |
296 | sched.Wait(waitCtx)
297 | if err := waitCtx.Err(); err != nil {
298 | t.Fatal("waiting timed out before resources were released", err)
299 | }
300 |
301 | time.Sleep(5 * time.Millisecond)
302 | endingRoutines := runtime.NumGoroutine()
303 | if endingRoutines >= runningRoutines {
304 | t.Error("number of routines should decrease after wait",
305 | runningRoutines,
306 | endingRoutines,
307 | )
308 | }
309 |
310 | if t.Failed() {
311 | t.Log("starting", startingRoutines,
312 | "noop", noopRoutines,
313 | "running", runningRoutines,
314 | "ending", endingRoutines,
315 | )
316 | }
317 | })
318 | }
319 | }
320 |
321 | func TestScheduler_JobWithRetries(t *testing.T) {
322 | t.Parallel()
323 |
324 | var n atomic.Int32
325 | funcRetryJob := job.NewFunctionJob(func(_ context.Context) (string, error) {
326 | if n.Add(1) < 3 {
327 | return "", errors.New("less than 3")
328 | }
329 | return "ok", nil
330 | })
331 | ctx := context.Background()
332 | sched, err := quartz.NewStdScheduler()
333 | assert.IsNil(t, err)
334 |
335 | opts := quartz.NewDefaultJobDetailOptions()
336 | opts.MaxRetries = 3
337 | opts.RetryInterval = 50 * time.Millisecond
338 | jobDetail := quartz.NewJobDetailWithOptions(
339 | funcRetryJob,
340 | quartz.NewJobKey("funcRetryJob"),
341 | opts,
342 | )
343 | err = sched.ScheduleJob(jobDetail, quartz.NewRunOnceTrigger(time.Millisecond))
344 | assert.IsNil(t, err)
345 |
346 | err = sched.ScheduleJob(jobDetail, quartz.NewRunOnceTrigger(time.Millisecond))
347 | assert.ErrorIs(t, err, quartz.ErrIllegalState)
348 | assert.ErrorIs(t, err, quartz.ErrJobAlreadyExists)
349 |
350 | jobDetail.Options().Replace = true
351 | err = sched.ScheduleJob(jobDetail, quartz.NewRunOnceTrigger(time.Millisecond))
352 | assert.IsNil(t, err)
353 |
354 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusNA)
355 | assert.Equal(t, n.Load(), 0)
356 |
357 | sched.Start(ctx)
358 |
359 | time.Sleep(25 * time.Millisecond)
360 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusFailure)
361 | assert.Equal(t, n.Load(), 1)
362 |
363 | time.Sleep(50 * time.Millisecond)
364 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusFailure)
365 | assert.Equal(t, n.Load(), 2)
366 |
367 | time.Sleep(100 * time.Millisecond)
368 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusOK)
369 | assert.Equal(t, n.Load(), 3)
370 |
371 | sched.Stop()
372 | }
373 |
374 | func TestScheduler_JobWithRetriesCtxDone(t *testing.T) {
375 | t.Parallel()
376 |
377 | var n atomic.Int32
378 | funcRetryJob := job.NewFunctionJob(func(_ context.Context) (string, error) {
379 | if n.Add(1) < 3 {
380 | return "", errors.New("less than 3")
381 | }
382 | return "ok", nil
383 | })
384 | ctx, cancel := context.WithCancel(context.Background())
385 | sched, err := quartz.NewStdScheduler()
386 | assert.IsNil(t, err)
387 |
388 | opts := quartz.NewDefaultJobDetailOptions()
389 | opts.MaxRetries = 3
390 | opts.RetryInterval = 50 * time.Millisecond
391 | jobDetail := quartz.NewJobDetailWithOptions(
392 | funcRetryJob,
393 | quartz.NewJobKey("funcRetryJob"),
394 | opts,
395 | )
396 | err = sched.ScheduleJob(jobDetail, quartz.NewRunOnceTrigger(time.Millisecond))
397 | assert.IsNil(t, err)
398 |
399 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusNA)
400 | assert.Equal(t, n.Load(), 0)
401 |
402 | sched.Start(ctx)
403 |
404 | time.Sleep(25 * time.Millisecond)
405 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusFailure)
406 | assert.Equal(t, n.Load(), 1)
407 |
408 | time.Sleep(50 * time.Millisecond)
409 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusFailure)
410 | assert.Equal(t, n.Load(), 2)
411 |
412 | cancel() // cancel the context after first retry
413 |
414 | time.Sleep(100 * time.Millisecond)
415 | assert.Equal(t, funcRetryJob.JobStatus(), job.StatusFailure)
416 | assert.Equal(t, n.Load(), 2)
417 |
418 | sched.Stop()
419 | }
420 |
421 | func TestScheduler_MisfiredJob(t *testing.T) {
422 | t.Parallel()
423 |
424 | funcJob := job.NewFunctionJob(func(_ context.Context) (string, error) {
425 | time.Sleep(20 * time.Millisecond)
426 | return "ok", nil
427 | })
428 |
429 | misfiredChan := make(chan quartz.ScheduledJob, 1)
430 | sched, err := quartz.NewStdScheduler(
431 | quartz.WithBlockingExecution(),
432 | quartz.WithOutdatedThreshold(time.Millisecond),
433 | quartz.WithRetryInterval(time.Millisecond),
434 | quartz.WithMisfiredChan(misfiredChan),
435 | )
436 | assert.IsNil(t, err)
437 |
438 | jobDetail := quartz.NewJobDetail(funcJob, quartz.NewJobKey("funcJob"))
439 | err = sched.ScheduleJob(jobDetail, quartz.NewSimpleTrigger(2*time.Millisecond))
440 | assert.IsNil(t, err)
441 |
442 | sched.Start(context.Background())
443 |
444 | misfired := <-misfiredChan
445 | assert.Equal(t, misfired.JobDetail().JobKey().Name(), "funcJob")
446 |
447 | sched.Stop()
448 | }
449 |
450 | func TestScheduler_JobPanic(t *testing.T) {
451 | t.Parallel()
452 |
453 | ctx, cancel := context.WithTimeout(context.Background(), 35*time.Millisecond)
454 | defer cancel()
455 |
456 | var n atomic.Int32
457 | addJob := job.NewFunctionJob(func(_ context.Context) (int32, error) {
458 | return n.Add(1), nil
459 | })
460 | panicJob := job.NewFunctionJob(func(_ context.Context) (int32, error) {
461 | panic("error")
462 | })
463 |
464 | sched, err := quartz.NewStdScheduler()
465 | assert.IsNil(t, err)
466 | sched.Start(ctx)
467 |
468 | addJobDetail := quartz.NewJobDetail(addJob, quartz.NewJobKey("addJob"))
469 | err = sched.ScheduleJob(addJobDetail, quartz.NewSimpleTrigger(10*time.Millisecond))
470 | assert.IsNil(t, err)
471 | panicJobDetail := quartz.NewJobDetail(panicJob, quartz.NewJobKey("panicJob"))
472 | err = sched.ScheduleJob(panicJobDetail, quartz.NewSimpleTrigger(15*time.Millisecond))
473 | assert.IsNil(t, err)
474 |
475 | sched.Wait(ctx)
476 | assert.Equal(t, n.Load(), 3)
477 | }
478 |
479 | func TestScheduler_PauseResume(t *testing.T) {
480 | t.Parallel()
481 |
482 | var n atomic.Int32
483 | funcJob := job.NewFunctionJob(func(_ context.Context) (string, error) {
484 | n.Add(1)
485 | return "ok", nil
486 | })
487 | sched, err := quartz.NewStdScheduler()
488 | assert.IsNil(t, err)
489 |
490 | jobDetail := quartz.NewJobDetail(funcJob, quartz.NewJobKey("funcJob"))
491 | err = sched.ScheduleJob(jobDetail, quartz.NewSimpleTrigger(10*time.Millisecond))
492 | assert.IsNil(t, err)
493 |
494 | assert.Equal(t, n.Load(), 0)
495 | sched.Start(context.Background())
496 |
497 | time.Sleep(55 * time.Millisecond)
498 | assert.Equal(t, n.Load(), 5)
499 |
500 | err = sched.PauseJob(jobDetail.JobKey())
501 | assert.IsNil(t, err)
502 |
503 | time.Sleep(55 * time.Millisecond)
504 | assert.Equal(t, n.Load(), 5)
505 |
506 | err = sched.ResumeJob(jobDetail.JobKey())
507 | assert.IsNil(t, err)
508 |
509 | time.Sleep(55 * time.Millisecond)
510 | assert.Equal(t, n.Load(), 10)
511 |
512 | sched.Stop()
513 | }
514 |
515 | func TestScheduler_PauseResumeErrors(t *testing.T) {
516 | t.Parallel()
517 |
518 | funcJob := job.NewFunctionJob(func(_ context.Context) (string, error) {
519 | return "ok", nil
520 | })
521 | sched, err := quartz.NewStdScheduler()
522 | assert.IsNil(t, err)
523 |
524 | jobDetail := quartz.NewJobDetail(funcJob, quartz.NewJobKey("funcJob"))
525 | err = sched.ScheduleJob(jobDetail, quartz.NewSimpleTrigger(10*time.Millisecond))
526 | assert.IsNil(t, err)
527 |
528 | err = sched.ResumeJob(jobDetail.JobKey())
529 | assert.ErrorIs(t, err, quartz.ErrIllegalState)
530 | assert.ErrorIs(t, err, quartz.ErrJobIsActive)
531 | err = sched.ResumeJob(quartz.NewJobKey("funcJob2"))
532 | assert.ErrorIs(t, err, quartz.ErrJobNotFound)
533 |
534 | err = sched.PauseJob(jobDetail.JobKey())
535 | assert.IsNil(t, err)
536 | err = sched.PauseJob(jobDetail.JobKey())
537 | assert.ErrorIs(t, err, quartz.ErrIllegalState)
538 | assert.ErrorIs(t, err, quartz.ErrJobIsSuspended)
539 | err = sched.PauseJob(quartz.NewJobKey("funcJob2"))
540 | assert.ErrorIs(t, err, quartz.ErrJobNotFound)
541 |
542 | assert.Equal(t, jobCount(sched, matcher.JobPaused()), 1)
543 | assert.Equal(t, jobCount(sched, matcher.JobActive()), 0)
544 | assert.Equal(t, jobCount(sched), 1)
545 |
546 | sched.Stop()
547 | }
548 |
549 | func TestScheduler_ArgumentValidationErrors(t *testing.T) {
550 | t.Parallel()
551 |
552 | sched, err := quartz.NewStdScheduler()
553 | assert.IsNil(t, err)
554 |
555 | j := job.NewShellJob("ls -la")
556 | trigger := quartz.NewRunOnceTrigger(time.Millisecond)
557 | expiredTrigger, err := quartz.NewCronTrigger("0 0 0 1 1 ? 2023")
558 | assert.IsNil(t, err)
559 |
560 | err = sched.ScheduleJob(nil, trigger)
561 | assert.ErrorContains(t, err, "jobDetail is nil")
562 | err = sched.ScheduleJob(quartz.NewJobDetail(j, nil), trigger)
563 | assert.ErrorContains(t, err, "jobDetail.jobKey is nil")
564 | err = sched.ScheduleJob(quartz.NewJobDetail(j, quartz.NewJobKey("")), trigger)
565 | assert.ErrorContains(t, err, "empty key name is not allowed")
566 | err = sched.ScheduleJob(quartz.NewJobDetail(j, quartz.NewJobKeyWithGroup("job", "")), nil)
567 | assert.ErrorContains(t, err, "trigger is nil")
568 | err = sched.ScheduleJob(quartz.NewJobDetail(j, quartz.NewJobKey("job")), expiredTrigger)
569 | assert.ErrorIs(t, err, quartz.ErrTriggerExpired)
570 |
571 | err = sched.DeleteJob(nil)
572 | assert.ErrorContains(t, err, "jobKey is nil")
573 |
574 | err = sched.PauseJob(nil)
575 | assert.ErrorContains(t, err, "jobKey is nil")
576 |
577 | err = sched.ResumeJob(nil)
578 | assert.ErrorContains(t, err, "jobKey is nil")
579 |
580 | _, err = sched.GetScheduledJob(nil)
581 | assert.ErrorContains(t, err, "jobKey is nil")
582 |
583 | sched.Stop()
584 | }
585 |
586 | func TestScheduler_StartStop(t *testing.T) {
587 | t.Parallel()
588 |
589 | sched, err := quartz.NewStdScheduler()
590 | assert.IsNil(t, err)
591 |
592 | ctx := context.Background()
593 | sched.Start(ctx)
594 | sched.Start(ctx)
595 | assert.Equal(t, sched.IsStarted(), true)
596 |
597 | sched.Stop()
598 | sched.Stop()
599 | assert.Equal(t, sched.IsStarted(), false)
600 | }
601 |
602 | func TestScheduler_OptionErrors(t *testing.T) {
603 | t.Parallel()
604 |
605 | opts := []quartz.SchedulerOpt{
606 | quartz.WithWorkerLimit(-1),
607 | quartz.WithMisfiredChan(nil),
608 | quartz.WithQueue(nil, &sync.Mutex{}),
609 | quartz.WithQueue(quartz.NewJobQueue(), nil),
610 | quartz.WithLogger(nil),
611 | }
612 | for _, opt := range opts {
613 | sched, err := quartz.NewStdScheduler(opt)
614 | assert.IsNil(t, sched)
615 | assert.ErrorIs(t, err, quartz.ErrIllegalArgument)
616 | }
617 | }
618 |
619 | func jobCount(sched quartz.Scheduler, matchers ...quartz.Matcher[quartz.ScheduledJob]) int {
620 | keys, _ := sched.GetJobKeys(matchers...)
621 | return len(keys)
622 | }
623 |
--------------------------------------------------------------------------------
/quartz/trigger.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "fmt"
5 | "time"
6 | )
7 |
8 | // Trigger represents the mechanism by which Jobs are scheduled.
9 | type Trigger interface {
10 | // NextFireTime returns the next time at which the Trigger is scheduled to fire.
11 | NextFireTime(prev int64) (int64, error)
12 |
13 | // Description returns the description of the Trigger.
14 | Description() string
15 | }
16 |
17 | // SimpleTrigger implements the quartz.Trigger interface; uses a fixed interval.
18 | type SimpleTrigger struct {
19 | Interval time.Duration
20 | }
21 |
22 | // Verify SimpleTrigger satisfies the Trigger interface.
23 | var _ Trigger = (*SimpleTrigger)(nil)
24 |
25 | // NewSimpleTrigger returns a new SimpleTrigger using the given interval.
26 | func NewSimpleTrigger(interval time.Duration) *SimpleTrigger {
27 | return &SimpleTrigger{
28 | Interval: interval,
29 | }
30 | }
31 |
32 | // NextFireTime returns the next time at which the SimpleTrigger is scheduled to fire.
33 | func (st *SimpleTrigger) NextFireTime(prev int64) (int64, error) {
34 | next := prev + st.Interval.Nanoseconds()
35 | return next, nil
36 | }
37 |
38 | // Description returns the description of the trigger.
39 | func (st *SimpleTrigger) Description() string {
40 | return fmt.Sprintf("SimpleTrigger%s%s", Sep, st.Interval)
41 | }
42 |
43 | // RunOnceTrigger implements the quartz.Trigger interface.
44 | // This type of Trigger can only be fired once and will expire immediately.
45 | type RunOnceTrigger struct {
46 | Delay time.Duration
47 | Expired bool
48 | }
49 |
50 | // Verify RunOnceTrigger satisfies the Trigger interface.
51 | var _ Trigger = (*RunOnceTrigger)(nil)
52 |
53 | // NewRunOnceTrigger returns a new RunOnceTrigger with the given delay time.
54 | func NewRunOnceTrigger(delay time.Duration) *RunOnceTrigger {
55 | return &RunOnceTrigger{
56 | Delay: delay,
57 | }
58 | }
59 |
60 | // NextFireTime returns the next time at which the RunOnceTrigger is scheduled to fire.
61 | // Sets expired to true afterwards.
62 | func (ot *RunOnceTrigger) NextFireTime(prev int64) (int64, error) {
63 | if !ot.Expired {
64 | next := prev + ot.Delay.Nanoseconds()
65 | ot.Expired = true
66 | return next, nil
67 | }
68 |
69 | return 0, ErrTriggerExpired
70 | }
71 |
72 | // Description returns the description of the trigger.
73 | func (ot *RunOnceTrigger) Description() string {
74 | status := "valid"
75 | if ot.Expired {
76 | status = "expired"
77 | }
78 |
79 | return fmt.Sprintf("RunOnceTrigger%s%s%s%s", Sep, ot.Delay, Sep, status)
80 | }
81 |
--------------------------------------------------------------------------------
/quartz/trigger_test.go:
--------------------------------------------------------------------------------
1 | package quartz_test
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/reugn/go-quartz/internal/assert"
8 | "github.com/reugn/go-quartz/quartz"
9 | )
10 |
11 | var fromEpoch int64 = 1577836800000000000
12 |
13 | func TestSimpleTrigger(t *testing.T) {
14 | trigger := quartz.NewSimpleTrigger(time.Second * 5)
15 | assert.Equal(t, trigger.Description(), "SimpleTrigger::5s")
16 |
17 | next, err := trigger.NextFireTime(fromEpoch)
18 | assert.Equal(t, next, 1577836805000000000)
19 | assert.IsNil(t, err)
20 |
21 | next, err = trigger.NextFireTime(next)
22 | assert.Equal(t, next, 1577836810000000000)
23 | assert.IsNil(t, err)
24 |
25 | next, err = trigger.NextFireTime(next)
26 | assert.Equal(t, next, 1577836815000000000)
27 | assert.IsNil(t, err)
28 | }
29 |
30 | func TestRunOnceTrigger(t *testing.T) {
31 | trigger := quartz.NewRunOnceTrigger(time.Second * 5)
32 | assert.Equal(t, trigger.Description(), "RunOnceTrigger::5s::valid")
33 |
34 | next, err := trigger.NextFireTime(fromEpoch)
35 | assert.Equal(t, next, 1577836805000000000)
36 | assert.IsNil(t, err)
37 |
38 | next, err = trigger.NextFireTime(next)
39 | assert.Equal(t, trigger.Description(), "RunOnceTrigger::5s::expired")
40 | assert.Equal(t, next, 0)
41 | assert.ErrorIs(t, err, quartz.ErrTriggerExpired)
42 | }
43 |
--------------------------------------------------------------------------------
/quartz/util.go:
--------------------------------------------------------------------------------
1 | package quartz
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "strings"
7 | "time"
8 | )
9 |
10 | const (
11 | listRune = ','
12 | stepRune = '/'
13 | rangeRune = '-'
14 | weekdayRune = 'W'
15 | lastRune = 'L'
16 | hashRune = '#'
17 | )
18 |
19 | // Sep is the serialization delimiter; the default is a double colon.
20 | var Sep = "::"
21 |
22 | func translateLiterals(glossary, literals []string) ([]int, error) {
23 | intValues := make([]int, 0, len(literals))
24 | for _, literal := range literals {
25 | index, err := normalize(literal, glossary)
26 | if err != nil {
27 | return nil, err
28 | }
29 | intValues = append(intValues, index)
30 | }
31 | return intValues, nil
32 | }
33 |
34 | func extractRangeValues(parsed []string) ([]string, []string) {
35 | values := make([]string, 0, len(parsed))
36 | rangeValues := make([]string, 0)
37 | for _, v := range parsed {
38 | if strings.ContainsRune(v, rangeRune) { // range value
39 | rangeValues = append(rangeValues, v)
40 | } else {
41 | values = append(values, v)
42 | }
43 | }
44 | return values, rangeValues
45 | }
46 |
47 | func extractStepValues(parsed []string) ([]string, []string) {
48 | values := make([]string, 0, len(parsed))
49 | stepValues := make([]string, 0)
50 | for _, v := range parsed {
51 | if strings.ContainsRune(v, stepRune) { // step value
52 | stepValues = append(stepValues, v)
53 | } else {
54 | values = append(values, v)
55 | }
56 | }
57 | return values, stepValues
58 | }
59 |
60 | func fillRangeValues(from, to int) ([]int, error) {
61 | if to < from {
62 | return nil, newCronParseError("fill range values")
63 | }
64 | length := (to - from) + 1
65 | rangeValues := make([]int, length)
66 | for i, j := from, 0; i <= to; i, j = i+1, j+1 {
67 | rangeValues[j] = i
68 | }
69 | return rangeValues, nil
70 | }
71 |
72 | func fillStepValues(from, step, upperBound int) ([]int, error) {
73 | if upperBound < from || step == 0 {
74 | return nil, newCronParseError("fill step values")
75 | }
76 | length := ((upperBound - from) / step) + 1
77 | stepValues := make([]int, length)
78 | for i, j := from, 0; i <= upperBound; i, j = i+step, j+1 {
79 | stepValues[j] = i
80 | }
81 | return stepValues, nil
82 | }
83 |
84 | func normalize(field string, glossary []string) (int, error) {
85 | numeric, err := strconv.Atoi(field)
86 | if err != nil {
87 | return translateLiteral(glossary, field)
88 | }
89 | return numeric, nil
90 | }
91 |
92 | func inScope(value, lowerBound, upperBound int) bool {
93 | if value >= lowerBound && value <= upperBound {
94 | return true
95 | }
96 | return false
97 | }
98 |
99 | func translateLiteral(glossary []string, literal string) (int, error) {
100 | upperCaseLiteral := strings.ToUpper(literal)
101 | for i, value := range glossary {
102 | if value == upperCaseLiteral {
103 | return i, nil
104 | }
105 | }
106 | return 0, newCronParseError(fmt.Sprintf("unknown literal %s", literal))
107 | }
108 |
109 | func newInvalidCronFieldError(t, field string) error {
110 | return newCronParseError(fmt.Sprintf("invalid %s field %s", t, field))
111 | }
112 |
113 | // NowNano returns the current Unix time in nanoseconds.
114 | func NowNano() int64 {
115 | return time.Now().UnixNano()
116 | }
117 |
--------------------------------------------------------------------------------