├── .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 | [![Build](https://github.com/reugn/go-quartz/actions/workflows/build.yml/badge.svg)](https://github.com/reugn/go-quartz/actions/workflows/build.yml) 4 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/reugn/go-quartz)](https://pkg.go.dev/github.com/reugn/go-quartz) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/reugn/go-quartz)](https://goreportcard.com/report/github.com/reugn/go-quartz) 6 | [![codecov](https://codecov.io/gh/reugn/go-quartz/branch/master/graph/badge.svg)](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 | --------------------------------------------------------------------------------