├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ ├── codeql-analysis.yml
│ ├── file_formatting.yml
│ └── go_test.yml
├── .gitignore
├── .golangci.yaml
├── .pre-commit-config.yaml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── assets
├── jetbrains-mono-white.png
└── sentry-wordmark-light-280x84.png
├── distributed.go
├── errors.go
├── example_test.go
├── examples
└── elector
│ └── main.go
├── executor.go
├── go.mod
├── go.sum
├── job.go
├── job_test.go
├── logger.go
├── logger_test.go
├── mocks
├── README.md
├── distributed.go
├── go.mod
├── go.sum
├── job.go
├── logger.go
└── scheduler.go
├── monitor.go
├── scheduler.go
├── scheduler_test.go
├── util.go
└── util_test.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: gocron
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
13 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | # Maintain dependencies for GitHub Actions
9 | - package-ecosystem: "github-actions"
10 | directory: "/"
11 | schedule:
12 | interval: "weekly"
13 |
14 | # Maintain Go dependencies
15 | - package-ecosystem: "gomod"
16 | directory: "/"
17 | schedule:
18 | interval: "weekly"
19 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ v2 ]
17 | branches-ignore:
18 | - "dependabot/**"
19 | pull_request:
20 | paths-ignore:
21 | - '**.md'
22 | # The branches below must be a subset of the branches above
23 | branches: [ v2 ]
24 | schedule:
25 | - cron: '34 7 * * 1'
26 |
27 | jobs:
28 | analyze:
29 | name: Analyze
30 | runs-on: ubuntu-latest
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'go' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 | # Learn more:
38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v4
43 |
44 | # Initializes the CodeQL tools for scanning.
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v3
47 | with:
48 | languages: ${{ matrix.language }}
49 | # If you wish to specify custom queries, you can do so here or in a config file.
50 | # By default, queries listed here will override any specified in a config file.
51 | # Prefix the list here with "+" to use these queries and those in the config file.
52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
53 |
54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
55 | # If this step fails, then you should remove it and run the build manually (see below)
56 | - name: Autobuild
57 | uses: github/codeql-action/autobuild@v3
58 |
59 | # ℹ️ Command-line programs to run using the OS shell.
60 | # 📚 https://git.io/JvXDl
61 |
62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
63 | # and modify them (or add more) to build your code if your project
64 | # uses a compiled language
65 |
66 | #- run: |
67 | # make bootstrap
68 | # make release
69 |
70 | - name: Perform CodeQL Analysis
71 | uses: github/codeql-action/analyze@v3
72 |
--------------------------------------------------------------------------------
/.github/workflows/file_formatting.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - v2
5 | pull_request:
6 | branches:
7 | - v2
8 |
9 | name: formatting
10 | jobs:
11 | check-sorted:
12 | name: check sorted
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: checkout code
16 | uses: actions/checkout@v4
17 | - name: verify example_test.go
18 | run: |
19 | grep "^func [a-z-A-Z]" example_test.go | sort -c
20 |
--------------------------------------------------------------------------------
/.github/workflows/go_test.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - v2
5 | pull_request:
6 | branches:
7 | - v2
8 |
9 | name: lint and test
10 | jobs:
11 | golangci:
12 | strategy:
13 | matrix:
14 | go-version:
15 | - "1.23"
16 | - "1.24"
17 | name: lint and test
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: Checkout code
21 | uses: actions/checkout@v4
22 | - name: Install Go
23 | uses: actions/setup-go@v5
24 | with:
25 | go-version: ${{ matrix.go-version }}
26 | - name: golangci-lint
27 | uses: golangci/golangci-lint-action@v8.0.0
28 | with:
29 | version: v2.1.5
30 | - name: test
31 | run: make test_ci
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 | local_testing
11 | coverage.out
12 |
13 | # Output of the go coverage tool, specifically when used with LiteIDE
14 | *.out
15 |
16 | # Dependency directories (remove the comment below to include it)
17 | vendor/
18 |
19 | # IDE project files
20 | .idea
21 |
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | run:
3 | issues-exit-code: 1
4 | tests: true
5 | output:
6 | formats:
7 | text:
8 | path: stdout
9 | print-linter-name: true
10 | print-issued-lines: true
11 | path-prefix: ""
12 | linters:
13 | enable:
14 | - bodyclose
15 | - copyloopvar
16 | - misspell
17 | - revive
18 | - whitespace
19 | exclusions:
20 | generated: lax
21 | presets:
22 | - common-false-positives
23 | - legacy
24 | - std-error-handling
25 | rules:
26 | - linters:
27 | - revive
28 | path: example_test.go
29 | text: seems to be unused
30 | - linters:
31 | - revive
32 | text: package-comments
33 | paths:
34 | - local
35 | - third_party$
36 | - builtin$
37 | - examples$
38 | issues:
39 | max-same-issues: 100
40 | fix: true
41 | formatters:
42 | enable:
43 | - gofumpt
44 | - goimports
45 | exclusions:
46 | generated: lax
47 | paths:
48 | - local
49 | - third_party$
50 | - builtin$
51 | - examples$
52 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # See https://pre-commit.com for more information
2 | # See https://pre-commit.com/hooks.html for more hooks
3 | repos:
4 | - repo: https://github.com/pre-commit/pre-commit-hooks
5 | rev: v5.0.0
6 | hooks:
7 | - id: check-added-large-files
8 | - id: check-case-conflict
9 | - id: check-merge-conflict
10 | - id: check-yaml
11 | - id: detect-private-key
12 | - id: end-of-file-fixer
13 | - id: trailing-whitespace
14 | - repo: https://github.com/golangci/golangci-lint
15 | rev: v2.1.5
16 | hooks:
17 | - id: golangci-lint
18 | - repo: https://github.com/TekWizely/pre-commit-golang
19 | rev: v1.0.0-rc.1
20 | hooks:
21 | - id: go-fumpt
22 | args:
23 | - -w
24 | - id: go-mod-tidy
25 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone. And we mean everyone!
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to creating a positive environment
12 | include:
13 |
14 | * Using welcoming and kind language
15 | * Being respectful of differing viewpoints and experiences
16 | * Gracefully accepting constructive criticism
17 | * Focusing on what is best for the community
18 | * Showing empathy towards other community members
19 |
20 | Examples of unacceptable behavior by participants include:
21 |
22 | * The use of sexualized language or imagery and unwelcome sexual attention or
23 | advances
24 | * Trolling, insulting/derogatory comments, and personal or political attacks
25 | * Public or private harassment
26 | * Publishing others' private information, such as a physical or electronic
27 | address, without explicit permission
28 | * Other conduct which could reasonably be considered inappropriate in a
29 | professional setting
30 |
31 | ## Our Responsibilities
32 |
33 | Project maintainers are responsible for clarifying the standards of acceptable
34 | behavior and are expected to take appropriate and fair corrective action in
35 | response to any instances of unacceptable behavior.
36 |
37 | Project maintainers have the right and responsibility to remove, edit, or
38 | reject comments, commits, code, wiki edits, issues, and other contributions
39 | that are not aligned to this Code of Conduct, or to ban temporarily or
40 | permanently any contributor for other behaviors that they deem inappropriate,
41 | threatening, offensive, or harmful.
42 |
43 | ## Scope
44 |
45 | This Code of Conduct applies both within project spaces and in public spaces
46 | when an individual is representing the project or its community. Examples of
47 | representing a project or community include using an official project e-mail
48 | address, posting via an official social media account, or acting as an appointed
49 | representative at an online or offline event. Representation of a project may be
50 | further defined and clarified by project maintainers.
51 |
52 | ## Enforcement
53 |
54 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
55 | reported by contacting the project team initially on Slack to coordinate private communication. All
56 | complaints will be reviewed and investigated and will result in a response that
57 | is deemed necessary and appropriate to the circumstances. The project team is
58 | obligated to maintain confidentiality with regard to the reporter of an incident.
59 | Further details of specific enforcement policies may be posted separately.
60 |
61 | Project maintainers who do not follow or enforce the Code of Conduct in good
62 | faith may face temporary or permanent repercussions as determined by other
63 | members of the project's leadership.
64 |
65 | ## Attribution
66 |
67 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
68 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
69 |
70 | [homepage]: https://www.contributor-covenant.org
71 |
72 | For answers to common questions about this code of conduct, see
73 | https://www.contributor-covenant.org/faq
74 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to gocron
2 |
3 | Thank you for coming to contribute to gocron! We welcome new ideas, PRs and general feedback.
4 |
5 | ## Reporting Bugs
6 |
7 | If you find a bug then please let the project know by opening an issue after doing the following:
8 |
9 | - Do a quick search of the existing issues to make sure the bug isn't already reported
10 | - Try and make a minimal list of steps that can reliably reproduce the bug you are experiencing
11 | - Collect as much information as you can to help identify what the issue is (project version, configuration files, etc)
12 |
13 | ## Suggesting Enhancements
14 |
15 | If you have a use case that you don't see a way to support yet, we would welcome the feedback in an issue. Before opening the issue, please consider:
16 |
17 | - Is this a common use case?
18 | - Is it simple to understand?
19 |
20 | You can help us out by doing the following before raising a new issue:
21 |
22 | - Check that the feature hasn't been requested already by searching existing issues
23 | - Try and reduce your enhancement into a single, concise and deliverable request, rather than a general idea
24 | - Explain your own use cases as the basis of the request
25 |
26 | ## Adding Features
27 |
28 | Pull requests are always welcome. However, before going through the trouble of implementing a change it's worth creating a bug or feature request issue.
29 | This allows us to discuss the changes and make sure they are a good fit for the project.
30 |
31 | Please always make sure a pull request has been:
32 |
33 | - Unit tested with `make test`
34 | - Linted with `make lint`
35 |
36 | ## Writing Tests
37 |
38 | Tests should follow the [table driven test pattern](https://dave.cheney.net/2013/06/09/writing-table-driven-tests-in-go). See other tests in the code base for additional examples.
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2014, 辣椒面
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 all
13 | 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 THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: fmt lint test mocks test_coverage test_ci
2 |
3 | GO_PKGS := $(shell go list -f {{.Dir}} ./...)
4 |
5 | fmt:
6 | @go list -f {{.Dir}} ./... | xargs -I{} gofmt -w -s {}
7 |
8 | lint:
9 | @grep "^func [a-zA-Z]" example_test.go | sort -c
10 | @golangci-lint run
11 |
12 | test:
13 | @go test -race -v $(GO_FLAGS) -count=1 $(GO_PKGS)
14 |
15 | test_coverage:
16 | @go test -race -v $(GO_FLAGS) -count=1 -coverprofile=coverage.out -covermode=atomic $(GO_PKGS)
17 |
18 | test_ci:
19 | @go test -race -v $(GO_FLAGS) -count=1 $(GO_PKGS)
20 |
21 | mocks:
22 | @go generate ./...
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gocron: A Golang Job Scheduling Package
2 |
3 | [](https://github.com/go-co-op/gocron/actions)
4 |  [](https://pkg.go.dev/github.com/go-co-op/gocron/v2)
5 |
6 | gocron is a job scheduling package which lets you run Go functions at pre-determined intervals.
7 |
8 | If you want to chat, you can find us on Slack at
9 | [
](https://gophers.slack.com/archives/CQ7T0T1FW)
10 |
11 | ## Quick Start
12 |
13 | ```
14 | go get github.com/go-co-op/gocron/v2
15 | ```
16 |
17 | ```golang
18 | package main
19 |
20 | import (
21 | "fmt"
22 | "time"
23 |
24 | "github.com/go-co-op/gocron/v2"
25 | )
26 |
27 | func main() {
28 | // create a scheduler
29 | s, err := gocron.NewScheduler()
30 | if err != nil {
31 | // handle error
32 | }
33 |
34 | // add a job to the scheduler
35 | j, err := s.NewJob(
36 | gocron.DurationJob(
37 | 10*time.Second,
38 | ),
39 | gocron.NewTask(
40 | func(a string, b int) {
41 | // do things
42 | },
43 | "hello",
44 | 1,
45 | ),
46 | )
47 | if err != nil {
48 | // handle error
49 | }
50 | // each job has a unique id
51 | fmt.Println(j.ID())
52 |
53 | // start the scheduler
54 | s.Start()
55 |
56 | // block until you are ready to shut down
57 | select {
58 | case <-time.After(time.Minute):
59 | }
60 |
61 | // when you're done, shut it down
62 | err = s.Shutdown()
63 | if err != nil {
64 | // handle error
65 | }
66 | }
67 | ```
68 |
69 | ## Examples
70 |
71 | - [Go doc examples](https://pkg.go.dev/github.com/go-co-op/gocron/v2#pkg-examples)
72 | - [Examples directory](examples)
73 |
74 | ## Concepts
75 |
76 | - **Job**: The job encapsulates a "task", which is made up of a go function and any function parameters. The Job then
77 | provides the scheduler with the time the job should next be scheduled to run.
78 | - **Scheduler**: The scheduler keeps track of all the jobs and sends each job to the executor when
79 | it is ready to be run.
80 | - **Executor**: The executor calls the job's task and manages the complexities of different job
81 | execution timing requirements (e.g. singletons that shouldn't overrun each other, limiting the max number of jobs running)
82 |
83 |
84 | ## Features
85 |
86 | ### Job types
87 | Jobs can be run at various intervals.
88 | - [**Duration**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#DurationJob):
89 | Jobs can be run at a fixed `time.Duration`.
90 | - [**Random duration**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#DurationRandomJob):
91 | Jobs can be run at a random `time.Duration` between a min and max.
92 | - [**Cron**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#CronJob):
93 | Jobs can be run using a crontab.
94 | - [**Daily**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#DailyJob):
95 | Jobs can be run every x days at specific times.
96 | - [**Weekly**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WeeklyJob):
97 | Jobs can be run every x weeks on specific days of the week and at specific times.
98 | - [**Monthly**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#MonthlyJob):
99 | Jobs can be run every x months on specific days of the month and at specific times.
100 | - [**One time**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#OneTimeJob):
101 | Jobs can be run at specific time(s) (either once or many times).
102 |
103 | ### Concurrency Limits
104 | Jobs can be limited individually or across the entire scheduler.
105 | - [**Per job limiting with singleton mode**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithSingletonMode):
106 | Jobs can be limited to a single concurrent execution that either reschedules (skips overlapping executions)
107 | or queues (waits for the previous execution to finish).
108 | - [**Per scheduler limiting with limit mode**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithLimitConcurrentJobs):
109 | Jobs can be limited to a certain number of concurrent executions across the entire scheduler
110 | using either reschedule (skip when the limit is met) or queue (jobs are added to a queue to
111 | wait for the limit to be available).
112 | - **Note:** A scheduler limit and a job limit can both be enabled.
113 |
114 | ### Distributed instances of gocron
115 | Multiple instances of gocron can be run.
116 | - [**Elector**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithDistributedElector):
117 | An elector can be used to elect a single instance of gocron to run as the primary with the
118 | other instances checking to see if a new leader needs to be elected.
119 | - Implementations: [go-co-op electors](https://github.com/go-co-op?q=-elector&type=all&language=&sort=)
120 | (don't see what you need? request on slack to get a repo created to contribute it!)
121 | - [**Locker**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithDistributedLocker):
122 | A locker can be used to lock each run of a job to a single instance of gocron.
123 | Locker can be at job or scheduler, if it is defined both at job and scheduler then locker of job will take precedence.
124 | - See Notes in the doc for [Locker](https://pkg.go.dev/github.com/go-co-op/gocron/v2#Locker) for
125 | details and limitations of the locker design.
126 | - Implementations: [go-co-op lockers](https://github.com/go-co-op?q=-lock&type=all&language=&sort=)
127 | (don't see what you need? request on slack to get a repo created to contribute it!)
128 |
129 | ### Events
130 | Job events can trigger actions.
131 | - [**Listeners**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithEventListeners):
132 | Can be added to a job, with [event listeners](https://pkg.go.dev/github.com/go-co-op/gocron/v2#EventListener),
133 | or all jobs across the
134 | [scheduler](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithGlobalJobOptions)
135 | to listen for job events and trigger actions.
136 |
137 | ### Options
138 | Many job and scheduler options are available.
139 | - [**Job options**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#JobOption):
140 | Job options can be set when creating a job using `NewJob`.
141 | - [**Global job options**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithGlobalJobOptions):
142 | Global job options can be set when creating a scheduler using `NewScheduler`
143 | and the `WithGlobalJobOptions` option.
144 | - [**Scheduler options**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#SchedulerOption):
145 | Scheduler options can be set when creating a scheduler using `NewScheduler`.
146 |
147 | ### Logging
148 | Logs can be enabled.
149 | - [Logger](https://pkg.go.dev/github.com/go-co-op/gocron/v2#Logger):
150 | The Logger interface can be implemented with your desired logging library.
151 | The provided NewLogger uses the standard library's log package.
152 |
153 | ### Metrics
154 | Metrics may be collected from the execution of each job.
155 | - [**Monitor**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#Monitor):
156 | - [**MonitorStatus**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#MonitorStatus) (includes status and error (if any) of the Job)
157 | A monitor can be used to collect metrics for each job from a scheduler.
158 | - Implementations: [go-co-op monitors](https://github.com/go-co-op?q=-monitor&type=all&language=&sort=)
159 | (don't see what you need? request on slack to get a repo created to contribute it!)
160 |
161 | ### Testing
162 | The gocron library is set up to enable testing.
163 | - Mocks are provided in [the mock package](mocks) using [gomock](https://github.com/uber-go/mock).
164 | - Time can be mocked by passing in a [FakeClock](https://pkg.go.dev/github.com/jonboulle/clockwork#FakeClock)
165 | to [WithClock](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithClock) -
166 | see the [example on WithClock](https://pkg.go.dev/github.com/go-co-op/gocron/v2#example-WithClock).
167 |
168 | ## Supporters
169 |
170 | We appreciate the support for free and open source software!
171 |
172 | This project is supported by:
173 |
174 | [JetBrains](https://www.jetbrains.com/?from=gocron)
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 | [Sentry](https://sentry.io/welcome/)
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 | ## Star History
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | The current plan is to maintain version 2 as long as possible incorporating any necessary security patches. Version 1 is deprecated and will no longer be patched.
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | 1.x.x | :heavy_multiplication_x: |
10 | | 2.x.x | :white_check_mark: |
11 |
12 | ## Reporting a Vulnerability
13 |
14 | Vulnerabilities can be reported by [opening an issue](https://github.com/go-co-op/gocron/issues/new/choose) or reaching out on Slack: [
](https://gophers.slack.com/archives/CQ7T0T1FW)
15 |
16 | We will do our best to address any vulnerabilities in an expeditious manner.
17 |
--------------------------------------------------------------------------------
/assets/jetbrains-mono-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/go-co-op/gocron/4fb3b987637d059463250ed4de26342f6441fef3/assets/jetbrains-mono-white.png
--------------------------------------------------------------------------------
/assets/sentry-wordmark-light-280x84.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/go-co-op/gocron/4fb3b987637d059463250ed4de26342f6441fef3/assets/sentry-wordmark-light-280x84.png
--------------------------------------------------------------------------------
/distributed.go:
--------------------------------------------------------------------------------
1 | //go:generate mockgen -destination=mocks/distributed.go -package=gocronmocks . Elector,Locker,Lock
2 | package gocron
3 |
4 | import (
5 | "context"
6 | )
7 |
8 | // Elector determines the leader from instances asking to be the leader. Only
9 | // the leader runs jobs. If the leader goes down, a new leader will be elected.
10 | type Elector interface {
11 | // IsLeader should return nil if the job should be scheduled by the instance
12 | // making the request and an error if the job should not be scheduled.
13 | IsLeader(context.Context) error
14 | }
15 |
16 | // Locker represents the required interface to lock jobs when running multiple schedulers.
17 | // The lock is held for the duration of the job's run, and it is expected that the
18 | // locker implementation handles time splay between schedulers.
19 | // The lock key passed is the job's name - which, if not set, defaults to the
20 | // go function's name, e.g. "pkg.myJob" for func myJob() {} in pkg
21 | //
22 | // Notes: The locker and scheduler do not handle synchronization of run times across
23 | // schedulers.
24 | //
25 | // 1. If you are using duration based jobs (DurationJob), you can utilize the JobOption
26 | // WithStartAt to set a start time for the job to the nearest time rounded to your
27 | // duration. For example, if you have a job that runs every 5 minutes, you can set
28 | // the start time to the nearest 5 minute e.g. 12:05, 12:10.
29 | //
30 | // 2. For all jobs, the implementation is still vulnerable to clockskew between scheduler
31 | // instances. This may result in a single scheduler instance running the majority of the
32 | // jobs.
33 | //
34 | // For distributed jobs, consider utilizing the Elector option if these notes are not acceptable
35 | // to your use case.
36 | type Locker interface {
37 | // Lock if an error is returned by lock, the job will not be scheduled.
38 | Lock(ctx context.Context, key string) (Lock, error)
39 | }
40 |
41 | // Lock represents an obtained lock. The lock is released after the execution of the job
42 | // by the scheduler.
43 | type Lock interface {
44 | Unlock(ctx context.Context) error
45 | }
46 |
--------------------------------------------------------------------------------
/errors.go:
--------------------------------------------------------------------------------
1 | package gocron
2 |
3 | import "fmt"
4 |
5 | // Public error definitions
6 | var (
7 | ErrCronJobInvalid = fmt.Errorf("gocron: CronJob: invalid crontab")
8 | ErrCronJobParse = fmt.Errorf("gocron: CronJob: crontab parse failure")
9 | ErrDailyJobAtTimeNil = fmt.Errorf("gocron: DailyJob: atTime within atTimes must not be nil")
10 | ErrDailyJobAtTimesNil = fmt.Errorf("gocron: DailyJob: atTimes must not be nil")
11 | ErrDailyJobHours = fmt.Errorf("gocron: DailyJob: atTimes hours must be between 0 and 23 inclusive")
12 | ErrDailyJobZeroInterval = fmt.Errorf("gocron: DailyJob: interval must be greater than 0")
13 | ErrDailyJobMinutesSeconds = fmt.Errorf("gocron: DailyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
14 | ErrDurationJobIntervalZero = fmt.Errorf("gocron: DurationJob: time interval is 0")
15 | ErrDurationRandomJobMinMax = fmt.Errorf("gocron: DurationRandomJob: minimum duration must be less than maximum duration")
16 | ErrEventListenerFuncNil = fmt.Errorf("gocron: eventListenerFunc must not be nil")
17 | ErrJobNotFound = fmt.Errorf("gocron: job not found")
18 | ErrJobRunNowFailed = fmt.Errorf("gocron: Job: RunNow: scheduler unreachable")
19 | ErrMonthlyJobDays = fmt.Errorf("gocron: MonthlyJob: daysOfTheMonth must be between 31 and -31 inclusive, and not 0")
20 | ErrMonthlyJobAtTimeNil = fmt.Errorf("gocron: MonthlyJob: atTime within atTimes must not be nil")
21 | ErrMonthlyJobAtTimesNil = fmt.Errorf("gocron: MonthlyJob: atTimes must not be nil")
22 | ErrMonthlyJobDaysNil = fmt.Errorf("gocron: MonthlyJob: daysOfTheMonth must not be nil")
23 | ErrMonthlyJobHours = fmt.Errorf("gocron: MonthlyJob: atTimes hours must be between 0 and 23 inclusive")
24 | ErrMonthlyJobZeroInterval = fmt.Errorf("gocron: MonthlyJob: interval must be greater than 0")
25 | ErrMonthlyJobMinutesSeconds = fmt.Errorf("gocron: MonthlyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
26 | ErrNewJobTaskNil = fmt.Errorf("gocron: NewJob: Task must not be nil")
27 | ErrNewJobTaskNotFunc = fmt.Errorf("gocron: NewJob: Task.Function must be of kind reflect.Func")
28 | ErrNewJobWrongNumberOfParameters = fmt.Errorf("gocron: NewJob: Number of provided parameters does not match expected")
29 | ErrNewJobWrongTypeOfParameters = fmt.Errorf("gocron: NewJob: Type of provided parameters does not match expected")
30 | ErrOneTimeJobStartDateTimePast = fmt.Errorf("gocron: OneTimeJob: start must not be in the past")
31 | ErrStopExecutorTimedOut = fmt.Errorf("gocron: timed out waiting for executor to stop")
32 | ErrStopJobsTimedOut = fmt.Errorf("gocron: timed out waiting for jobs to finish")
33 | ErrStopSchedulerTimedOut = fmt.Errorf("gocron: timed out waiting for scheduler to stop")
34 | ErrWeeklyJobAtTimeNil = fmt.Errorf("gocron: WeeklyJob: atTime within atTimes must not be nil")
35 | ErrWeeklyJobAtTimesNil = fmt.Errorf("gocron: WeeklyJob: atTimes must not be nil")
36 | ErrWeeklyJobDaysOfTheWeekNil = fmt.Errorf("gocron: WeeklyJob: daysOfTheWeek must not be nil")
37 | ErrWeeklyJobHours = fmt.Errorf("gocron: WeeklyJob: atTimes hours must be between 0 and 23 inclusive")
38 | ErrWeeklyJobZeroInterval = fmt.Errorf("gocron: WeeklyJob: interval must be greater than 0")
39 | ErrWeeklyJobMinutesSeconds = fmt.Errorf("gocron: WeeklyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
40 | ErrPanicRecovered = fmt.Errorf("gocron: panic recovered")
41 | ErrWithClockNil = fmt.Errorf("gocron: WithClock: clock must not be nil")
42 | ErrWithContextNil = fmt.Errorf("gocron: WithContext: context must not be nil")
43 | ErrWithDistributedElectorNil = fmt.Errorf("gocron: WithDistributedElector: elector must not be nil")
44 | ErrWithDistributedLockerNil = fmt.Errorf("gocron: WithDistributedLocker: locker must not be nil")
45 | ErrWithDistributedJobLockerNil = fmt.Errorf("gocron: WithDistributedJobLocker: locker must not be nil")
46 | ErrWithIdentifierNil = fmt.Errorf("gocron: WithIdentifier: identifier must not be nil")
47 | ErrWithLimitConcurrentJobsZero = fmt.Errorf("gocron: WithLimitConcurrentJobs: limit must be greater than 0")
48 | ErrWithLocationNil = fmt.Errorf("gocron: WithLocation: location must not be nil")
49 | ErrWithLoggerNil = fmt.Errorf("gocron: WithLogger: logger must not be nil")
50 | ErrWithMonitorNil = fmt.Errorf("gocron: WithMonitor: monitor must not be nil")
51 | ErrWithNameEmpty = fmt.Errorf("gocron: WithName: name must not be empty")
52 | ErrWithStartDateTimePast = fmt.Errorf("gocron: WithStartDateTime: start must not be in the past")
53 | ErrWithStopDateTimePast = fmt.Errorf("gocron: WithStopDateTime: end must not be in the past")
54 | ErrStartTimeLaterThanEndTime = fmt.Errorf("gocron: WithStartDateTime: start must not be later than end")
55 | ErrStopTimeEarlierThanStartTime = fmt.Errorf("gocron: WithStopDateTime: end must not be earlier than start")
56 | ErrWithStopTimeoutZeroOrNegative = fmt.Errorf("gocron: WithStopTimeout: timeout must be greater than 0")
57 | )
58 |
59 | // internal errors
60 | var (
61 | errAtTimeNil = fmt.Errorf("errAtTimeNil")
62 | errAtTimesNil = fmt.Errorf("errAtTimesNil")
63 | errAtTimeHours = fmt.Errorf("errAtTimeHours")
64 | errAtTimeMinSec = fmt.Errorf("errAtTimeMinSec")
65 | )
66 |
--------------------------------------------------------------------------------
/example_test.go:
--------------------------------------------------------------------------------
1 | package gocron_test
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "sync"
7 | "time"
8 |
9 | "github.com/go-co-op/gocron/v2"
10 | "github.com/google/uuid"
11 | "github.com/jonboulle/clockwork"
12 | )
13 |
14 | func ExampleAfterJobRuns() {
15 | s, _ := gocron.NewScheduler()
16 | defer func() { _ = s.Shutdown() }()
17 |
18 | _, _ = s.NewJob(
19 | gocron.DurationJob(
20 | time.Second,
21 | ),
22 | gocron.NewTask(
23 | func() {},
24 | ),
25 | gocron.WithEventListeners(
26 | gocron.AfterJobRuns(
27 | func(jobID uuid.UUID, jobName string) {
28 | // do something after the job completes
29 | },
30 | ),
31 | ),
32 | )
33 | }
34 |
35 | func ExampleAfterJobRunsWithError() {
36 | s, _ := gocron.NewScheduler()
37 | defer func() { _ = s.Shutdown() }()
38 |
39 | _, _ = s.NewJob(
40 | gocron.DurationJob(
41 | time.Second,
42 | ),
43 | gocron.NewTask(
44 | func() {},
45 | ),
46 | gocron.WithEventListeners(
47 | gocron.AfterJobRunsWithError(
48 | func(jobID uuid.UUID, jobName string, err error) {
49 | // do something when the job returns an error
50 | },
51 | ),
52 | ),
53 | )
54 | }
55 |
56 | var _ gocron.Locker = new(errorLocker)
57 |
58 | type errorLocker struct{}
59 |
60 | func (e errorLocker) Lock(_ context.Context, _ string) (gocron.Lock, error) {
61 | return nil, fmt.Errorf("locked")
62 | }
63 |
64 | func ExampleAfterLockError() {
65 | s, _ := gocron.NewScheduler()
66 | defer func() { _ = s.Shutdown() }()
67 |
68 | _, _ = s.NewJob(
69 | gocron.DurationJob(
70 | time.Second,
71 | ),
72 | gocron.NewTask(
73 | func() {},
74 | ),
75 | gocron.WithDistributedJobLocker(&errorLocker{}),
76 | gocron.WithEventListeners(
77 | gocron.AfterLockError(
78 | func(jobID uuid.UUID, jobName string, err error) {
79 | // do something immediately before the job is run
80 | },
81 | ),
82 | ),
83 | )
84 | }
85 |
86 | func ExampleBeforeJobRuns() {
87 | s, _ := gocron.NewScheduler()
88 | defer func() { _ = s.Shutdown() }()
89 |
90 | _, _ = s.NewJob(
91 | gocron.DurationJob(
92 | time.Second,
93 | ),
94 | gocron.NewTask(
95 | func() {},
96 | ),
97 | gocron.WithEventListeners(
98 | gocron.BeforeJobRuns(
99 | func(jobID uuid.UUID, jobName string) {
100 | // do something immediately before the job is run
101 | },
102 | ),
103 | ),
104 | )
105 | }
106 |
107 | func ExampleBeforeJobRunsSkipIfBeforeFuncErrors() {
108 | s, _ := gocron.NewScheduler()
109 | defer func() { _ = s.Shutdown() }()
110 |
111 | _, _ = s.NewJob(
112 | gocron.DurationJob(
113 | time.Second,
114 | ),
115 | gocron.NewTask(
116 | func() {
117 | fmt.Println("Will never run, because before job func errors")
118 | },
119 | ),
120 | gocron.WithEventListeners(
121 | gocron.BeforeJobRunsSkipIfBeforeFuncErrors(
122 | func(jobID uuid.UUID, jobName string) error {
123 | return fmt.Errorf("error")
124 | },
125 | ),
126 | ),
127 | )
128 | }
129 |
130 | func ExampleCronJob() {
131 | s, _ := gocron.NewScheduler()
132 | defer func() { _ = s.Shutdown() }()
133 |
134 | _, _ = s.NewJob(
135 | gocron.CronJob(
136 | // standard cron tab parsing
137 | "1 * * * *",
138 | false,
139 | ),
140 | gocron.NewTask(
141 | func() {},
142 | ),
143 | )
144 | _, _ = s.NewJob(
145 | gocron.CronJob(
146 | // optionally include seconds as the first field
147 | "* 1 * * * *",
148 | true,
149 | ),
150 | gocron.NewTask(
151 | func() {},
152 | ),
153 | )
154 | }
155 |
156 | func ExampleDailyJob() {
157 | s, _ := gocron.NewScheduler()
158 | defer func() { _ = s.Shutdown() }()
159 |
160 | _, _ = s.NewJob(
161 | gocron.DailyJob(
162 | 1,
163 | gocron.NewAtTimes(
164 | gocron.NewAtTime(10, 30, 0),
165 | gocron.NewAtTime(14, 0, 0),
166 | ),
167 | ),
168 | gocron.NewTask(
169 | func(a, b string) {},
170 | "a",
171 | "b",
172 | ),
173 | )
174 | }
175 |
176 | func ExampleDurationJob() {
177 | s, _ := gocron.NewScheduler()
178 | defer func() { _ = s.Shutdown() }()
179 |
180 | _, _ = s.NewJob(
181 | gocron.DurationJob(
182 | time.Second*5,
183 | ),
184 | gocron.NewTask(
185 | func() {},
186 | ),
187 | )
188 | }
189 |
190 | func ExampleDurationRandomJob() {
191 | s, _ := gocron.NewScheduler()
192 | defer func() { _ = s.Shutdown() }()
193 |
194 | _, _ = s.NewJob(
195 | gocron.DurationRandomJob(
196 | time.Second,
197 | 5*time.Second,
198 | ),
199 | gocron.NewTask(
200 | func() {},
201 | ),
202 | )
203 | }
204 |
205 | func ExampleJob_id() {
206 | s, _ := gocron.NewScheduler()
207 | defer func() { _ = s.Shutdown() }()
208 |
209 | j, _ := s.NewJob(
210 | gocron.DurationJob(
211 | time.Second,
212 | ),
213 | gocron.NewTask(
214 | func() {},
215 | ),
216 | )
217 |
218 | fmt.Println(j.ID())
219 | }
220 |
221 | func ExampleJob_lastRun() {
222 | s, _ := gocron.NewScheduler()
223 | defer func() { _ = s.Shutdown() }()
224 |
225 | j, _ := s.NewJob(
226 | gocron.DurationJob(
227 | time.Second,
228 | ),
229 | gocron.NewTask(
230 | func() {},
231 | ),
232 | )
233 |
234 | fmt.Println(j.LastRun())
235 | }
236 |
237 | func ExampleJob_name() {
238 | s, _ := gocron.NewScheduler()
239 | defer func() { _ = s.Shutdown() }()
240 |
241 | j, _ := s.NewJob(
242 | gocron.DurationJob(
243 | time.Second,
244 | ),
245 | gocron.NewTask(
246 | func() {},
247 | ),
248 | gocron.WithName("foobar"),
249 | )
250 |
251 | fmt.Println(j.Name())
252 | // Output:
253 | // foobar
254 | }
255 |
256 | func ExampleJob_nextRun() {
257 | s, _ := gocron.NewScheduler()
258 | defer func() { _ = s.Shutdown() }()
259 |
260 | j, _ := s.NewJob(
261 | gocron.DurationJob(
262 | time.Second,
263 | ),
264 | gocron.NewTask(
265 | func() {},
266 | ),
267 | )
268 |
269 | nextRun, _ := j.NextRun()
270 | fmt.Println(nextRun)
271 | }
272 |
273 | func ExampleJob_nextRuns() {
274 | s, _ := gocron.NewScheduler()
275 | defer func() { _ = s.Shutdown() }()
276 |
277 | j, _ := s.NewJob(
278 | gocron.DurationJob(
279 | time.Second,
280 | ),
281 | gocron.NewTask(
282 | func() {},
283 | ),
284 | )
285 |
286 | nextRuns, _ := j.NextRuns(5)
287 | fmt.Println(nextRuns)
288 | }
289 |
290 | func ExampleJob_runNow() {
291 | s, _ := gocron.NewScheduler()
292 | defer func() { _ = s.Shutdown() }()
293 |
294 | j, _ := s.NewJob(
295 | gocron.MonthlyJob(
296 | 1,
297 | gocron.NewDaysOfTheMonth(3, -5, -1),
298 | gocron.NewAtTimes(
299 | gocron.NewAtTime(10, 30, 0),
300 | gocron.NewAtTime(11, 15, 0),
301 | ),
302 | ),
303 | gocron.NewTask(
304 | func() {},
305 | ),
306 | )
307 | s.Start()
308 | // Runs the job one time now, without impacting the schedule
309 | _ = j.RunNow()
310 | }
311 |
312 | func ExampleJob_tags() {
313 | s, _ := gocron.NewScheduler()
314 | defer func() { _ = s.Shutdown() }()
315 |
316 | j, _ := s.NewJob(
317 | gocron.DurationJob(
318 | time.Second,
319 | ),
320 | gocron.NewTask(
321 | func() {},
322 | ),
323 | gocron.WithTags("foo", "bar"),
324 | )
325 |
326 | fmt.Println(j.Tags())
327 | // Output:
328 | // [foo bar]
329 | }
330 |
331 | func ExampleMonthlyJob() {
332 | s, _ := gocron.NewScheduler()
333 | defer func() { _ = s.Shutdown() }()
334 |
335 | _, _ = s.NewJob(
336 | gocron.MonthlyJob(
337 | 1,
338 | gocron.NewDaysOfTheMonth(3, -5, -1),
339 | gocron.NewAtTimes(
340 | gocron.NewAtTime(10, 30, 0),
341 | gocron.NewAtTime(11, 15, 0),
342 | ),
343 | ),
344 | gocron.NewTask(
345 | func() {},
346 | ),
347 | )
348 | }
349 |
350 | func ExampleNewScheduler() {
351 | s, _ := gocron.NewScheduler()
352 | defer func() { _ = s.Shutdown() }()
353 |
354 | fmt.Println(s.Jobs())
355 | }
356 |
357 | func ExampleNewTask() {
358 | s, _ := gocron.NewScheduler()
359 | defer func() { _ = s.Shutdown() }()
360 |
361 | _, _ = s.NewJob(
362 | gocron.DurationJob(time.Second),
363 | gocron.NewTask(
364 | func(ctx context.Context) {
365 | // gocron will pass in a context (either the default Job context, or one
366 | // provided via WithContext) to the job and will cancel the context on shutdown.
367 | // This allows you to listen for and handle cancellation within your job.
368 | },
369 | ),
370 | )
371 | }
372 |
373 | func ExampleOneTimeJob() {
374 | s, _ := gocron.NewScheduler()
375 | defer func() { _ = s.Shutdown() }()
376 |
377 | // run a job once, immediately
378 | _, _ = s.NewJob(
379 | gocron.OneTimeJob(
380 | gocron.OneTimeJobStartImmediately(),
381 | ),
382 | gocron.NewTask(
383 | func() {},
384 | ),
385 | )
386 | // run a job once in 10 seconds
387 | _, _ = s.NewJob(
388 | gocron.OneTimeJob(
389 | gocron.OneTimeJobStartDateTime(time.Now().Add(10*time.Second)),
390 | ),
391 | gocron.NewTask(
392 | func() {},
393 | ),
394 | )
395 | // run job twice - once in 10 seconds and once in 55 minutes
396 | n := time.Now()
397 | _, _ = s.NewJob(
398 | gocron.OneTimeJob(
399 | gocron.OneTimeJobStartDateTimes(
400 | n.Add(10*time.Second),
401 | n.Add(55*time.Minute),
402 | ),
403 | ),
404 | gocron.NewTask(func() {}),
405 | )
406 |
407 | s.Start()
408 | }
409 |
410 | func ExampleScheduler_jobs() {
411 | s, _ := gocron.NewScheduler()
412 | defer func() { _ = s.Shutdown() }()
413 |
414 | _, _ = s.NewJob(
415 | gocron.DurationJob(
416 | 10*time.Second,
417 | ),
418 | gocron.NewTask(
419 | func() {},
420 | ),
421 | )
422 | fmt.Println(len(s.Jobs()))
423 | // Output:
424 | // 1
425 | }
426 |
427 | func ExampleScheduler_newJob() {
428 | s, _ := gocron.NewScheduler()
429 | defer func() { _ = s.Shutdown() }()
430 |
431 | j, err := s.NewJob(
432 | gocron.DurationJob(
433 | 10*time.Second,
434 | ),
435 | gocron.NewTask(
436 | func() {},
437 | ),
438 | )
439 | if err != nil {
440 | panic(err)
441 | }
442 | fmt.Println(j.ID())
443 | }
444 |
445 | func ExampleScheduler_removeByTags() {
446 | s, _ := gocron.NewScheduler()
447 | defer func() { _ = s.Shutdown() }()
448 |
449 | _, _ = s.NewJob(
450 | gocron.DurationJob(
451 | time.Second,
452 | ),
453 | gocron.NewTask(
454 | func() {},
455 | ),
456 | gocron.WithTags("tag1"),
457 | )
458 | _, _ = s.NewJob(
459 | gocron.DurationJob(
460 | time.Second,
461 | ),
462 | gocron.NewTask(
463 | func() {},
464 | ),
465 | gocron.WithTags("tag2"),
466 | )
467 | fmt.Println(len(s.Jobs()))
468 |
469 | s.RemoveByTags("tag1", "tag2")
470 |
471 | fmt.Println(len(s.Jobs()))
472 | // Output:
473 | // 2
474 | // 0
475 | }
476 |
477 | func ExampleScheduler_removeJob() {
478 | s, _ := gocron.NewScheduler()
479 | defer func() { _ = s.Shutdown() }()
480 |
481 | j, _ := s.NewJob(
482 | gocron.DurationJob(
483 | time.Second,
484 | ),
485 | gocron.NewTask(
486 | func() {},
487 | ),
488 | )
489 |
490 | fmt.Println(len(s.Jobs()))
491 |
492 | _ = s.RemoveJob(j.ID())
493 |
494 | fmt.Println(len(s.Jobs()))
495 | // Output:
496 | // 1
497 | // 0
498 | }
499 |
500 | func ExampleScheduler_shutdown() {
501 | s, _ := gocron.NewScheduler()
502 | defer func() { _ = s.Shutdown() }()
503 | }
504 |
505 | func ExampleScheduler_start() {
506 | s, _ := gocron.NewScheduler()
507 | defer func() { _ = s.Shutdown() }()
508 |
509 | _, _ = s.NewJob(
510 | gocron.CronJob(
511 | "* * * * *",
512 | false,
513 | ),
514 | gocron.NewTask(
515 | func() {},
516 | ),
517 | )
518 |
519 | s.Start()
520 | }
521 |
522 | func ExampleScheduler_stopJobs() {
523 | s, _ := gocron.NewScheduler()
524 | defer func() { _ = s.Shutdown() }()
525 |
526 | _, _ = s.NewJob(
527 | gocron.CronJob(
528 | "* * * * *",
529 | false,
530 | ),
531 | gocron.NewTask(
532 | func() {},
533 | ),
534 | )
535 |
536 | s.Start()
537 |
538 | _ = s.StopJobs()
539 | }
540 |
541 | func ExampleScheduler_update() {
542 | s, _ := gocron.NewScheduler()
543 | defer func() { _ = s.Shutdown() }()
544 |
545 | j, _ := s.NewJob(
546 | gocron.CronJob(
547 | "* * * * *",
548 | false,
549 | ),
550 | gocron.NewTask(
551 | func() {},
552 | ),
553 | )
554 |
555 | s.Start()
556 |
557 | // after some time, need to change the job
558 |
559 | j, _ = s.Update(
560 | j.ID(),
561 | gocron.DurationJob(
562 | 5*time.Second,
563 | ),
564 | gocron.NewTask(
565 | func() {},
566 | ),
567 | )
568 | }
569 |
570 | func ExampleWeeklyJob() {
571 | s, _ := gocron.NewScheduler()
572 | defer func() { _ = s.Shutdown() }()
573 |
574 | _, _ = s.NewJob(
575 | gocron.WeeklyJob(
576 | 2,
577 | gocron.NewWeekdays(time.Tuesday, time.Wednesday, time.Saturday),
578 | gocron.NewAtTimes(
579 | gocron.NewAtTime(1, 30, 0),
580 | gocron.NewAtTime(12, 0, 30),
581 | ),
582 | ),
583 | gocron.NewTask(
584 | func() {},
585 | ),
586 | )
587 | }
588 |
589 | func ExampleWithClock() {
590 | fakeClock := clockwork.NewFakeClock()
591 | s, _ := gocron.NewScheduler(
592 | gocron.WithClock(fakeClock),
593 | )
594 | var wg sync.WaitGroup
595 | wg.Add(1)
596 | _, _ = s.NewJob(
597 | gocron.DurationJob(
598 | time.Second*5,
599 | ),
600 | gocron.NewTask(
601 | func(one string, two int) {
602 | fmt.Printf("%s, %d\n", one, two)
603 | wg.Done()
604 | },
605 | "one", 2,
606 | ),
607 | )
608 | s.Start()
609 | _ = fakeClock.BlockUntilContext(context.Background(), 1)
610 | fakeClock.Advance(time.Second * 5)
611 | wg.Wait()
612 | _ = s.StopJobs()
613 | // Output:
614 | // one, 2
615 | }
616 |
617 | func ExampleWithContext() {
618 | s, _ := gocron.NewScheduler()
619 | defer func() { _ = s.Shutdown() }()
620 |
621 | ctx, cancel := context.WithCancel(context.Background())
622 | defer cancel()
623 |
624 | _, _ = s.NewJob(
625 | gocron.DurationJob(
626 | time.Second,
627 | ),
628 | gocron.NewTask(
629 | func(ctx context.Context) {
630 | // gocron will pass in the context provided via WithContext
631 | // to the job and will cancel the context on shutdown.
632 | // This allows you to listen for and handle cancellation within your job.
633 | },
634 | ),
635 | gocron.WithContext(ctx),
636 | )
637 | }
638 |
639 | var _ gocron.Cron = (*customCron)(nil)
640 |
641 | type customCron struct{}
642 |
643 | func (c customCron) IsValid(crontab string, location *time.Location, now time.Time) error {
644 | return nil
645 | }
646 |
647 | func (c customCron) Next(lastRun time.Time) time.Time {
648 | return time.Now().Add(time.Second)
649 | }
650 |
651 | func ExampleWithCronImplementation() {
652 | s, _ := gocron.NewScheduler()
653 | defer func() { _ = s.Shutdown() }()
654 | _, _ = s.NewJob(
655 | gocron.CronJob(
656 | "* * * * *",
657 | false,
658 | ),
659 | gocron.NewTask(
660 | func() {},
661 | ),
662 | gocron.WithCronImplementation(
663 | &customCron{},
664 | ),
665 | )
666 | }
667 |
668 | func ExampleWithDisabledDistributedJobLocker() {
669 | // var _ gocron.Locker = (*myLocker)(nil)
670 | //
671 | // type myLocker struct{}
672 | //
673 | // func (m myLocker) Lock(ctx context.Context, key string) (Lock, error) {
674 | // return &testLock{}, nil
675 | // }
676 | //
677 | // var _ gocron.Lock = (*testLock)(nil)
678 | //
679 | // type testLock struct{}
680 | //
681 | // func (t testLock) Unlock(_ context.Context) error {
682 | // return nil
683 | // }
684 |
685 | locker := &myLocker{}
686 |
687 | s, _ := gocron.NewScheduler(
688 | gocron.WithDistributedLocker(locker),
689 | )
690 |
691 | _, _ = s.NewJob(
692 | gocron.DurationJob(
693 | time.Second,
694 | ),
695 | gocron.NewTask(
696 | func() {},
697 | ),
698 | gocron.WithDisabledDistributedJobLocker(true),
699 | )
700 | }
701 |
702 | var _ gocron.Elector = (*myElector)(nil)
703 |
704 | type myElector struct{}
705 |
706 | func (m myElector) IsLeader(_ context.Context) error {
707 | return nil
708 | }
709 |
710 | func ExampleWithDistributedElector() {
711 | // var _ gocron.Elector = (*myElector)(nil)
712 | //
713 | // type myElector struct{}
714 | //
715 | // func (m myElector) IsLeader(_ context.Context) error {
716 | // return nil
717 | // }
718 | //
719 | elector := &myElector{}
720 |
721 | _, _ = gocron.NewScheduler(
722 | gocron.WithDistributedElector(elector),
723 | )
724 | }
725 |
726 | var _ gocron.Locker = (*myLocker)(nil)
727 |
728 | type myLocker struct{}
729 |
730 | func (m myLocker) Lock(ctx context.Context, key string) (gocron.Lock, error) {
731 | return &testLock{}, nil
732 | }
733 |
734 | var _ gocron.Lock = (*testLock)(nil)
735 |
736 | type testLock struct{}
737 |
738 | func (t testLock) Unlock(_ context.Context) error {
739 | return nil
740 | }
741 |
742 | func ExampleWithDistributedLocker() {
743 | // var _ gocron.Locker = (*myLocker)(nil)
744 | //
745 | // type myLocker struct{}
746 | //
747 | // func (m myLocker) Lock(ctx context.Context, key string) (Lock, error) {
748 | // return &testLock{}, nil
749 | // }
750 | //
751 | // var _ gocron.Lock = (*testLock)(nil)
752 | //
753 | // type testLock struct{}
754 | //
755 | // func (t testLock) Unlock(_ context.Context) error {
756 | // return nil
757 | // }
758 |
759 | locker := &myLocker{}
760 |
761 | _, _ = gocron.NewScheduler(
762 | gocron.WithDistributedLocker(locker),
763 | )
764 | }
765 |
766 | func ExampleWithEventListeners() {
767 | s, _ := gocron.NewScheduler()
768 | defer func() { _ = s.Shutdown() }()
769 |
770 | _, _ = s.NewJob(
771 | gocron.DurationJob(
772 | time.Second,
773 | ),
774 | gocron.NewTask(
775 | func() {},
776 | ),
777 | gocron.WithEventListeners(
778 | gocron.AfterJobRuns(
779 | func(jobID uuid.UUID, jobName string) {
780 | // do something after the job completes
781 | },
782 | ),
783 | gocron.AfterJobRunsWithError(
784 | func(jobID uuid.UUID, jobName string, err error) {
785 | // do something when the job returns an error
786 | },
787 | ),
788 | gocron.BeforeJobRuns(
789 | func(jobID uuid.UUID, jobName string) {
790 | // do something immediately before the job is run
791 | },
792 | ),
793 | ),
794 | )
795 | }
796 |
797 | func ExampleWithGlobalJobOptions() {
798 | s, _ := gocron.NewScheduler(
799 | gocron.WithGlobalJobOptions(
800 | gocron.WithTags("tag1", "tag2", "tag3"),
801 | ),
802 | )
803 |
804 | j, _ := s.NewJob(
805 | gocron.DurationJob(
806 | time.Second,
807 | ),
808 | gocron.NewTask(
809 | func(one string, two int) {
810 | fmt.Printf("%s, %d", one, two)
811 | },
812 | "one", 2,
813 | ),
814 | )
815 | // The job will have the globally applied tags
816 | fmt.Println(j.Tags())
817 |
818 | s2, _ := gocron.NewScheduler(
819 | gocron.WithGlobalJobOptions(
820 | gocron.WithTags("tag1", "tag2", "tag3"),
821 | ),
822 | )
823 | j2, _ := s2.NewJob(
824 | gocron.DurationJob(
825 | time.Second,
826 | ),
827 | gocron.NewTask(
828 | func(one string, two int) {
829 | fmt.Printf("%s, %d", one, two)
830 | },
831 | "one", 2,
832 | ),
833 | gocron.WithTags("tag4", "tag5", "tag6"),
834 | )
835 | // The job will have the tags set specifically on the job
836 | // overriding those set globally by the scheduler
837 | fmt.Println(j2.Tags())
838 | // Output:
839 | // [tag1 tag2 tag3]
840 | // [tag4 tag5 tag6]
841 | }
842 |
843 | func ExampleWithIdentifier() {
844 | s, _ := gocron.NewScheduler()
845 | defer func() { _ = s.Shutdown() }()
846 |
847 | j, _ := s.NewJob(
848 | gocron.DurationJob(
849 | time.Second,
850 | ),
851 | gocron.NewTask(
852 | func(one string, two int) {
853 | fmt.Printf("%s, %d", one, two)
854 | },
855 | "one", 2,
856 | ),
857 | gocron.WithIdentifier(uuid.MustParse("87b95dfc-3e71-11ef-9454-0242ac120002")),
858 | )
859 | fmt.Println(j.ID())
860 | // Output:
861 | // 87b95dfc-3e71-11ef-9454-0242ac120002
862 | }
863 |
864 | func ExampleWithLimitConcurrentJobs() {
865 | _, _ = gocron.NewScheduler(
866 | gocron.WithLimitConcurrentJobs(
867 | 1,
868 | gocron.LimitModeReschedule,
869 | ),
870 | )
871 | }
872 |
873 | func ExampleWithLimitedRuns() {
874 | s, _ := gocron.NewScheduler()
875 | defer func() { _ = s.Shutdown() }()
876 |
877 | _, _ = s.NewJob(
878 | gocron.DurationJob(
879 | time.Millisecond,
880 | ),
881 | gocron.NewTask(
882 | func(one string, two int) {
883 | fmt.Printf("%s, %d\n", one, two)
884 | },
885 | "one", 2,
886 | ),
887 | gocron.WithLimitedRuns(1),
888 | )
889 | s.Start()
890 |
891 | time.Sleep(100 * time.Millisecond)
892 | _ = s.StopJobs()
893 | fmt.Printf("no jobs in scheduler: %v\n", s.Jobs())
894 | // Output:
895 | // one, 2
896 | // no jobs in scheduler: []
897 | }
898 |
899 | func ExampleWithLocation() {
900 | location, _ := time.LoadLocation("Asia/Kolkata")
901 |
902 | _, _ = gocron.NewScheduler(
903 | gocron.WithLocation(location),
904 | )
905 | }
906 |
907 | func ExampleWithLogger() {
908 | _, _ = gocron.NewScheduler(
909 | gocron.WithLogger(
910 | gocron.NewLogger(gocron.LogLevelDebug),
911 | ),
912 | )
913 | }
914 |
915 | func ExampleWithMonitor() {
916 | //type exampleMonitor struct {
917 | // mu sync.Mutex
918 | // counter map[string]int
919 | // time map[string][]time.Duration
920 | //}
921 | //
922 | //func newExampleMonitor() *exampleMonitor {
923 | // return &exampleMonitor{
924 | // counter: make(map[string]int),
925 | // time: make(map[string][]time.Duration),
926 | //}
927 | //}
928 | //
929 | //func (t *exampleMonitor) IncrementJob(_ uuid.UUID, name string, _ []string, _ JobStatus) {
930 | // t.mu.Lock()
931 | // defer t.mu.Unlock()
932 | // _, ok := t.counter[name]
933 | // if !ok {
934 | // t.counter[name] = 0
935 | // }
936 | // t.counter[name]++
937 | //}
938 | //
939 | //func (t *exampleMonitor) RecordJobTiming(startTime, endTime time.Time, _ uuid.UUID, name string, _ []string) {
940 | // t.mu.Lock()
941 | // defer t.mu.Unlock()
942 | // _, ok := t.time[name]
943 | // if !ok {
944 | // t.time[name] = make([]time.Duration, 0)
945 | // }
946 | // t.time[name] = append(t.time[name], endTime.Sub(startTime))
947 | //}
948 | //
949 | //monitor := newExampleMonitor()
950 | //s, _ := NewScheduler(
951 | // WithMonitor(monitor),
952 | //)
953 | //name := "example"
954 | //_, _ = s.NewJob(
955 | // DurationJob(
956 | // time.Second,
957 | // ),
958 | // NewTask(
959 | // func() {
960 | // time.Sleep(1 * time.Second)
961 | // },
962 | // ),
963 | // WithName(name),
964 | // WithStartAt(
965 | // WithStartImmediately(),
966 | // ),
967 | //)
968 | //s.Start()
969 | //time.Sleep(5 * time.Second)
970 | //_ = s.Shutdown()
971 | //
972 | //fmt.Printf("Job %q total execute count: %d\n", name, monitor.counter[name])
973 | //for i, val := range monitor.time[name] {
974 | // fmt.Printf("Job %q execute #%d elapsed %.4f seconds\n", name, i+1, val.Seconds())
975 | //}
976 | }
977 |
978 | func ExampleWithName() {
979 | s, _ := gocron.NewScheduler()
980 | defer func() { _ = s.Shutdown() }()
981 |
982 | j, _ := s.NewJob(
983 | gocron.DurationJob(
984 | time.Second,
985 | ),
986 | gocron.NewTask(
987 | func(one string, two int) {
988 | fmt.Printf("%s, %d", one, two)
989 | },
990 | "one", 2,
991 | ),
992 | gocron.WithName("job 1"),
993 | )
994 | fmt.Println(j.Name())
995 | // Output:
996 | // job 1
997 | }
998 |
999 | func ExampleWithSingletonMode() {
1000 | s, _ := gocron.NewScheduler()
1001 | defer func() { _ = s.Shutdown() }()
1002 |
1003 | _, _ = s.NewJob(
1004 | gocron.DurationJob(
1005 | time.Second,
1006 | ),
1007 | gocron.NewTask(
1008 | func() {
1009 | // this job will skip half it's executions
1010 | // and effectively run every 2 seconds
1011 | time.Sleep(1500 * time.Second)
1012 | },
1013 | ),
1014 | gocron.WithSingletonMode(gocron.LimitModeReschedule),
1015 | )
1016 | }
1017 |
1018 | func ExampleWithStartAt() {
1019 | s, _ := gocron.NewScheduler()
1020 | defer func() { _ = s.Shutdown() }()
1021 |
1022 | start := time.Date(9999, 9, 9, 9, 9, 9, 9, time.UTC)
1023 |
1024 | j, _ := s.NewJob(
1025 | gocron.DurationJob(
1026 | time.Second,
1027 | ),
1028 | gocron.NewTask(
1029 | func(one string, two int) {
1030 | fmt.Printf("%s, %d", one, two)
1031 | },
1032 | "one", 2,
1033 | ),
1034 | gocron.WithStartAt(
1035 | gocron.WithStartDateTime(start),
1036 | ),
1037 | )
1038 | s.Start()
1039 |
1040 | next, _ := j.NextRun()
1041 | fmt.Println(next)
1042 |
1043 | _ = s.StopJobs()
1044 | // Output:
1045 | // 9999-09-09 09:09:09.000000009 +0000 UTC
1046 | }
1047 |
1048 | func ExampleWithStopTimeout() {
1049 | _, _ = gocron.NewScheduler(
1050 | gocron.WithStopTimeout(time.Second * 5),
1051 | )
1052 | }
1053 |
1054 | func ExampleWithTags() {
1055 | s, _ := gocron.NewScheduler()
1056 | defer func() { _ = s.Shutdown() }()
1057 |
1058 | j, _ := s.NewJob(
1059 | gocron.DurationJob(
1060 | time.Second,
1061 | ),
1062 | gocron.NewTask(
1063 | func(one string, two int) {
1064 | fmt.Printf("%s, %d", one, two)
1065 | },
1066 | "one", 2,
1067 | ),
1068 | gocron.WithTags("tag1", "tag2", "tag3"),
1069 | )
1070 | fmt.Println(j.Tags())
1071 | // Output:
1072 | // [tag1 tag2 tag3]
1073 | }
1074 |
--------------------------------------------------------------------------------
/examples/elector/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "time"
8 |
9 | "github.com/go-co-op/gocron/v2"
10 | )
11 |
12 | var _ gocron.Elector = (*myElector)(nil)
13 |
14 | type myElector struct {
15 | num int
16 | leader bool
17 | }
18 |
19 | func (m myElector) IsLeader(_ context.Context) error {
20 | if m.leader {
21 | log.Printf("node %d is leader", m.num)
22 | return nil
23 | }
24 | log.Printf("node %d is not leader", m.num)
25 | return fmt.Errorf("not leader")
26 | }
27 |
28 | func main() {
29 | log.SetFlags(log.LstdFlags | log.Lmicroseconds)
30 |
31 | for i := 0; i < 3; i++ {
32 | go func(i int) {
33 | elector := &myElector{
34 | num: i,
35 | }
36 | if i == 0 {
37 | elector.leader = true
38 | }
39 |
40 | scheduler, err := gocron.NewScheduler(
41 | gocron.WithDistributedElector(elector),
42 | )
43 | if err != nil {
44 | log.Println(err)
45 | return
46 | }
47 |
48 | _, err = scheduler.NewJob(
49 | gocron.DurationJob(time.Second),
50 | gocron.NewTask(func() {
51 | log.Println("run job")
52 | }),
53 | )
54 | if err != nil {
55 | log.Println(err)
56 | return
57 | }
58 | scheduler.Start()
59 |
60 | if i == 0 {
61 | time.Sleep(5 * time.Second)
62 | elector.leader = false
63 | }
64 | if i == 1 {
65 | time.Sleep(5 * time.Second)
66 | elector.leader = true
67 | }
68 | }(i)
69 | }
70 |
71 | select {} // wait forever
72 | }
73 |
--------------------------------------------------------------------------------
/executor.go:
--------------------------------------------------------------------------------
1 | package gocron
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "strconv"
7 | "sync"
8 | "time"
9 |
10 | "github.com/jonboulle/clockwork"
11 |
12 | "github.com/google/uuid"
13 | )
14 |
15 | type executor struct {
16 | // context used for shutting down
17 | ctx context.Context
18 | // cancel used by the executor to signal a stop of it's functions
19 | cancel context.CancelFunc
20 | // clock used for regular time or mocking time
21 | clock clockwork.Clock
22 | // the executor's logger
23 | logger Logger
24 |
25 | // receives jobs scheduled to execute
26 | jobsIn chan jobIn
27 | // sends out jobs for rescheduling
28 | jobsOutForRescheduling chan uuid.UUID
29 | // sends out jobs once completed
30 | jobsOutCompleted chan uuid.UUID
31 | // used to request jobs from the scheduler
32 | jobOutRequest chan jobOutRequest
33 |
34 | // sends out job needs to update the next runs
35 | jobUpdateNextRuns chan uuid.UUID
36 |
37 | // used by the executor to receive a stop signal from the scheduler
38 | stopCh chan struct{}
39 | // ensure that stop runs before the next call to start and only runs once
40 | stopOnce *sync.Once
41 | // the timeout value when stopping
42 | stopTimeout time.Duration
43 | // used to signal that the executor has completed shutdown
44 | done chan error
45 |
46 | // runners for any singleton type jobs
47 | // map[uuid.UUID]singletonRunner
48 | singletonRunners *sync.Map
49 | // config for limit mode
50 | limitMode *limitModeConfig
51 | // the elector when running distributed instances
52 | elector Elector
53 | // the locker when running distributed instances
54 | locker Locker
55 | // monitor for reporting metrics
56 | monitor Monitor
57 | // monitorStatus for reporting metrics
58 | monitorStatus MonitorStatus
59 | }
60 |
61 | type jobIn struct {
62 | id uuid.UUID
63 | shouldSendOut bool
64 | }
65 |
66 | type singletonRunner struct {
67 | in chan jobIn
68 | rescheduleLimiter chan struct{}
69 | }
70 |
71 | type limitModeConfig struct {
72 | started bool
73 | mode LimitMode
74 | limit uint
75 | rescheduleLimiter chan struct{}
76 | in chan jobIn
77 | // singletonJobs is used to track singleton jobs that are running
78 | // in the limit mode runner. This is used to prevent the same job
79 | // from running multiple times across limit mode runners when both
80 | // a limit mode and singleton mode are enabled.
81 | singletonJobs map[uuid.UUID]struct{}
82 | singletonJobsMu sync.Mutex
83 | }
84 |
85 | func (e *executor) start() {
86 | e.logger.Debug("gocron: executor started")
87 |
88 | // creating the executor's context here as the executor
89 | // is the only goroutine that should access this context
90 | // any other uses within the executor should create a context
91 | // using the executor context as parent.
92 | e.ctx, e.cancel = context.WithCancel(context.Background())
93 | e.stopOnce = &sync.Once{}
94 |
95 | // the standardJobsWg tracks
96 | standardJobsWg := &waitGroupWithMutex{}
97 |
98 | singletonJobsWg := &waitGroupWithMutex{}
99 |
100 | limitModeJobsWg := &waitGroupWithMutex{}
101 |
102 | // create a fresh map for tracking singleton runners
103 | e.singletonRunners = &sync.Map{}
104 |
105 | // start the for leap that is the executor
106 | // selecting on channels for work to do
107 | for {
108 | select {
109 | // job ids in are sent from 1 of 2 places:
110 | // 1. the scheduler sends directly when jobs
111 | // are run immediately.
112 | // 2. sent from time.AfterFuncs in which job schedules
113 | // are spun up by the scheduler
114 | case jIn := <-e.jobsIn:
115 | select {
116 | case <-e.stopCh:
117 | e.stop(standardJobsWg, singletonJobsWg, limitModeJobsWg)
118 | return
119 | default:
120 | }
121 | // this context is used to handle cancellation of the executor
122 | // on requests for a job to the scheduler via requestJobCtx
123 | ctx, cancel := context.WithCancel(e.ctx)
124 |
125 | if e.limitMode != nil && !e.limitMode.started {
126 | // check if we are already running the limit mode runners
127 | // if not, spin up the required number i.e. limit!
128 | e.limitMode.started = true
129 | for i := e.limitMode.limit; i > 0; i-- {
130 | limitModeJobsWg.Add(1)
131 | go e.limitModeRunner("limitMode-"+strconv.Itoa(int(i)), e.limitMode.in, limitModeJobsWg, e.limitMode.mode, e.limitMode.rescheduleLimiter)
132 | }
133 | }
134 |
135 | // spin off into a goroutine to unblock the executor and
136 | // allow for processing for more work
137 | go func(executorCtx context.Context) {
138 | // make sure to cancel the above context per the docs
139 | // // Canceling this context releases resources associated with it, so code should
140 | // // call cancel as soon as the operations running in this Context complete.
141 | defer cancel()
142 |
143 | // check for limit mode - this spins up a separate runner which handles
144 | // limiting the total number of concurrently running jobs
145 | if e.limitMode != nil {
146 | if e.limitMode.mode == LimitModeReschedule {
147 | select {
148 | // rescheduleLimiter is a channel the size of the limit
149 | // this blocks publishing to the channel and keeps
150 | // the executor from building up a waiting queue
151 | // and forces rescheduling
152 | case e.limitMode.rescheduleLimiter <- struct{}{}:
153 | e.limitMode.in <- jIn
154 | default:
155 | // all runners are busy, reschedule the work for later
156 | // which means we just skip it here and do nothing
157 | // TODO when metrics are added, this should increment a rescheduled metric
158 | e.sendOutForRescheduling(&jIn)
159 | }
160 | } else {
161 | // since we're not using LimitModeReschedule, but instead using LimitModeWait
162 | // we do want to queue up the work to the limit mode runners and allow them
163 | // to work through the channel backlog. A hard limit of 1000 is in place
164 | // at which point this call would block.
165 | // TODO when metrics are added, this should increment a wait metric
166 | e.sendOutForRescheduling(&jIn)
167 | e.limitMode.in <- jIn
168 | }
169 | } else {
170 | // no limit mode, so we're either running a regular job or
171 | // a job with a singleton mode
172 | //
173 | // get the job, so we can figure out what kind it is and how
174 | // to execute it
175 | j := requestJobCtx(ctx, jIn.id, e.jobOutRequest)
176 | if j == nil {
177 | // safety check as it'd be strange bug if this occurred
178 | return
179 | }
180 | if j.singletonMode {
181 | // for singleton mode, get the existing runner for the job
182 | // or spin up a new one
183 | runner := &singletonRunner{}
184 | runnerSrc, ok := e.singletonRunners.Load(jIn.id)
185 | if !ok {
186 | runner.in = make(chan jobIn, 1000)
187 | if j.singletonLimitMode == LimitModeReschedule {
188 | runner.rescheduleLimiter = make(chan struct{}, 1)
189 | }
190 | e.singletonRunners.Store(jIn.id, runner)
191 | singletonJobsWg.Add(1)
192 | go e.singletonModeRunner("singleton-"+jIn.id.String(), runner.in, singletonJobsWg, j.singletonLimitMode, runner.rescheduleLimiter)
193 | } else {
194 | runner = runnerSrc.(*singletonRunner)
195 | }
196 |
197 | if j.singletonLimitMode == LimitModeReschedule {
198 | // reschedule mode uses the limiter channel to check
199 | // for a running job and reschedules if the channel is full.
200 | select {
201 | case runner.rescheduleLimiter <- struct{}{}:
202 | runner.in <- jIn
203 | e.sendOutForRescheduling(&jIn)
204 | default:
205 | // runner is busy, reschedule the work for later
206 | // which means we just skip it here and do nothing
207 | e.incrementJobCounter(*j, SingletonRescheduled)
208 | e.sendOutForRescheduling(&jIn)
209 | }
210 | } else {
211 | // wait mode, fill up that queue (buffered channel, so it's ok)
212 | runner.in <- jIn
213 | e.sendOutForRescheduling(&jIn)
214 | }
215 | } else {
216 | select {
217 | case <-executorCtx.Done():
218 | return
219 | default:
220 | }
221 | // we've gotten to the basic / standard jobs --
222 | // the ones without anything special that just want
223 | // to be run. Add to the WaitGroup so that
224 | // stopping or shutting down can wait for the jobs to
225 | // complete.
226 | standardJobsWg.Add(1)
227 | go func(j internalJob) {
228 | e.runJob(j, jIn)
229 | standardJobsWg.Done()
230 | }(*j)
231 | }
232 | }
233 | }(e.ctx)
234 | case <-e.stopCh:
235 | e.stop(standardJobsWg, singletonJobsWg, limitModeJobsWg)
236 | return
237 | }
238 | }
239 | }
240 |
241 | func (e *executor) sendOutForRescheduling(jIn *jobIn) {
242 | if jIn.shouldSendOut {
243 | select {
244 | case e.jobsOutForRescheduling <- jIn.id:
245 | case <-e.ctx.Done():
246 | return
247 | }
248 | }
249 | // we need to set this to false now, because to handle
250 | // non-limit jobs, we send out from the e.runJob function
251 | // and in this case we don't want to send out twice.
252 | jIn.shouldSendOut = false
253 | }
254 |
255 | func (e *executor) sendOutForNextRunUpdate(jIn *jobIn) {
256 | select {
257 | case e.jobUpdateNextRuns <- jIn.id:
258 | case <-e.ctx.Done():
259 | return
260 | }
261 | }
262 |
263 | func (e *executor) limitModeRunner(name string, in chan jobIn, wg *waitGroupWithMutex, limitMode LimitMode, rescheduleLimiter chan struct{}) {
264 | e.logger.Debug("gocron: limitModeRunner starting", "name", name)
265 | for {
266 | select {
267 | case jIn := <-in:
268 | select {
269 | case <-e.ctx.Done():
270 | e.logger.Debug("gocron: limitModeRunner shutting down", "name", name)
271 | wg.Done()
272 | return
273 | default:
274 | }
275 |
276 | ctx, cancel := context.WithCancel(e.ctx)
277 | j := requestJobCtx(ctx, jIn.id, e.jobOutRequest)
278 | cancel()
279 | if j != nil {
280 | if j.singletonMode {
281 | e.limitMode.singletonJobsMu.Lock()
282 | _, ok := e.limitMode.singletonJobs[jIn.id]
283 | if ok {
284 | // this job is already running, so don't run it
285 | // but instead reschedule it
286 | e.limitMode.singletonJobsMu.Unlock()
287 | if jIn.shouldSendOut {
288 | select {
289 | case <-e.ctx.Done():
290 | return
291 | case <-j.ctx.Done():
292 | return
293 | case e.jobsOutForRescheduling <- j.id:
294 | }
295 | }
296 | // remove the limiter block, as this particular job
297 | // was a singleton already running, and we want to
298 | // allow another job to be scheduled
299 | if limitMode == LimitModeReschedule {
300 | <-rescheduleLimiter
301 | }
302 | continue
303 | }
304 | e.limitMode.singletonJobs[jIn.id] = struct{}{}
305 | e.limitMode.singletonJobsMu.Unlock()
306 | }
307 | e.runJob(*j, jIn)
308 |
309 | if j.singletonMode {
310 | e.limitMode.singletonJobsMu.Lock()
311 | delete(e.limitMode.singletonJobs, jIn.id)
312 | e.limitMode.singletonJobsMu.Unlock()
313 | }
314 | }
315 |
316 | // remove the limiter block to allow another job to be scheduled
317 | if limitMode == LimitModeReschedule {
318 | <-rescheduleLimiter
319 | }
320 | case <-e.ctx.Done():
321 | e.logger.Debug("limitModeRunner shutting down", "name", name)
322 | wg.Done()
323 | return
324 | }
325 | }
326 | }
327 |
328 | func (e *executor) singletonModeRunner(name string, in chan jobIn, wg *waitGroupWithMutex, limitMode LimitMode, rescheduleLimiter chan struct{}) {
329 | e.logger.Debug("gocron: singletonModeRunner starting", "name", name)
330 | for {
331 | select {
332 | case jIn := <-in:
333 | select {
334 | case <-e.ctx.Done():
335 | e.logger.Debug("gocron: singletonModeRunner shutting down", "name", name)
336 | wg.Done()
337 | return
338 | default:
339 | }
340 |
341 | ctx, cancel := context.WithCancel(e.ctx)
342 | j := requestJobCtx(ctx, jIn.id, e.jobOutRequest)
343 | cancel()
344 | if j != nil {
345 | // need to set shouldSendOut = false here, as there is a duplicative call to sendOutForRescheduling
346 | // inside the runJob function that needs to be skipped. sendOutForRescheduling is previously called
347 | // when the job is sent to the singleton mode runner.
348 | jIn.shouldSendOut = false
349 | e.runJob(*j, jIn)
350 | }
351 |
352 | // remove the limiter block to allow another job to be scheduled
353 | if limitMode == LimitModeReschedule {
354 | <-rescheduleLimiter
355 | }
356 | case <-e.ctx.Done():
357 | e.logger.Debug("singletonModeRunner shutting down", "name", name)
358 | wg.Done()
359 | return
360 | }
361 | }
362 | }
363 |
364 | func (e *executor) runJob(j internalJob, jIn jobIn) {
365 | if j.ctx == nil {
366 | return
367 | }
368 | select {
369 | case <-e.ctx.Done():
370 | return
371 | case <-j.ctx.Done():
372 | return
373 | default:
374 | }
375 |
376 | if j.stopTimeReached(e.clock.Now()) {
377 | return
378 | }
379 |
380 | if e.elector != nil {
381 | if err := e.elector.IsLeader(j.ctx); err != nil {
382 | e.sendOutForRescheduling(&jIn)
383 | e.incrementJobCounter(j, Skip)
384 | return
385 | }
386 | } else if !j.disabledLocker && j.locker != nil {
387 | lock, err := j.locker.Lock(j.ctx, j.name)
388 | if err != nil {
389 | _ = callJobFuncWithParams(j.afterLockError, j.id, j.name, err)
390 | e.sendOutForRescheduling(&jIn)
391 | e.incrementJobCounter(j, Skip)
392 | e.sendOutForNextRunUpdate(&jIn)
393 | return
394 | }
395 | defer func() { _ = lock.Unlock(j.ctx) }()
396 | } else if !j.disabledLocker && e.locker != nil {
397 | lock, err := e.locker.Lock(j.ctx, j.name)
398 | if err != nil {
399 | _ = callJobFuncWithParams(j.afterLockError, j.id, j.name, err)
400 | e.sendOutForRescheduling(&jIn)
401 | e.incrementJobCounter(j, Skip)
402 | e.sendOutForNextRunUpdate(&jIn)
403 | return
404 | }
405 | defer func() { _ = lock.Unlock(j.ctx) }()
406 | }
407 |
408 | _ = callJobFuncWithParams(j.beforeJobRuns, j.id, j.name)
409 |
410 | err := callJobFuncWithParams(j.beforeJobRunsSkipIfBeforeFuncErrors, j.id, j.name)
411 | if err != nil {
412 | e.sendOutForRescheduling(&jIn)
413 |
414 | select {
415 | case e.jobsOutCompleted <- j.id:
416 | case <-e.ctx.Done():
417 | }
418 |
419 | return
420 | }
421 |
422 | e.sendOutForRescheduling(&jIn)
423 | select {
424 | case e.jobsOutCompleted <- j.id:
425 | case <-e.ctx.Done():
426 | }
427 |
428 | startTime := time.Now()
429 | if j.afterJobRunsWithPanic != nil {
430 | err = e.callJobWithRecover(j)
431 | } else {
432 | err = callJobFuncWithParams(j.function, j.parameters...)
433 | }
434 | e.recordJobTiming(startTime, time.Now(), j)
435 | if err != nil {
436 | _ = callJobFuncWithParams(j.afterJobRunsWithError, j.id, j.name, err)
437 | e.incrementJobCounter(j, Fail)
438 | e.recordJobTimingWithStatus(startTime, time.Now(), j, Fail, err)
439 | } else {
440 | _ = callJobFuncWithParams(j.afterJobRuns, j.id, j.name)
441 | e.incrementJobCounter(j, Success)
442 | e.recordJobTimingWithStatus(startTime, time.Now(), j, Success, nil)
443 | }
444 | }
445 |
446 | func (e *executor) callJobWithRecover(j internalJob) (err error) {
447 | defer func() {
448 | if recoverData := recover(); recoverData != nil {
449 | _ = callJobFuncWithParams(j.afterJobRunsWithPanic, j.id, j.name, recoverData)
450 |
451 | // if panic is occurred, we should return an error
452 | err = fmt.Errorf("%w from %v", ErrPanicRecovered, recoverData)
453 | }
454 | }()
455 |
456 | return callJobFuncWithParams(j.function, j.parameters...)
457 | }
458 |
459 | func (e *executor) recordJobTiming(start time.Time, end time.Time, j internalJob) {
460 | if e.monitor != nil {
461 | e.monitor.RecordJobTiming(start, end, j.id, j.name, j.tags)
462 | }
463 | }
464 |
465 | func (e *executor) recordJobTimingWithStatus(start time.Time, end time.Time, j internalJob, status JobStatus, err error) {
466 | if e.monitorStatus != nil {
467 | e.monitorStatus.RecordJobTimingWithStatus(start, end, j.id, j.name, j.tags, status, err)
468 | }
469 | }
470 |
471 | func (e *executor) incrementJobCounter(j internalJob, status JobStatus) {
472 | if e.monitor != nil {
473 | e.monitor.IncrementJob(j.id, j.name, j.tags, status)
474 | }
475 | }
476 |
477 | func (e *executor) stop(standardJobsWg, singletonJobsWg, limitModeJobsWg *waitGroupWithMutex) {
478 | e.stopOnce.Do(func() {
479 | e.logger.Debug("gocron: stopping executor")
480 | // we've been asked to stop. This is either because the scheduler has been told
481 | // to stop all jobs or the scheduler has been asked to completely shutdown.
482 | //
483 | // cancel tells all the functions to stop their work and send in a done response
484 | e.cancel()
485 |
486 | // the wait for job channels are used to report back whether we successfully waited
487 | // for all jobs to complete or if we hit the configured timeout.
488 | waitForJobs := make(chan struct{}, 1)
489 | waitForSingletons := make(chan struct{}, 1)
490 | waitForLimitMode := make(chan struct{}, 1)
491 |
492 | // the waiter context is used to cancel the functions waiting on jobs.
493 | // this is done to avoid goroutine leaks.
494 | waiterCtx, waiterCancel := context.WithCancel(context.Background())
495 |
496 | // wait for standard jobs to complete
497 | go func() {
498 | e.logger.Debug("gocron: waiting for standard jobs to complete")
499 | go func() {
500 | // this is done in a separate goroutine, so we aren't
501 | // blocked by the WaitGroup's Wait call in the event
502 | // that the waiter context is cancelled.
503 | // This particular goroutine could leak in the event that
504 | // some long-running standard job doesn't complete.
505 | standardJobsWg.Wait()
506 | e.logger.Debug("gocron: standard jobs completed")
507 | waitForJobs <- struct{}{}
508 | }()
509 | <-waiterCtx.Done()
510 | }()
511 |
512 | // wait for per job singleton limit mode runner jobs to complete
513 | go func() {
514 | e.logger.Debug("gocron: waiting for singleton jobs to complete")
515 | go func() {
516 | singletonJobsWg.Wait()
517 | e.logger.Debug("gocron: singleton jobs completed")
518 | waitForSingletons <- struct{}{}
519 | }()
520 | <-waiterCtx.Done()
521 | }()
522 |
523 | // wait for limit mode runners to complete
524 | go func() {
525 | e.logger.Debug("gocron: waiting for limit mode jobs to complete")
526 | go func() {
527 | limitModeJobsWg.Wait()
528 | e.logger.Debug("gocron: limitMode jobs completed")
529 | waitForLimitMode <- struct{}{}
530 | }()
531 | <-waiterCtx.Done()
532 | }()
533 |
534 | // now either wait for all the jobs to complete,
535 | // or hit the timeout.
536 | var count int
537 | timeout := time.Now().Add(e.stopTimeout)
538 | for time.Now().Before(timeout) && count < 3 {
539 | select {
540 | case <-waitForJobs:
541 | count++
542 | case <-waitForSingletons:
543 | count++
544 | case <-waitForLimitMode:
545 | count++
546 | default:
547 | }
548 | }
549 | if count < 3 {
550 | e.done <- ErrStopJobsTimedOut
551 | e.logger.Debug("gocron: executor stopped - timed out")
552 | } else {
553 | e.done <- nil
554 | e.logger.Debug("gocron: executor stopped")
555 | }
556 | waiterCancel()
557 |
558 | if e.limitMode != nil {
559 | e.limitMode.started = false
560 | }
561 | })
562 | }
563 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/go-co-op/gocron/v2
2 |
3 | go 1.23.0
4 |
5 | require (
6 | github.com/google/uuid v1.6.0
7 | github.com/jonboulle/clockwork v0.5.0
8 | github.com/robfig/cron/v3 v3.0.1
9 | github.com/stretchr/testify v1.10.0
10 | go.uber.org/goleak v1.3.0
11 | )
12 |
13 | require (
14 | github.com/davecgh/go-spew v1.1.1 // indirect
15 | github.com/kr/text v0.2.0 // indirect
16 | github.com/pmezard/go-difflib v1.0.0 // indirect
17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
18 | gopkg.in/yaml.v3 v3.0.1 // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
5 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
6 | github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
7 | github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
8 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
9 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
10 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
11 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
12 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
13 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
16 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
17 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
18 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
19 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
20 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
21 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
23 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
24 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
27 |
--------------------------------------------------------------------------------
/job.go:
--------------------------------------------------------------------------------
1 | //go:generate mockgen -destination=mocks/job.go -package=gocronmocks . Job
2 | package gocron
3 |
4 | import (
5 | "context"
6 | "errors"
7 | "fmt"
8 | "math/rand"
9 | "slices"
10 | "strings"
11 | "time"
12 |
13 | "github.com/google/uuid"
14 | "github.com/jonboulle/clockwork"
15 | "github.com/robfig/cron/v3"
16 | )
17 |
18 | // internalJob stores the information needed by the scheduler
19 | // to manage scheduling, starting and stopping the job
20 | type internalJob struct {
21 | ctx context.Context
22 | parentCtx context.Context
23 | cancel context.CancelFunc
24 | id uuid.UUID
25 | name string
26 | tags []string
27 | cron Cron
28 | jobSchedule
29 |
30 | // as some jobs may queue up, it's possible to
31 | // have multiple nextScheduled times
32 | nextScheduled []time.Time
33 |
34 | lastRun time.Time
35 | function any
36 | parameters []any
37 | timer clockwork.Timer
38 | singletonMode bool
39 | singletonLimitMode LimitMode
40 | limitRunsTo *limitRunsTo
41 | startTime time.Time
42 | startImmediately bool
43 | stopTime time.Time
44 | // event listeners
45 | afterJobRuns func(jobID uuid.UUID, jobName string)
46 | beforeJobRuns func(jobID uuid.UUID, jobName string)
47 | beforeJobRunsSkipIfBeforeFuncErrors func(jobID uuid.UUID, jobName string) error
48 | afterJobRunsWithError func(jobID uuid.UUID, jobName string, err error)
49 | afterJobRunsWithPanic func(jobID uuid.UUID, jobName string, recoverData any)
50 | afterLockError func(jobID uuid.UUID, jobName string, err error)
51 | disabledLocker bool
52 |
53 | locker Locker
54 | }
55 |
56 | // stop is used to stop the job's timer and cancel the context
57 | // stopping the timer is critical for cleaning up jobs that are
58 | // sleeping in a time.AfterFunc timer when the job is being stopped.
59 | // cancelling the context keeps the executor from continuing to try
60 | // and run the job.
61 | func (j *internalJob) stop() {
62 | if j.timer != nil {
63 | j.timer.Stop()
64 | }
65 | j.cancel()
66 | }
67 |
68 | func (j *internalJob) stopTimeReached(now time.Time) bool {
69 | if j.stopTime.IsZero() {
70 | return false
71 | }
72 | return j.stopTime.Before(now)
73 | }
74 |
75 | // task stores the function and parameters
76 | // that are actually run when the job is executed.
77 | type task struct {
78 | function any
79 | parameters []any
80 | }
81 |
82 | // Task defines a function that returns the task
83 | // function and parameters.
84 | type Task func() task
85 |
86 | // NewTask provides the job's task function and parameters.
87 | // If you set the first argument of your Task func to be a context.Context,
88 | // gocron will pass in a context (either the default Job context, or one
89 | // provided via WithContext) to the job and will cancel the context on shutdown.
90 | // This allows you to listen for and handle cancellation within your job.
91 | func NewTask(function any, parameters ...any) Task {
92 | return func() task {
93 | return task{
94 | function: function,
95 | parameters: parameters,
96 | }
97 | }
98 | }
99 |
100 | // limitRunsTo is used for managing the number of runs
101 | // when the user only wants the job to run a certain
102 | // number of times and then be removed from the scheduler.
103 | type limitRunsTo struct {
104 | limit uint
105 | runCount uint
106 | }
107 |
108 | // -----------------------------------------------
109 | // -----------------------------------------------
110 | // --------------- Custom Cron -------------------
111 | // -----------------------------------------------
112 | // -----------------------------------------------
113 |
114 | // Cron defines the interface that must be
115 | // implemented to provide a custom cron implementation for
116 | // the job. Pass in the implementation using the JobOption WithCronImplementation.
117 | type Cron interface {
118 | IsValid(crontab string, location *time.Location, now time.Time) error
119 | Next(lastRun time.Time) time.Time
120 | }
121 |
122 | // -----------------------------------------------
123 | // -----------------------------------------------
124 | // --------------- Job Variants ------------------
125 | // -----------------------------------------------
126 | // -----------------------------------------------
127 |
128 | // JobDefinition defines the interface that must be
129 | // implemented to create a job from the definition.
130 | type JobDefinition interface {
131 | setup(j *internalJob, l *time.Location, now time.Time) error
132 | }
133 |
134 | // Default cron implementation
135 |
136 | func newDefaultCronImplementation(withSeconds bool) Cron {
137 | return &defaultCron{
138 | withSeconds: withSeconds,
139 | }
140 | }
141 |
142 | var _ Cron = (*defaultCron)(nil)
143 |
144 | type defaultCron struct {
145 | cronSchedule cron.Schedule
146 | withSeconds bool
147 | }
148 |
149 | func (c *defaultCron) IsValid(crontab string, location *time.Location, now time.Time) error {
150 | var withLocation string
151 | if strings.HasPrefix(crontab, "TZ=") || strings.HasPrefix(crontab, "CRON_TZ=") {
152 | withLocation = crontab
153 | } else {
154 | // since the user didn't provide a timezone default to the location
155 | // passed in by the scheduler. Default: time.Local
156 | withLocation = fmt.Sprintf("CRON_TZ=%s %s", location.String(), crontab)
157 | }
158 |
159 | var (
160 | cronSchedule cron.Schedule
161 | err error
162 | )
163 |
164 | if c.withSeconds {
165 | p := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
166 | cronSchedule, err = p.Parse(withLocation)
167 | } else {
168 | cronSchedule, err = cron.ParseStandard(withLocation)
169 | }
170 | if err != nil {
171 | return errors.Join(ErrCronJobParse, err)
172 | }
173 | if cronSchedule.Next(now).IsZero() {
174 | return ErrCronJobInvalid
175 | }
176 | c.cronSchedule = cronSchedule
177 | return nil
178 | }
179 |
180 | func (c *defaultCron) Next(lastRun time.Time) time.Time {
181 | return c.cronSchedule.Next(lastRun)
182 | }
183 |
184 | // default cron job implementation
185 | var _ JobDefinition = (*cronJobDefinition)(nil)
186 |
187 | type cronJobDefinition struct {
188 | crontab string
189 | cron Cron
190 | }
191 |
192 | func (c cronJobDefinition) setup(j *internalJob, location *time.Location, now time.Time) error {
193 | if j.cron != nil {
194 | c.cron = j.cron
195 | }
196 |
197 | if err := c.cron.IsValid(c.crontab, location, now); err != nil {
198 | return err
199 | }
200 |
201 | j.jobSchedule = &cronJob{crontab: c.crontab, cronSchedule: c.cron}
202 | return nil
203 | }
204 |
205 | // CronJob defines a new job using the crontab syntax: `* * * * *`.
206 | // An optional 6th field can be used at the beginning if withSeconds
207 | // is set to true: `* * * * * *`.
208 | // The timezone can be set on the Scheduler using WithLocation, or in the
209 | // crontab in the form `TZ=America/Chicago * * * * *` or
210 | // `CRON_TZ=America/Chicago * * * * *`
211 | func CronJob(crontab string, withSeconds bool) JobDefinition {
212 | return cronJobDefinition{
213 | crontab: crontab,
214 | cron: newDefaultCronImplementation(withSeconds),
215 | }
216 | }
217 |
218 | var _ JobDefinition = (*durationJobDefinition)(nil)
219 |
220 | type durationJobDefinition struct {
221 | duration time.Duration
222 | }
223 |
224 | func (d durationJobDefinition) setup(j *internalJob, _ *time.Location, _ time.Time) error {
225 | if d.duration == 0 {
226 | return ErrDurationJobIntervalZero
227 | }
228 | j.jobSchedule = &durationJob{duration: d.duration}
229 | return nil
230 | }
231 |
232 | // DurationJob defines a new job using time.Duration
233 | // for the interval.
234 | func DurationJob(duration time.Duration) JobDefinition {
235 | return durationJobDefinition{
236 | duration: duration,
237 | }
238 | }
239 |
240 | var _ JobDefinition = (*durationRandomJobDefinition)(nil)
241 |
242 | type durationRandomJobDefinition struct {
243 | min, max time.Duration
244 | }
245 |
246 | func (d durationRandomJobDefinition) setup(j *internalJob, _ *time.Location, _ time.Time) error {
247 | if d.min >= d.max {
248 | return ErrDurationRandomJobMinMax
249 | }
250 |
251 | j.jobSchedule = &durationRandomJob{
252 | min: d.min,
253 | max: d.max,
254 | rand: rand.New(rand.NewSource(time.Now().UnixNano())), // nolint:gosec
255 | }
256 | return nil
257 | }
258 |
259 | // DurationRandomJob defines a new job that runs on a random interval
260 | // between the min and max duration values provided.
261 | //
262 | // To achieve a similar behavior as tools that use a splay/jitter technique
263 | // consider the median value as the baseline and the difference between the
264 | // max-median or median-min as the splay/jitter.
265 | //
266 | // For example, if you want a job to run every 5 minutes, but want to add
267 | // up to 1 min of jitter to the interval, you could use
268 | // DurationRandomJob(4*time.Minute, 6*time.Minute)
269 | func DurationRandomJob(minDuration, maxDuration time.Duration) JobDefinition {
270 | return durationRandomJobDefinition{
271 | min: minDuration,
272 | max: maxDuration,
273 | }
274 | }
275 |
276 | // DailyJob runs the job on the interval of days, and at the set times.
277 | // By default, the job will start the next available day, considering the last run to be now,
278 | // and the time and day based on the interval and times you input. This means, if you
279 | // select an interval greater than 1, your job by default will run X (interval) days from now
280 | // if there are no atTimes left in the current day. You can use WithStartAt to tell the
281 | // scheduler to start the job sooner.
282 | func DailyJob(interval uint, atTimes AtTimes) JobDefinition {
283 | return dailyJobDefinition{
284 | interval: interval,
285 | atTimes: atTimes,
286 | }
287 | }
288 |
289 | var _ JobDefinition = (*dailyJobDefinition)(nil)
290 |
291 | type dailyJobDefinition struct {
292 | interval uint
293 | atTimes AtTimes
294 | }
295 |
296 | func (d dailyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error {
297 | atTimesDate, err := convertAtTimesToDateTime(d.atTimes, location)
298 | switch {
299 | case errors.Is(err, errAtTimesNil):
300 | return ErrDailyJobAtTimesNil
301 | case errors.Is(err, errAtTimeNil):
302 | return ErrDailyJobAtTimeNil
303 | case errors.Is(err, errAtTimeHours):
304 | return ErrDailyJobHours
305 | case errors.Is(err, errAtTimeMinSec):
306 | return ErrDailyJobMinutesSeconds
307 | }
308 |
309 | if d.interval == 0 {
310 | return ErrDailyJobZeroInterval
311 | }
312 |
313 | ds := dailyJob{
314 | interval: d.interval,
315 | atTimes: atTimesDate,
316 | }
317 | j.jobSchedule = ds
318 | return nil
319 | }
320 |
321 | var _ JobDefinition = (*weeklyJobDefinition)(nil)
322 |
323 | type weeklyJobDefinition struct {
324 | interval uint
325 | daysOfTheWeek Weekdays
326 | atTimes AtTimes
327 | }
328 |
329 | func (w weeklyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error {
330 | var ws weeklyJob
331 | if w.interval == 0 {
332 | return ErrWeeklyJobZeroInterval
333 | }
334 | ws.interval = w.interval
335 |
336 | if w.daysOfTheWeek == nil {
337 | return ErrWeeklyJobDaysOfTheWeekNil
338 | }
339 |
340 | daysOfTheWeek := w.daysOfTheWeek()
341 |
342 | slices.Sort(daysOfTheWeek)
343 | ws.daysOfWeek = daysOfTheWeek
344 |
345 | atTimesDate, err := convertAtTimesToDateTime(w.atTimes, location)
346 | switch {
347 | case errors.Is(err, errAtTimesNil):
348 | return ErrWeeklyJobAtTimesNil
349 | case errors.Is(err, errAtTimeNil):
350 | return ErrWeeklyJobAtTimeNil
351 | case errors.Is(err, errAtTimeHours):
352 | return ErrWeeklyJobHours
353 | case errors.Is(err, errAtTimeMinSec):
354 | return ErrWeeklyJobMinutesSeconds
355 | }
356 | ws.atTimes = atTimesDate
357 |
358 | j.jobSchedule = ws
359 | return nil
360 | }
361 |
362 | // Weekdays defines a function that returns a list of week days.
363 | type Weekdays func() []time.Weekday
364 |
365 | // NewWeekdays provide the days of the week the job should run.
366 | func NewWeekdays(weekday time.Weekday, weekdays ...time.Weekday) Weekdays {
367 | return func() []time.Weekday {
368 | return append([]time.Weekday{weekday}, weekdays...)
369 | }
370 | }
371 |
372 | // WeeklyJob runs the job on the interval of weeks, on the specific days of the week
373 | // specified, and at the set times.
374 | //
375 | // By default, the job will start the next available day, considering the last run to be now,
376 | // and the time and day based on the interval, days and times you input. This means, if you
377 | // select an interval greater than 1, your job by default will run X (interval) weeks from now
378 | // if there are no daysOfTheWeek left in the current week. You can use WithStartAt to tell the
379 | // scheduler to start the job sooner.
380 | func WeeklyJob(interval uint, daysOfTheWeek Weekdays, atTimes AtTimes) JobDefinition {
381 | return weeklyJobDefinition{
382 | interval: interval,
383 | daysOfTheWeek: daysOfTheWeek,
384 | atTimes: atTimes,
385 | }
386 | }
387 |
388 | var _ JobDefinition = (*monthlyJobDefinition)(nil)
389 |
390 | type monthlyJobDefinition struct {
391 | interval uint
392 | daysOfTheMonth DaysOfTheMonth
393 | atTimes AtTimes
394 | }
395 |
396 | func (m monthlyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error {
397 | var ms monthlyJob
398 | if m.interval == 0 {
399 | return ErrMonthlyJobZeroInterval
400 | }
401 | ms.interval = m.interval
402 |
403 | if m.daysOfTheMonth == nil {
404 | return ErrMonthlyJobDaysNil
405 | }
406 |
407 | var daysStart, daysEnd []int
408 | for _, day := range m.daysOfTheMonth() {
409 | if day > 31 || day == 0 || day < -31 {
410 | return ErrMonthlyJobDays
411 | }
412 | if day > 0 {
413 | daysStart = append(daysStart, day)
414 | } else {
415 | daysEnd = append(daysEnd, day)
416 | }
417 | }
418 | daysStart = removeSliceDuplicatesInt(daysStart)
419 | ms.days = daysStart
420 |
421 | daysEnd = removeSliceDuplicatesInt(daysEnd)
422 | ms.daysFromEnd = daysEnd
423 |
424 | atTimesDate, err := convertAtTimesToDateTime(m.atTimes, location)
425 | switch {
426 | case errors.Is(err, errAtTimesNil):
427 | return ErrMonthlyJobAtTimesNil
428 | case errors.Is(err, errAtTimeNil):
429 | return ErrMonthlyJobAtTimeNil
430 | case errors.Is(err, errAtTimeHours):
431 | return ErrMonthlyJobHours
432 | case errors.Is(err, errAtTimeMinSec):
433 | return ErrMonthlyJobMinutesSeconds
434 | }
435 | ms.atTimes = atTimesDate
436 |
437 | j.jobSchedule = ms
438 | return nil
439 | }
440 |
441 | type days []int
442 |
443 | // DaysOfTheMonth defines a function that returns a list of days.
444 | type DaysOfTheMonth func() days
445 |
446 | // NewDaysOfTheMonth provide the days of the month the job should
447 | // run. The days can be positive 1 to 31 and/or negative -31 to -1.
448 | // Negative values count backwards from the end of the month.
449 | // For example: -1 == the last day of the month.
450 | //
451 | // -5 == 5 days before the end of the month.
452 | func NewDaysOfTheMonth(day int, moreDays ...int) DaysOfTheMonth {
453 | return func() days {
454 | return append([]int{day}, moreDays...)
455 | }
456 | }
457 |
458 | type atTime struct {
459 | hours, minutes, seconds uint
460 | }
461 |
462 | func (a atTime) time(location *time.Location) time.Time {
463 | return time.Date(0, 0, 0, int(a.hours), int(a.minutes), int(a.seconds), 0, location)
464 | }
465 |
466 | // TimeFromAtTime is a helper function to allow converting AtTime into a time.Time value
467 | // Note: the time.Time value will have zero values for all Time fields except Hours, Minutes, Seconds.
468 | //
469 | // For example: time.Date(0, 0, 0, 1, 1, 1, 0, time.UTC)
470 | func TimeFromAtTime(at AtTime, loc *time.Location) time.Time {
471 | return at().time(loc)
472 | }
473 |
474 | // AtTime defines a function that returns the internal atTime
475 | type AtTime func() atTime
476 |
477 | // NewAtTime provide the hours, minutes and seconds at which
478 | // the job should be run
479 | func NewAtTime(hours, minutes, seconds uint) AtTime {
480 | return func() atTime {
481 | return atTime{hours: hours, minutes: minutes, seconds: seconds}
482 | }
483 | }
484 |
485 | // AtTimes define a list of AtTime
486 | type AtTimes func() []AtTime
487 |
488 | // NewAtTimes provide the hours, minutes and seconds at which
489 | // the job should be run
490 | func NewAtTimes(atTime AtTime, atTimes ...AtTime) AtTimes {
491 | return func() []AtTime {
492 | return append([]AtTime{atTime}, atTimes...)
493 | }
494 | }
495 |
496 | // MonthlyJob runs the job on the interval of months, on the specific days of the month
497 | // specified, and at the set times. Days of the month can be 1 to 31 or negative (-1 to -31), which
498 | // count backwards from the end of the month. E.g. -1 is the last day of the month.
499 | //
500 | // If a day of the month is selected that does not exist in all months (e.g. 31st)
501 | // any month that does not have that day will be skipped.
502 | //
503 | // By default, the job will start the next available day, considering the last run to be now,
504 | // and the time and month based on the interval, days and times you input.
505 | // This means, if you select an interval greater than 1, your job by default will run
506 | // X (interval) months from now if there are no daysOfTheMonth left in the current month.
507 | // You can use WithStartAt to tell the scheduler to start the job sooner.
508 | //
509 | // Carefully consider your configuration!
510 | // - For example: an interval of 2 months on the 31st of each month, starting 12/31
511 | // would skip Feb, April, June, and next run would be in August.
512 | func MonthlyJob(interval uint, daysOfTheMonth DaysOfTheMonth, atTimes AtTimes) JobDefinition {
513 | return monthlyJobDefinition{
514 | interval: interval,
515 | daysOfTheMonth: daysOfTheMonth,
516 | atTimes: atTimes,
517 | }
518 | }
519 |
520 | var _ JobDefinition = (*oneTimeJobDefinition)(nil)
521 |
522 | type oneTimeJobDefinition struct {
523 | startAt OneTimeJobStartAtOption
524 | }
525 |
526 | func (o oneTimeJobDefinition) setup(j *internalJob, _ *time.Location, now time.Time) error {
527 | sortedTimes := o.startAt(j)
528 | slices.SortStableFunc(sortedTimes, ascendingTime)
529 | // deduplicate the times
530 | sortedTimes = removeSliceDuplicatesTimeOnSortedSlice(sortedTimes)
531 | // keep only schedules that are in the future
532 | idx, found := slices.BinarySearchFunc(sortedTimes, now, ascendingTime)
533 | if found {
534 | idx++
535 | }
536 | sortedTimes = sortedTimes[idx:]
537 | if !j.startImmediately && len(sortedTimes) == 0 {
538 | return ErrOneTimeJobStartDateTimePast
539 | }
540 | j.jobSchedule = oneTimeJob{sortedTimes: sortedTimes}
541 | return nil
542 | }
543 |
544 | func removeSliceDuplicatesTimeOnSortedSlice(times []time.Time) []time.Time {
545 | ret := make([]time.Time, 0, len(times))
546 | for i, t := range times {
547 | if i == 0 || t != times[i-1] {
548 | ret = append(ret, t)
549 | }
550 | }
551 | return ret
552 | }
553 |
554 | // OneTimeJobStartAtOption defines when the one time job is run
555 | type OneTimeJobStartAtOption func(*internalJob) []time.Time
556 |
557 | // OneTimeJobStartImmediately tells the scheduler to run the one time job immediately.
558 | func OneTimeJobStartImmediately() OneTimeJobStartAtOption {
559 | return func(j *internalJob) []time.Time {
560 | j.startImmediately = true
561 | return []time.Time{}
562 | }
563 | }
564 |
565 | // OneTimeJobStartDateTime sets the date & time at which the job should run.
566 | // This datetime must be in the future (according to the scheduler clock).
567 | func OneTimeJobStartDateTime(start time.Time) OneTimeJobStartAtOption {
568 | return func(_ *internalJob) []time.Time {
569 | return []time.Time{start}
570 | }
571 | }
572 |
573 | // OneTimeJobStartDateTimes sets the date & times at which the job should run.
574 | // At least one of the date/times must be in the future (according to the scheduler clock).
575 | func OneTimeJobStartDateTimes(times ...time.Time) OneTimeJobStartAtOption {
576 | return func(_ *internalJob) []time.Time {
577 | return times
578 | }
579 | }
580 |
581 | // OneTimeJob is to run a job once at a specified time and not on
582 | // any regular schedule.
583 | func OneTimeJob(startAt OneTimeJobStartAtOption) JobDefinition {
584 | return oneTimeJobDefinition{
585 | startAt: startAt,
586 | }
587 | }
588 |
589 | // -----------------------------------------------
590 | // -----------------------------------------------
591 | // ----------------- Job Options -----------------
592 | // -----------------------------------------------
593 | // -----------------------------------------------
594 |
595 | // JobOption defines the constructor for job options.
596 | type JobOption func(*internalJob, time.Time) error
597 |
598 | // WithDistributedJobLocker sets the locker to be used by multiple
599 | // Scheduler instances to ensure that only one instance of each
600 | // job is run.
601 | func WithDistributedJobLocker(locker Locker) JobOption {
602 | return func(j *internalJob, _ time.Time) error {
603 | if locker == nil {
604 | return ErrWithDistributedJobLockerNil
605 | }
606 | j.locker = locker
607 | return nil
608 | }
609 | }
610 |
611 | // WithDisabledDistributedJobLocker disables the distributed job locker.
612 | // This is useful when a global distributed locker has been set on the scheduler
613 | // level using WithDistributedLocker and need to be disabled for specific jobs.
614 | func WithDisabledDistributedJobLocker(disabled bool) JobOption {
615 | return func(j *internalJob, _ time.Time) error {
616 | j.disabledLocker = disabled
617 | return nil
618 | }
619 | }
620 |
621 | // WithEventListeners sets the event listeners that should be
622 | // run for the job.
623 | func WithEventListeners(eventListeners ...EventListener) JobOption {
624 | return func(j *internalJob, _ time.Time) error {
625 | for _, eventListener := range eventListeners {
626 | if err := eventListener(j); err != nil {
627 | return err
628 | }
629 | }
630 | return nil
631 | }
632 | }
633 |
634 | // WithLimitedRuns limits the number of executions of this job to n.
635 | // Upon reaching the limit, the job is removed from the scheduler.
636 | func WithLimitedRuns(limit uint) JobOption {
637 | return func(j *internalJob, _ time.Time) error {
638 | j.limitRunsTo = &limitRunsTo{
639 | limit: limit,
640 | runCount: 0,
641 | }
642 | return nil
643 | }
644 | }
645 |
646 | // WithName sets the name of the job. Name provides
647 | // a human-readable identifier for the job.
648 | func WithName(name string) JobOption {
649 | return func(j *internalJob, _ time.Time) error {
650 | if name == "" {
651 | return ErrWithNameEmpty
652 | }
653 | j.name = name
654 | return nil
655 | }
656 | }
657 |
658 | // WithCronImplementation sets the custom Cron implementation for the job.
659 | // This is only utilized for the CronJob type.
660 | func WithCronImplementation(c Cron) JobOption {
661 | return func(j *internalJob, _ time.Time) error {
662 | j.cron = c
663 | return nil
664 | }
665 | }
666 |
667 | // WithSingletonMode keeps the job from running again if it is already running.
668 | // This is useful for jobs that should not overlap, and that occasionally
669 | // (but not consistently) run longer than the interval between job runs.
670 | func WithSingletonMode(mode LimitMode) JobOption {
671 | return func(j *internalJob, _ time.Time) error {
672 | j.singletonMode = true
673 | j.singletonLimitMode = mode
674 | return nil
675 | }
676 | }
677 |
678 | // WithStartAt sets the option for starting the job at
679 | // a specific datetime.
680 | func WithStartAt(option StartAtOption) JobOption {
681 | return func(j *internalJob, now time.Time) error {
682 | return option(j, now)
683 | }
684 | }
685 |
686 | // StartAtOption defines options for starting the job
687 | type StartAtOption func(*internalJob, time.Time) error
688 |
689 | // WithStartImmediately tells the scheduler to run the job immediately
690 | // regardless of the type or schedule of job. After this immediate run
691 | // the job is scheduled from this time based on the job definition.
692 | func WithStartImmediately() StartAtOption {
693 | return func(j *internalJob, _ time.Time) error {
694 | j.startImmediately = true
695 | return nil
696 | }
697 | }
698 |
699 | // WithStartDateTime sets the first date & time at which the job should run.
700 | // This datetime must be in the future.
701 | func WithStartDateTime(start time.Time) StartAtOption {
702 | return func(j *internalJob, now time.Time) error {
703 | if start.IsZero() || start.Before(now) {
704 | return ErrWithStartDateTimePast
705 | }
706 | if !j.stopTime.IsZero() && j.stopTime.Before(start) {
707 | return ErrStartTimeLaterThanEndTime
708 | }
709 | j.startTime = start
710 | return nil
711 | }
712 | }
713 |
714 | // WithStopAt sets the option for stopping the job from running
715 | // after the specified time.
716 | func WithStopAt(option StopAtOption) JobOption {
717 | return func(j *internalJob, now time.Time) error {
718 | return option(j, now)
719 | }
720 | }
721 |
722 | // StopAtOption defines options for stopping the job
723 | type StopAtOption func(*internalJob, time.Time) error
724 |
725 | // WithStopDateTime sets the final date & time after which the job should stop.
726 | // This must be in the future and should be after the startTime (if specified).
727 | // The job's final run may be at the stop time, but not after.
728 | func WithStopDateTime(end time.Time) StopAtOption {
729 | return func(j *internalJob, now time.Time) error {
730 | if end.IsZero() || end.Before(now) {
731 | return ErrWithStopDateTimePast
732 | }
733 | if end.Before(j.startTime) {
734 | return ErrStopTimeEarlierThanStartTime
735 | }
736 | j.stopTime = end
737 | return nil
738 | }
739 | }
740 |
741 | // WithTags sets the tags for the job. Tags provide
742 | // a way to identify jobs by a set of tags and remove
743 | // multiple jobs by tag.
744 | func WithTags(tags ...string) JobOption {
745 | return func(j *internalJob, _ time.Time) error {
746 | j.tags = tags
747 | return nil
748 | }
749 | }
750 |
751 | // WithIdentifier sets the identifier for the job. The identifier
752 | // is used to uniquely identify the job and is used for logging
753 | // and metrics.
754 | func WithIdentifier(id uuid.UUID) JobOption {
755 | return func(j *internalJob, _ time.Time) error {
756 | if id == uuid.Nil {
757 | return ErrWithIdentifierNil
758 | }
759 |
760 | j.id = id
761 | return nil
762 | }
763 | }
764 |
765 | // WithContext sets the parent context for the job.
766 | // If you set the first argument of your Task func to be a context.Context,
767 | // gocron will pass in the provided context to the job and will cancel the
768 | // context on shutdown. If you cancel the context the job will no longer be
769 | // scheduled as well. This allows you to both control the job via a context
770 | // and listen for and handle cancellation within your job.
771 | func WithContext(ctx context.Context) JobOption {
772 | return func(j *internalJob, _ time.Time) error {
773 | if ctx == nil {
774 | return ErrWithContextNil
775 | }
776 | j.parentCtx = ctx
777 | return nil
778 | }
779 | }
780 |
781 | // -----------------------------------------------
782 | // -----------------------------------------------
783 | // ------------- Job Event Listeners -------------
784 | // -----------------------------------------------
785 | // -----------------------------------------------
786 |
787 | // EventListener defines the constructor for event
788 | // listeners that can be used to listen for job events.
789 | type EventListener func(*internalJob) error
790 |
791 | // BeforeJobRuns is used to listen for when a job is about to run and
792 | // then run the provided function.
793 | func BeforeJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) EventListener {
794 | return func(j *internalJob) error {
795 | if eventListenerFunc == nil {
796 | return ErrEventListenerFuncNil
797 | }
798 | j.beforeJobRuns = eventListenerFunc
799 | return nil
800 | }
801 | }
802 |
803 | // BeforeJobRunsSkipIfBeforeFuncErrors is used to listen for when a job is about to run and
804 | // then runs the provided function. If the provided function returns an error, the job will be
805 | // rescheduled and the current run will be skipped.
806 | func BeforeJobRunsSkipIfBeforeFuncErrors(eventListenerFunc func(jobID uuid.UUID, jobName string) error) EventListener {
807 | return func(j *internalJob) error {
808 | if eventListenerFunc == nil {
809 | return ErrEventListenerFuncNil
810 | }
811 | j.beforeJobRunsSkipIfBeforeFuncErrors = eventListenerFunc
812 | return nil
813 | }
814 | }
815 |
816 | // AfterJobRuns is used to listen for when a job has run
817 | // without an error, and then run the provided function.
818 | func AfterJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) EventListener {
819 | return func(j *internalJob) error {
820 | if eventListenerFunc == nil {
821 | return ErrEventListenerFuncNil
822 | }
823 | j.afterJobRuns = eventListenerFunc
824 | return nil
825 | }
826 | }
827 |
828 | // AfterJobRunsWithError is used to listen for when a job has run and
829 | // returned an error, and then run the provided function.
830 | func AfterJobRunsWithError(eventListenerFunc func(jobID uuid.UUID, jobName string, err error)) EventListener {
831 | return func(j *internalJob) error {
832 | if eventListenerFunc == nil {
833 | return ErrEventListenerFuncNil
834 | }
835 | j.afterJobRunsWithError = eventListenerFunc
836 | return nil
837 | }
838 | }
839 |
840 | // AfterJobRunsWithPanic is used to listen for when a job has run and
841 | // returned panicked recover data, and then run the provided function.
842 | func AfterJobRunsWithPanic(eventListenerFunc func(jobID uuid.UUID, jobName string, recoverData any)) EventListener {
843 | return func(j *internalJob) error {
844 | if eventListenerFunc == nil {
845 | return ErrEventListenerFuncNil
846 | }
847 | j.afterJobRunsWithPanic = eventListenerFunc
848 | return nil
849 | }
850 | }
851 |
852 | // AfterLockError is used to when the distributed locker returns an error and
853 | // then run the provided function.
854 | func AfterLockError(eventListenerFunc func(jobID uuid.UUID, jobName string, err error)) EventListener {
855 | return func(j *internalJob) error {
856 | if eventListenerFunc == nil {
857 | return ErrEventListenerFuncNil
858 | }
859 | j.afterLockError = eventListenerFunc
860 | return nil
861 | }
862 | }
863 |
864 | // -----------------------------------------------
865 | // -----------------------------------------------
866 | // ---------------- Job Schedules ----------------
867 | // -----------------------------------------------
868 | // -----------------------------------------------
869 |
870 | type jobSchedule interface {
871 | next(lastRun time.Time) time.Time
872 | }
873 |
874 | var _ jobSchedule = (*cronJob)(nil)
875 |
876 | type cronJob struct {
877 | crontab string
878 | cronSchedule Cron
879 | }
880 |
881 | func (j *cronJob) next(lastRun time.Time) time.Time {
882 | return j.cronSchedule.Next(lastRun)
883 | }
884 |
885 | var _ jobSchedule = (*durationJob)(nil)
886 |
887 | type durationJob struct {
888 | duration time.Duration
889 | }
890 |
891 | func (j *durationJob) next(lastRun time.Time) time.Time {
892 | return lastRun.Add(j.duration)
893 | }
894 |
895 | var _ jobSchedule = (*durationRandomJob)(nil)
896 |
897 | type durationRandomJob struct {
898 | min, max time.Duration
899 | rand *rand.Rand
900 | }
901 |
902 | func (j *durationRandomJob) next(lastRun time.Time) time.Time {
903 | r := j.rand.Int63n(int64(j.max - j.min))
904 | return lastRun.Add(j.min + time.Duration(r))
905 | }
906 |
907 | var _ jobSchedule = (*dailyJob)(nil)
908 |
909 | type dailyJob struct {
910 | interval uint
911 | atTimes []time.Time
912 | }
913 |
914 | func (d dailyJob) next(lastRun time.Time) time.Time {
915 | firstPass := true
916 | next := d.nextDay(lastRun, firstPass)
917 | if !next.IsZero() {
918 | return next
919 | }
920 | firstPass = false
921 |
922 | startNextDay := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day()+int(d.interval), 0, 0, 0, 0, lastRun.Location())
923 | return d.nextDay(startNextDay, firstPass)
924 | }
925 |
926 | func (d dailyJob) nextDay(lastRun time.Time, firstPass bool) time.Time {
927 | for _, at := range d.atTimes {
928 | // sub the at time hour/min/sec onto the lastScheduledRun's values
929 | // to use in checks to see if we've got our next run time
930 | atDate := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day(), at.Hour(), at.Minute(), at.Second(), 0, lastRun.Location())
931 |
932 | if firstPass && atDate.After(lastRun) {
933 | // checking to see if it is after i.e. greater than,
934 | // and not greater or equal as our lastScheduledRun day/time
935 | // will be in the loop, and we don't want to select it again
936 | return atDate
937 | } else if !firstPass && !atDate.Before(lastRun) {
938 | // now that we're looking at the next day, it's ok to consider
939 | // the same at time that was last run (as lastScheduledRun has been incremented)
940 | return atDate
941 | }
942 | }
943 | return time.Time{}
944 | }
945 |
946 | var _ jobSchedule = (*weeklyJob)(nil)
947 |
948 | type weeklyJob struct {
949 | interval uint
950 | daysOfWeek []time.Weekday
951 | atTimes []time.Time
952 | }
953 |
954 | func (w weeklyJob) next(lastRun time.Time) time.Time {
955 | firstPass := true
956 | next := w.nextWeekDayAtTime(lastRun, firstPass)
957 | if !next.IsZero() {
958 | return next
959 | }
960 | firstPass = false
961 |
962 | startOfTheNextIntervalWeek := (lastRun.Day() - int(lastRun.Weekday())) + int(w.interval*7)
963 | from := time.Date(lastRun.Year(), lastRun.Month(), startOfTheNextIntervalWeek, 0, 0, 0, 0, lastRun.Location())
964 | return w.nextWeekDayAtTime(from, firstPass)
965 | }
966 |
967 | func (w weeklyJob) nextWeekDayAtTime(lastRun time.Time, firstPass bool) time.Time {
968 | for _, wd := range w.daysOfWeek {
969 | // checking if we're on the same day or later in the same week
970 | if wd >= lastRun.Weekday() {
971 | // weekDayDiff is used to add the correct amount to the atDate day below
972 | weekDayDiff := wd - lastRun.Weekday()
973 | for _, at := range w.atTimes {
974 | // sub the at time hour/min/sec onto the lastScheduledRun's values
975 | // to use in checks to see if we've got our next run time
976 | atDate := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day()+int(weekDayDiff), at.Hour(), at.Minute(), at.Second(), 0, lastRun.Location())
977 |
978 | if firstPass && atDate.After(lastRun) {
979 | // checking to see if it is after i.e. greater than,
980 | // and not greater or equal as our lastScheduledRun day/time
981 | // will be in the loop, and we don't want to select it again
982 | return atDate
983 | } else if !firstPass && !atDate.Before(lastRun) {
984 | // now that we're looking at the next week, it's ok to consider
985 | // the same at time that was last run (as lastScheduledRun has been incremented)
986 | return atDate
987 | }
988 | }
989 | }
990 | }
991 | return time.Time{}
992 | }
993 |
994 | var _ jobSchedule = (*monthlyJob)(nil)
995 |
996 | type monthlyJob struct {
997 | interval uint
998 | days []int
999 | daysFromEnd []int
1000 | atTimes []time.Time
1001 | }
1002 |
1003 | func (m monthlyJob) next(lastRun time.Time) time.Time {
1004 | daysList := make([]int, len(m.days))
1005 | copy(daysList, m.days)
1006 |
1007 | daysFromEnd := m.handleNegativeDays(lastRun, daysList, m.daysFromEnd)
1008 | next := m.nextMonthDayAtTime(lastRun, daysFromEnd, true)
1009 | if !next.IsZero() {
1010 | return next
1011 | }
1012 |
1013 | from := time.Date(lastRun.Year(), lastRun.Month()+time.Month(m.interval), 1, 0, 0, 0, 0, lastRun.Location())
1014 | for next.IsZero() {
1015 | daysFromEnd = m.handleNegativeDays(from, daysList, m.daysFromEnd)
1016 | next = m.nextMonthDayAtTime(from, daysFromEnd, false)
1017 | from = from.AddDate(0, int(m.interval), 0)
1018 | }
1019 |
1020 | return next
1021 | }
1022 |
1023 | func (m monthlyJob) handleNegativeDays(from time.Time, days, negativeDays []int) []int {
1024 | var out []int
1025 | // getting a list of the days from the end of the following month
1026 | // -1 == the last day of the month
1027 | firstDayNextMonth := time.Date(from.Year(), from.Month()+1, 1, 0, 0, 0, 0, from.Location())
1028 | for _, daySub := range negativeDays {
1029 | day := firstDayNextMonth.AddDate(0, 0, daySub).Day()
1030 | out = append(out, day)
1031 | }
1032 | out = append(out, days...)
1033 | slices.Sort(out)
1034 | return out
1035 | }
1036 |
1037 | func (m monthlyJob) nextMonthDayAtTime(lastRun time.Time, days []int, firstPass bool) time.Time {
1038 | // find the next day in the month that should run and then check for an at time
1039 | for _, day := range days {
1040 | if day >= lastRun.Day() {
1041 | for _, at := range m.atTimes {
1042 | // sub the day, and the at time hour/min/sec onto the lastScheduledRun's values
1043 | // to use in checks to see if we've got our next run time
1044 | atDate := time.Date(lastRun.Year(), lastRun.Month(), day, at.Hour(), at.Minute(), at.Second(), 0, lastRun.Location())
1045 |
1046 | if atDate.Month() != lastRun.Month() {
1047 | // this check handles if we're setting a day not in the current month
1048 | // e.g. setting day 31 in Feb results in March 2nd
1049 | continue
1050 | }
1051 |
1052 | if firstPass && atDate.After(lastRun) {
1053 | // checking to see if it is after i.e. greater than,
1054 | // and not greater or equal as our lastScheduledRun day/time
1055 | // will be in the loop, and we don't want to select it again
1056 | return atDate
1057 | } else if !firstPass && !atDate.Before(lastRun) {
1058 | // now that we're looking at the next month, it's ok to consider
1059 | // the same at time that was lastScheduledRun (as lastScheduledRun has been incremented)
1060 | return atDate
1061 | }
1062 | }
1063 | continue
1064 | }
1065 | }
1066 | return time.Time{}
1067 | }
1068 |
1069 | var _ jobSchedule = (*oneTimeJob)(nil)
1070 |
1071 | type oneTimeJob struct {
1072 | sortedTimes []time.Time
1073 | }
1074 |
1075 | // next finds the next item in a sorted list of times using binary-search.
1076 | //
1077 | // example: sortedTimes: [2, 4, 6, 8]
1078 | //
1079 | // lastRun: 1 => [idx=0,found=false] => next is 2 - sorted[idx] idx=0
1080 | // lastRun: 2 => [idx=0,found=true] => next is 4 - sorted[idx+1] idx=1
1081 | // lastRun: 3 => [idx=1,found=false] => next is 4 - sorted[idx] idx=1
1082 | // lastRun: 4 => [idx=1,found=true] => next is 6 - sorted[idx+1] idx=2
1083 | // lastRun: 7 => [idx=3,found=false] => next is 8 - sorted[idx] idx=3
1084 | // lastRun: 8 => [idx=3,found=found] => next is none
1085 | // lastRun: 9 => [idx=3,found=found] => next is none
1086 | func (o oneTimeJob) next(lastRun time.Time) time.Time {
1087 | idx, found := slices.BinarySearchFunc(o.sortedTimes, lastRun, ascendingTime)
1088 | // if found, the next run is the following index
1089 | if found {
1090 | idx++
1091 | }
1092 | // exhausted runs
1093 | if idx >= len(o.sortedTimes) {
1094 | return time.Time{}
1095 | }
1096 |
1097 | return o.sortedTimes[idx]
1098 | }
1099 |
1100 | // -----------------------------------------------
1101 | // -----------------------------------------------
1102 | // ---------------- Job Interface ----------------
1103 | // -----------------------------------------------
1104 | // -----------------------------------------------
1105 |
1106 | // Job provides the available methods on the job
1107 | // available to the caller.
1108 | type Job interface {
1109 | // ID returns the job's unique identifier.
1110 | ID() uuid.UUID
1111 | // LastRun returns the time of the job's last run
1112 | LastRun() (time.Time, error)
1113 | // Name returns the name defined on the job.
1114 | Name() string
1115 | // NextRun returns the time of the job's next scheduled run.
1116 | NextRun() (time.Time, error)
1117 | // NextRuns returns the requested number of calculated next run values.
1118 | NextRuns(int) ([]time.Time, error)
1119 | // RunNow runs the job once, now. This does not alter
1120 | // the existing run schedule, and will respect all job
1121 | // and scheduler limits. This means that running a job now may
1122 | // cause the job's regular interval to be rescheduled due to
1123 | // the instance being run by RunNow blocking your run limit.
1124 | RunNow() error
1125 | // Tags returns the job's string tags.
1126 | Tags() []string
1127 | }
1128 |
1129 | var _ Job = (*job)(nil)
1130 |
1131 | // job is the internal struct that implements
1132 | // the public interface. This is used to avoid
1133 | // leaking information the caller never needs
1134 | // to have or tinker with.
1135 | type job struct {
1136 | id uuid.UUID
1137 | name string
1138 | tags []string
1139 | jobOutRequest chan jobOutRequest
1140 | runJobRequest chan runJobRequest
1141 | }
1142 |
1143 | func (j job) ID() uuid.UUID {
1144 | return j.id
1145 | }
1146 |
1147 | func (j job) LastRun() (time.Time, error) {
1148 | ij := requestJob(j.id, j.jobOutRequest)
1149 | if ij == nil || ij.id == uuid.Nil {
1150 | return time.Time{}, ErrJobNotFound
1151 | }
1152 | return ij.lastRun, nil
1153 | }
1154 |
1155 | func (j job) Name() string {
1156 | return j.name
1157 | }
1158 |
1159 | func (j job) NextRun() (time.Time, error) {
1160 | ij := requestJob(j.id, j.jobOutRequest)
1161 | if ij == nil || ij.id == uuid.Nil {
1162 | return time.Time{}, ErrJobNotFound
1163 | }
1164 | if len(ij.nextScheduled) == 0 {
1165 | return time.Time{}, nil
1166 | }
1167 | // the first element is the next scheduled run with subsequent
1168 | // runs following after in the slice
1169 | return ij.nextScheduled[0], nil
1170 | }
1171 |
1172 | func (j job) NextRuns(count int) ([]time.Time, error) {
1173 | ij := requestJob(j.id, j.jobOutRequest)
1174 | if ij == nil || ij.id == uuid.Nil {
1175 | return nil, ErrJobNotFound
1176 | }
1177 |
1178 | lengthNextScheduled := len(ij.nextScheduled)
1179 | if lengthNextScheduled == 0 {
1180 | return nil, nil
1181 | } else if count <= lengthNextScheduled {
1182 | return ij.nextScheduled[:count], nil
1183 | }
1184 |
1185 | out := make([]time.Time, count)
1186 | for i := 0; i < count; i++ {
1187 | if i < lengthNextScheduled {
1188 | out[i] = ij.nextScheduled[i]
1189 | continue
1190 | }
1191 |
1192 | from := out[i-1]
1193 | out[i] = ij.next(from)
1194 | }
1195 |
1196 | return out, nil
1197 | }
1198 |
1199 | func (j job) Tags() []string {
1200 | return j.tags
1201 | }
1202 |
1203 | func (j job) RunNow() error {
1204 | ctx, cancel := context.WithTimeout(context.Background(), time.Second)
1205 | defer cancel()
1206 | resp := make(chan error, 1)
1207 |
1208 | t := time.NewTimer(100 * time.Millisecond)
1209 | select {
1210 | case j.runJobRequest <- runJobRequest{
1211 | id: j.id,
1212 | outChan: resp,
1213 | }:
1214 | t.Stop()
1215 | case <-t.C:
1216 | return ErrJobRunNowFailed
1217 | }
1218 | var err error
1219 | select {
1220 | case <-ctx.Done():
1221 | return ErrJobRunNowFailed
1222 | case errReceived := <-resp:
1223 | err = errReceived
1224 | }
1225 | return err
1226 | }
1227 |
--------------------------------------------------------------------------------
/job_test.go:
--------------------------------------------------------------------------------
1 | package gocron
2 |
3 | import (
4 | "math/rand"
5 | "testing"
6 | "time"
7 |
8 | "github.com/google/uuid"
9 | "github.com/jonboulle/clockwork"
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestDurationJob_next(t *testing.T) {
15 | tests := []time.Duration{
16 | time.Millisecond,
17 | time.Second,
18 | 100 * time.Second,
19 | 1000 * time.Second,
20 | 5 * time.Second,
21 | 50 * time.Second,
22 | time.Minute,
23 | 5 * time.Minute,
24 | 100 * time.Minute,
25 | time.Hour,
26 | 2 * time.Hour,
27 | 100 * time.Hour,
28 | 1000 * time.Hour,
29 | }
30 |
31 | lastRun := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
32 |
33 | for _, duration := range tests {
34 | t.Run(duration.String(), func(t *testing.T) {
35 | d := durationJob{duration: duration}
36 | next := d.next(lastRun)
37 | expected := lastRun.Add(duration)
38 |
39 | assert.Equal(t, expected, next)
40 | })
41 | }
42 | }
43 |
44 | func TestDailyJob_next(t *testing.T) {
45 | tests := []struct {
46 | name string
47 | interval uint
48 | atTimes []time.Time
49 | lastRun time.Time
50 | expectedNextRun time.Time
51 | expectedDurationToNextRun time.Duration
52 | }{
53 | {
54 | "daily at midnight",
55 | 1,
56 | []time.Time{
57 | time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
58 | },
59 | time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
60 | time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
61 | 24 * time.Hour,
62 | },
63 | {
64 | "daily multiple at times",
65 | 1,
66 | []time.Time{
67 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
68 | time.Date(0, 0, 0, 12, 30, 0, 0, time.UTC),
69 | },
70 | time.Date(2000, 1, 1, 5, 30, 0, 0, time.UTC),
71 | time.Date(2000, 1, 1, 12, 30, 0, 0, time.UTC),
72 | 7 * time.Hour,
73 | },
74 | {
75 | "every 2 days multiple at times",
76 | 2,
77 | []time.Time{
78 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
79 | time.Date(0, 0, 0, 12, 30, 0, 0, time.UTC),
80 | },
81 | time.Date(2000, 1, 1, 12, 30, 0, 0, time.UTC),
82 | time.Date(2000, 1, 3, 5, 30, 0, 0, time.UTC),
83 | 41 * time.Hour,
84 | },
85 | }
86 |
87 | for _, tt := range tests {
88 | t.Run(tt.name, func(t *testing.T) {
89 | d := dailyJob{
90 | interval: tt.interval,
91 | atTimes: tt.atTimes,
92 | }
93 |
94 | next := d.next(tt.lastRun)
95 | assert.Equal(t, tt.expectedNextRun, next)
96 | assert.Equal(t, tt.expectedDurationToNextRun, next.Sub(tt.lastRun))
97 | })
98 | }
99 | }
100 |
101 | func TestWeeklyJob_next(t *testing.T) {
102 | tests := []struct {
103 | name string
104 | interval uint
105 | daysOfWeek []time.Weekday
106 | atTimes []time.Time
107 | lastRun time.Time
108 | expectedNextRun time.Time
109 | expectedDurationToNextRun time.Duration
110 | }{
111 | {
112 | "last run Monday, next run is Thursday",
113 | 1,
114 | []time.Weekday{time.Monday, time.Thursday},
115 | []time.Time{
116 | time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
117 | },
118 | time.Date(2000, 1, 3, 0, 0, 0, 0, time.UTC),
119 | time.Date(2000, 1, 6, 0, 0, 0, 0, time.UTC),
120 | 3 * 24 * time.Hour,
121 | },
122 | {
123 | "last run Thursday, next run is Monday",
124 | 1,
125 | []time.Weekday{time.Monday, time.Thursday},
126 | []time.Time{
127 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
128 | },
129 | time.Date(2000, 1, 6, 5, 30, 0, 0, time.UTC),
130 | time.Date(2000, 1, 10, 5, 30, 0, 0, time.UTC),
131 | 4 * 24 * time.Hour,
132 | },
133 | }
134 |
135 | for _, tt := range tests {
136 | t.Run(tt.name, func(t *testing.T) {
137 | w := weeklyJob{
138 | interval: tt.interval,
139 | daysOfWeek: tt.daysOfWeek,
140 | atTimes: tt.atTimes,
141 | }
142 |
143 | next := w.next(tt.lastRun)
144 | assert.Equal(t, tt.expectedNextRun, next)
145 | assert.Equal(t, tt.expectedDurationToNextRun, next.Sub(tt.lastRun))
146 | })
147 | }
148 | }
149 |
150 | func TestMonthlyJob_next(t *testing.T) {
151 | americaChicago, err := time.LoadLocation("America/Chicago")
152 | require.NoError(t, err)
153 |
154 | tests := []struct {
155 | name string
156 | interval uint
157 | days []int
158 | daysFromEnd []int
159 | atTimes []time.Time
160 | lastRun time.Time
161 | expectedNextRun time.Time
162 | expectedDurationToNextRun time.Duration
163 | }{
164 | {
165 | "same day - before at time",
166 | 1,
167 | []int{1},
168 | nil,
169 | []time.Time{
170 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
171 | },
172 | time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
173 | time.Date(2000, 1, 1, 5, 30, 0, 0, time.UTC),
174 | 5*time.Hour + 30*time.Minute,
175 | },
176 | {
177 | "same day - after at time, runs next available date",
178 | 1,
179 | []int{1, 10},
180 | nil,
181 | []time.Time{
182 | time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
183 | },
184 | time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
185 | time.Date(2000, 1, 10, 0, 0, 0, 0, time.UTC),
186 | 9 * 24 * time.Hour,
187 | },
188 | {
189 | "same day - after at time, runs next available date, following interval month",
190 | 2,
191 | []int{1},
192 | nil,
193 | []time.Time{
194 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
195 | },
196 | time.Date(2000, 1, 1, 5, 30, 0, 0, time.UTC),
197 | time.Date(2000, 3, 1, 5, 30, 0, 0, time.UTC),
198 | 60 * 24 * time.Hour,
199 | },
200 | {
201 | "daylight savings time",
202 | 1,
203 | []int{5},
204 | nil,
205 | []time.Time{
206 | time.Date(0, 0, 0, 5, 30, 0, 0, americaChicago),
207 | },
208 | time.Date(2023, 11, 1, 0, 0, 0, 0, americaChicago),
209 | time.Date(2023, 11, 5, 5, 30, 0, 0, americaChicago),
210 | 4*24*time.Hour + 6*time.Hour + 30*time.Minute,
211 | },
212 | {
213 | "negative days",
214 | 1,
215 | nil,
216 | []int{-1, -3, -5},
217 | []time.Time{
218 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
219 | },
220 | time.Date(2000, 1, 29, 5, 30, 0, 0, time.UTC),
221 | time.Date(2000, 1, 31, 5, 30, 0, 0, time.UTC),
222 | 2 * 24 * time.Hour,
223 | },
224 | {
225 | "day not in current month, runs next month (leap year)",
226 | 1,
227 | []int{31},
228 | nil,
229 | []time.Time{
230 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
231 | },
232 | time.Date(2000, 1, 31, 5, 30, 0, 0, time.UTC),
233 | time.Date(2000, 3, 31, 5, 30, 0, 0, time.UTC),
234 | 29*24*time.Hour + 31*24*time.Hour,
235 | },
236 | {
237 | "multiple days not in order",
238 | 1,
239 | []int{10, 7, 19, 2},
240 | nil,
241 | []time.Time{
242 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
243 | },
244 | time.Date(2000, 1, 2, 5, 30, 0, 0, time.UTC),
245 | time.Date(2000, 1, 7, 5, 30, 0, 0, time.UTC),
246 | 5 * 24 * time.Hour,
247 | },
248 | {
249 | "day not in next interval month, selects next available option, skips Feb, April & June",
250 | 2,
251 | []int{31},
252 | nil,
253 | []time.Time{
254 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
255 | },
256 | time.Date(1999, 12, 31, 5, 30, 0, 0, time.UTC),
257 | time.Date(2000, 8, 31, 5, 30, 0, 0, time.UTC),
258 | 244 * 24 * time.Hour,
259 | },
260 | {
261 | "handle -1 with differing month's day count",
262 | 1,
263 | nil,
264 | []int{-1},
265 | []time.Time{
266 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
267 | },
268 | time.Date(2024, 1, 31, 5, 30, 0, 0, time.UTC),
269 | time.Date(2024, 2, 29, 5, 30, 0, 0, time.UTC),
270 | 29 * 24 * time.Hour,
271 | },
272 | {
273 | "handle -1 with another differing month's day count",
274 | 1,
275 | nil,
276 | []int{-1},
277 | []time.Time{
278 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
279 | },
280 | time.Date(2024, 2, 29, 5, 30, 0, 0, time.UTC),
281 | time.Date(2024, 3, 31, 5, 30, 0, 0, time.UTC),
282 | 31 * 24 * time.Hour,
283 | },
284 | {
285 | "handle -1 every 3 months next run in February",
286 | 3,
287 | nil,
288 | []int{-1},
289 | []time.Time{
290 | time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
291 | },
292 | time.Date(2023, 11, 30, 5, 30, 0, 0, time.UTC),
293 | time.Date(2024, 2, 29, 5, 30, 0, 0, time.UTC),
294 | 91 * 24 * time.Hour,
295 | },
296 | }
297 |
298 | for _, tt := range tests {
299 | t.Run(tt.name, func(t *testing.T) {
300 | m := monthlyJob{
301 | interval: tt.interval,
302 | days: tt.days,
303 | daysFromEnd: tt.daysFromEnd,
304 | atTimes: tt.atTimes,
305 | }
306 |
307 | next := m.next(tt.lastRun)
308 | assert.Equal(t, tt.expectedNextRun, next)
309 | assert.Equal(t, tt.expectedDurationToNextRun, next.Sub(tt.lastRun))
310 | })
311 | }
312 | }
313 |
314 | func TestDurationRandomJob_next(t *testing.T) {
315 | tests := []struct {
316 | name string
317 | min time.Duration
318 | max time.Duration
319 | lastRun time.Time
320 | expectedMin time.Time
321 | expectedMax time.Time
322 | }{
323 | {
324 | "min 1s, max 5s",
325 | time.Second,
326 | 5 * time.Second,
327 | time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
328 | time.Date(2000, 1, 1, 0, 0, 1, 0, time.UTC),
329 | time.Date(2000, 1, 1, 0, 0, 5, 0, time.UTC),
330 | },
331 | {
332 | "min 100ms, max 1s",
333 | 100 * time.Millisecond,
334 | 1 * time.Second,
335 | time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
336 | time.Date(2000, 1, 1, 0, 0, 0, 100000000, time.UTC),
337 | time.Date(2000, 1, 1, 0, 0, 1, 0, time.UTC),
338 | },
339 | }
340 |
341 | for _, tt := range tests {
342 | t.Run(tt.name, func(t *testing.T) {
343 | rj := durationRandomJob{
344 | min: tt.min,
345 | max: tt.max,
346 | rand: rand.New(rand.NewSource(time.Now().UnixNano())), // nolint:gosec
347 | }
348 |
349 | for i := 0; i < 100; i++ {
350 | next := rj.next(tt.lastRun)
351 | assert.GreaterOrEqual(t, next, tt.expectedMin)
352 | assert.LessOrEqual(t, next, tt.expectedMax)
353 | }
354 | })
355 | }
356 | }
357 |
358 | func TestOneTimeJob_next(t *testing.T) {
359 | otj := oneTimeJob{}
360 | assert.Zero(t, otj.next(time.Time{}))
361 | }
362 |
363 | func TestJob_RunNow_Error(t *testing.T) {
364 | s := newTestScheduler(t)
365 |
366 | j, err := s.NewJob(
367 | DurationJob(time.Second),
368 | NewTask(func() {}),
369 | )
370 | require.NoError(t, err)
371 |
372 | require.NoError(t, s.Shutdown())
373 |
374 | assert.EqualError(t, j.RunNow(), ErrJobRunNowFailed.Error())
375 | }
376 |
377 | func TestJob_LastRun(t *testing.T) {
378 | testTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)
379 | fakeClock := clockwork.NewFakeClockAt(testTime)
380 |
381 | s := newTestScheduler(t,
382 | WithClock(fakeClock),
383 | )
384 |
385 | j, err := s.NewJob(
386 | DurationJob(
387 | time.Second,
388 | ),
389 | NewTask(
390 | func() {},
391 | ),
392 | WithStartAt(WithStartImmediately()),
393 | )
394 | require.NoError(t, err)
395 |
396 | s.Start()
397 | time.Sleep(10 * time.Millisecond)
398 |
399 | lastRun, err := j.LastRun()
400 | assert.NoError(t, err)
401 |
402 | err = s.Shutdown()
403 | require.NoError(t, err)
404 |
405 | assert.Equal(t, testTime, lastRun)
406 | }
407 |
408 | func TestWithEventListeners(t *testing.T) {
409 | tests := []struct {
410 | name string
411 | eventListeners []EventListener
412 | err error
413 | }{
414 | {
415 | "no event listeners",
416 | nil,
417 | nil,
418 | },
419 | {
420 | "beforeJobRuns",
421 | []EventListener{
422 | BeforeJobRuns(func(_ uuid.UUID, _ string) {}),
423 | },
424 | nil,
425 | },
426 | {
427 | "afterJobRuns",
428 | []EventListener{
429 | AfterJobRuns(func(_ uuid.UUID, _ string) {}),
430 | },
431 | nil,
432 | },
433 | {
434 | "afterJobRunsWithError",
435 | []EventListener{
436 | AfterJobRunsWithError(func(_ uuid.UUID, _ string, _ error) {}),
437 | },
438 | nil,
439 | },
440 | {
441 | "afterJobRunsWithPanic",
442 | []EventListener{
443 | AfterJobRunsWithPanic(func(_ uuid.UUID, _ string, _ any) {}),
444 | },
445 | nil,
446 | },
447 | {
448 | "afterLockError",
449 | []EventListener{
450 | AfterLockError(func(_ uuid.UUID, _ string, _ error) {}),
451 | },
452 | nil,
453 | },
454 | {
455 | "multiple event listeners",
456 | []EventListener{
457 | AfterJobRuns(func(_ uuid.UUID, _ string) {}),
458 | AfterJobRunsWithError(func(_ uuid.UUID, _ string, _ error) {}),
459 | BeforeJobRuns(func(_ uuid.UUID, _ string) {}),
460 | AfterLockError(func(_ uuid.UUID, _ string, _ error) {}),
461 | },
462 | nil,
463 | },
464 | {
465 | "nil after job runs listener",
466 | []EventListener{
467 | AfterJobRuns(nil),
468 | },
469 | ErrEventListenerFuncNil,
470 | },
471 | {
472 | "nil after job runs with error listener",
473 | []EventListener{
474 | AfterJobRunsWithError(nil),
475 | },
476 | ErrEventListenerFuncNil,
477 | },
478 | {
479 | "nil before job runs listener",
480 | []EventListener{
481 | BeforeJobRuns(nil),
482 | },
483 | ErrEventListenerFuncNil,
484 | },
485 | {
486 | "nil before job runs error listener",
487 | []EventListener{
488 | BeforeJobRunsSkipIfBeforeFuncErrors(nil),
489 | },
490 | ErrEventListenerFuncNil,
491 | },
492 | }
493 |
494 | for _, tt := range tests {
495 | t.Run(tt.name, func(t *testing.T) {
496 | var ij internalJob
497 | err := WithEventListeners(tt.eventListeners...)(&ij, time.Now())
498 | assert.Equal(t, tt.err, err)
499 |
500 | if err != nil {
501 | return
502 | }
503 | var count int
504 | if ij.beforeJobRuns != nil {
505 | count++
506 | }
507 | if ij.afterJobRuns != nil {
508 | count++
509 | }
510 | if ij.afterJobRunsWithError != nil {
511 | count++
512 | }
513 | if ij.afterJobRunsWithPanic != nil {
514 | count++
515 | }
516 | if ij.afterLockError != nil {
517 | count++
518 | }
519 | assert.Equal(t, len(tt.eventListeners), count)
520 | })
521 | }
522 | }
523 |
524 | func TestJob_NextRun(t *testing.T) {
525 | tests := []struct {
526 | name string
527 | f func()
528 | }{
529 | {
530 | "simple",
531 | func() {},
532 | },
533 | {
534 | "sleep 3 seconds",
535 | func() {
536 | time.Sleep(300 * time.Millisecond)
537 | },
538 | },
539 | }
540 |
541 | for _, tt := range tests {
542 | t.Run(tt.name, func(t *testing.T) {
543 | testTime := time.Now()
544 |
545 | s := newTestScheduler(t)
546 |
547 | j, err := s.NewJob(
548 | DurationJob(
549 | 100*time.Millisecond,
550 | ),
551 | NewTask(
552 | func() {},
553 | ),
554 | WithStartAt(WithStartDateTime(testTime.Add(100*time.Millisecond))),
555 | WithSingletonMode(LimitModeReschedule),
556 | )
557 | require.NoError(t, err)
558 |
559 | s.Start()
560 | nextRun, err := j.NextRun()
561 | require.NoError(t, err)
562 |
563 | assert.Equal(t, testTime.Add(100*time.Millisecond), nextRun)
564 |
565 | time.Sleep(150 * time.Millisecond)
566 |
567 | nextRun, err = j.NextRun()
568 | assert.NoError(t, err)
569 |
570 | assert.Equal(t, testTime.Add(200*time.Millisecond), nextRun)
571 | assert.Equal(t, 200*time.Millisecond, nextRun.Sub(testTime))
572 |
573 | err = s.Shutdown()
574 | require.NoError(t, err)
575 | })
576 | }
577 | }
578 |
579 | func TestJob_NextRuns(t *testing.T) {
580 | tests := []struct {
581 | name string
582 | jd JobDefinition
583 | assertion func(t *testing.T, iteration int, previousRun, nextRun time.Time)
584 | }{
585 | {
586 | "simple - milliseconds",
587 | DurationJob(
588 | 100 * time.Millisecond,
589 | ),
590 | func(t *testing.T, _ int, previousRun, nextRun time.Time) {
591 | assert.Equal(t, previousRun.UnixMilli()+100, nextRun.UnixMilli())
592 | },
593 | },
594 | {
595 | "weekly",
596 | WeeklyJob(
597 | 2,
598 | NewWeekdays(time.Tuesday),
599 | NewAtTimes(
600 | NewAtTime(0, 0, 0),
601 | ),
602 | ),
603 | func(t *testing.T, iteration int, previousRun, nextRun time.Time) {
604 | diff := time.Hour * 14 * 24
605 | if iteration == 1 {
606 | // because the job is run immediately, the first run is on
607 | // Saturday 1/1/2000. The following run is then on Tuesday 1/11/2000
608 | diff = time.Hour * 10 * 24
609 | }
610 | assert.Equal(t, previousRun.Add(diff).Day(), nextRun.Day())
611 | },
612 | },
613 | }
614 |
615 | for _, tt := range tests {
616 | t.Run(tt.name, func(t *testing.T) {
617 | testTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)
618 | fakeClock := clockwork.NewFakeClockAt(testTime)
619 |
620 | s := newTestScheduler(t,
621 | WithClock(fakeClock),
622 | )
623 |
624 | j, err := s.NewJob(
625 | tt.jd,
626 | NewTask(
627 | func() {},
628 | ),
629 | WithStartAt(WithStartImmediately()),
630 | )
631 | require.NoError(t, err)
632 |
633 | s.Start()
634 | time.Sleep(10 * time.Millisecond)
635 |
636 | nextRuns, err := j.NextRuns(5)
637 | require.NoError(t, err)
638 |
639 | assert.Len(t, nextRuns, 5)
640 |
641 | for i := range nextRuns {
642 | if i == 0 {
643 | // skipping because there is no previous run
644 | continue
645 | }
646 | tt.assertion(t, i, nextRuns[i-1], nextRuns[i])
647 | }
648 |
649 | assert.NoError(t, s.Shutdown())
650 | })
651 | }
652 | }
653 |
654 | func TestJob_PanicOccurred(t *testing.T) {
655 | gotCh := make(chan any)
656 | errCh := make(chan error)
657 | s := newTestScheduler(t)
658 | _, err := s.NewJob(
659 | DurationJob(10*time.Millisecond),
660 | NewTask(func() {
661 | a := 0
662 | _ = 1 / a
663 | }),
664 | WithEventListeners(
665 | AfterJobRunsWithPanic(func(_ uuid.UUID, _ string, recoverData any) {
666 | gotCh <- recoverData
667 | }), AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
668 | errCh <- err
669 | }),
670 | ),
671 | )
672 | require.NoError(t, err)
673 |
674 | s.Start()
675 | got := <-gotCh
676 | require.EqualError(t, got.(error), "runtime error: integer divide by zero")
677 |
678 | err = <-errCh
679 | require.ErrorIs(t, err, ErrPanicRecovered)
680 | require.EqualError(t, err, "gocron: panic recovered from runtime error: integer divide by zero")
681 |
682 | require.NoError(t, s.Shutdown())
683 | close(gotCh)
684 | close(errCh)
685 | }
686 |
687 | func TestTimeFromAtTime(t *testing.T) {
688 | testTimeUTC := time.Date(0, 0, 0, 1, 1, 1, 0, time.UTC)
689 | cst, err := time.LoadLocation("America/Chicago")
690 | require.NoError(t, err)
691 | testTimeCST := time.Date(0, 0, 0, 1, 1, 1, 0, cst)
692 |
693 | tests := []struct {
694 | name string
695 | at AtTime
696 | loc *time.Location
697 | expectedTime time.Time
698 | expectedStr string
699 | }{
700 | {
701 | "UTC",
702 | NewAtTime(
703 | uint(testTimeUTC.Hour()),
704 | uint(testTimeUTC.Minute()),
705 | uint(testTimeUTC.Second()),
706 | ),
707 | time.UTC,
708 | testTimeUTC,
709 | "01:01:01",
710 | },
711 | {
712 | "CST",
713 | NewAtTime(
714 | uint(testTimeCST.Hour()),
715 | uint(testTimeCST.Minute()),
716 | uint(testTimeCST.Second()),
717 | ),
718 | cst,
719 | testTimeCST,
720 | "01:01:01",
721 | },
722 | }
723 |
724 | for _, tt := range tests {
725 | t.Run(tt.name, func(t *testing.T) {
726 | result := TimeFromAtTime(tt.at, tt.loc)
727 | assert.Equal(t, tt.expectedTime, result)
728 |
729 | resultFmt := result.Format("15:04:05")
730 | assert.Equal(t, tt.expectedStr, resultFmt)
731 | })
732 | }
733 | }
734 |
735 | func TestNewAtTimes(t *testing.T) {
736 | at := NewAtTimes(
737 | NewAtTime(1, 1, 1),
738 | NewAtTime(2, 2, 2),
739 | )
740 |
741 | var times []string
742 | for _, att := range at() {
743 | timeStr := TimeFromAtTime(att, time.UTC).Format("15:04")
744 | times = append(times, timeStr)
745 | }
746 |
747 | var timesAgain []string
748 | for _, att := range at() {
749 | timeStr := TimeFromAtTime(att, time.UTC).Format("15:04")
750 | timesAgain = append(timesAgain, timeStr)
751 | }
752 |
753 | assert.Equal(t, times, timesAgain)
754 | }
755 |
756 | func TestNewWeekdays(t *testing.T) {
757 | wd := NewWeekdays(
758 | time.Monday,
759 | time.Tuesday,
760 | )
761 |
762 | var dayStrings []string
763 | for _, w := range wd() {
764 | dayStrings = append(dayStrings, w.String())
765 | }
766 |
767 | var dayStringsAgain []string
768 | for _, w := range wd() {
769 | dayStringsAgain = append(dayStringsAgain, w.String())
770 | }
771 |
772 | assert.Equal(t, dayStrings, dayStringsAgain)
773 | }
774 |
775 | func TestNewDaysOfTheMonth(t *testing.T) {
776 | dom := NewDaysOfTheMonth(1, 2, 3)
777 |
778 | var domInts []int
779 | for _, d := range dom() {
780 | domInts = append(domInts, d)
781 | }
782 |
783 | var domIntsAgain []int
784 | for _, d := range dom() {
785 | domIntsAgain = append(domIntsAgain, d)
786 | }
787 |
788 | assert.Equal(t, domInts, domIntsAgain)
789 | }
790 |
--------------------------------------------------------------------------------
/logger.go:
--------------------------------------------------------------------------------
1 | //go:generate mockgen -destination=mocks/logger.go -package=gocronmocks . Logger
2 | package gocron
3 |
4 | import (
5 | "fmt"
6 | "log"
7 | "os"
8 | "strings"
9 | )
10 |
11 | // Logger is the interface that wraps the basic logging methods
12 | // used by gocron. The methods are modeled after the standard
13 | // library slog package. The default logger is a no-op logger.
14 | // To enable logging, use one of the provided New*Logger functions
15 | // or implement your own Logger. The actual level of Log that is logged
16 | // is handled by the implementation.
17 | type Logger interface {
18 | Debug(msg string, args ...any)
19 | Error(msg string, args ...any)
20 | Info(msg string, args ...any)
21 | Warn(msg string, args ...any)
22 | }
23 |
24 | var _ Logger = (*noOpLogger)(nil)
25 |
26 | type noOpLogger struct{}
27 |
28 | func (l noOpLogger) Debug(_ string, _ ...any) {}
29 | func (l noOpLogger) Error(_ string, _ ...any) {}
30 | func (l noOpLogger) Info(_ string, _ ...any) {}
31 | func (l noOpLogger) Warn(_ string, _ ...any) {}
32 |
33 | var _ Logger = (*logger)(nil)
34 |
35 | // LogLevel is the level of logging that should be logged
36 | // when using the basic NewLogger.
37 | type LogLevel int
38 |
39 | // The different log levels that can be used.
40 | const (
41 | LogLevelError LogLevel = iota
42 | LogLevelWarn
43 | LogLevelInfo
44 | LogLevelDebug
45 | )
46 |
47 | type logger struct {
48 | log *log.Logger
49 | level LogLevel
50 | }
51 |
52 | // NewLogger returns a new Logger that logs at the given level.
53 | func NewLogger(level LogLevel) Logger {
54 | l := log.New(os.Stdout, "", log.LstdFlags)
55 | return &logger{
56 | log: l,
57 | level: level,
58 | }
59 | }
60 |
61 | func (l *logger) Debug(msg string, args ...any) {
62 | if l.level < LogLevelDebug {
63 | return
64 | }
65 | l.log.Printf("DEBUG: %s%s\n", msg, logFormatArgs(args...))
66 | }
67 |
68 | func (l *logger) Error(msg string, args ...any) {
69 | if l.level < LogLevelError {
70 | return
71 | }
72 | l.log.Printf("ERROR: %s%s\n", msg, logFormatArgs(args...))
73 | }
74 |
75 | func (l *logger) Info(msg string, args ...any) {
76 | if l.level < LogLevelInfo {
77 | return
78 | }
79 | l.log.Printf("INFO: %s%s\n", msg, logFormatArgs(args...))
80 | }
81 |
82 | func (l *logger) Warn(msg string, args ...any) {
83 | if l.level < LogLevelWarn {
84 | return
85 | }
86 | l.log.Printf("WARN: %s%s\n", msg, logFormatArgs(args...))
87 | }
88 |
89 | func logFormatArgs(args ...any) string {
90 | if len(args) == 0 {
91 | return ""
92 | }
93 | if len(args)%2 != 0 {
94 | return ", " + fmt.Sprint(args...)
95 | }
96 | var pairs []string
97 | for i := 0; i < len(args); i += 2 {
98 | pairs = append(pairs, fmt.Sprintf("%s=%v", args[i], args[i+1]))
99 | }
100 | return ", " + strings.Join(pairs, ", ")
101 | }
102 |
--------------------------------------------------------------------------------
/logger_test.go:
--------------------------------------------------------------------------------
1 | package gocron
2 |
3 | import (
4 | "bytes"
5 | "log"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestNoOpLogger(_ *testing.T) {
13 | noOp := noOpLogger{}
14 | noOp.Debug("debug", "arg1", "arg2")
15 | noOp.Error("error", "arg1", "arg2")
16 | noOp.Info("info", "arg1", "arg2")
17 | noOp.Warn("warn", "arg1", "arg2")
18 | }
19 |
20 | func TestNewLogger(t *testing.T) {
21 | tests := []struct {
22 | name string
23 | level LogLevel
24 | }{
25 | {
26 | "debug",
27 | LogLevelDebug,
28 | },
29 | {
30 | "info",
31 | LogLevelInfo,
32 | },
33 | {
34 | "warn",
35 | LogLevelWarn,
36 | },
37 | {
38 | "error",
39 | LogLevelError,
40 | },
41 | {
42 | "Less than error",
43 | -1,
44 | },
45 | }
46 |
47 | for _, tt := range tests {
48 | t.Run(tt.name, func(t *testing.T) {
49 | var results bytes.Buffer
50 | l := &logger{
51 | level: tt.level,
52 | log: log.New(&results, "", log.LstdFlags),
53 | }
54 |
55 | var noArgs []any
56 | oneArg := []any{"arg1"}
57 | twoArgs := []any{"arg1", "arg2"}
58 | var noArgsStr []string
59 | oneArgStr := []string{"arg1"}
60 | twoArgsStr := []string{"arg1", "arg2"}
61 |
62 | for _, args := range []struct {
63 | argsAny []any
64 | argsStr []string
65 | }{
66 | {noArgs, noArgsStr},
67 | {oneArg, oneArgStr},
68 | {twoArgs, twoArgsStr},
69 | } {
70 | l.Debug("debug", args.argsAny...)
71 | if tt.level >= LogLevelDebug {
72 | r := results.String()
73 | assert.Contains(t, r, "DEBUG: debug")
74 | assert.Contains(t, r, strings.Join(args.argsStr, "="))
75 | } else {
76 | assert.Empty(t, results.String())
77 | }
78 | results.Reset()
79 |
80 | l.Info("info", args.argsAny...)
81 | if tt.level >= LogLevelInfo {
82 | r := results.String()
83 | assert.Contains(t, r, "INFO: info")
84 | assert.Contains(t, r, strings.Join(args.argsStr, "="))
85 | } else {
86 | assert.Empty(t, results.String())
87 | }
88 | results.Reset()
89 |
90 | l.Warn("warn", args.argsAny...)
91 | if tt.level >= LogLevelWarn {
92 | r := results.String()
93 | assert.Contains(t, r, "WARN: warn")
94 | assert.Contains(t, r, strings.Join(args.argsStr, "="))
95 | } else {
96 | assert.Empty(t, results.String())
97 | }
98 | results.Reset()
99 |
100 | l.Error("error", args.argsAny...)
101 | if tt.level >= LogLevelError {
102 | r := results.String()
103 | assert.Contains(t, r, "ERROR: error")
104 | assert.Contains(t, r, strings.Join(args.argsStr, "="))
105 | } else {
106 | assert.Empty(t, results.String())
107 | }
108 | results.Reset()
109 | }
110 | })
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/mocks/README.md:
--------------------------------------------------------------------------------
1 | # gocron mocks
2 |
3 | ## Quick Start
4 |
5 | ```
6 | go get github.com/go-co-op/gocron/mocks/v2
7 | ```
8 |
9 | write a test
10 |
11 | ```golang
12 | package main
13 |
14 | import (
15 | "testing"
16 |
17 | "github.com/go-co-op/gocron/mocks/v2"
18 | "github.com/go-co-op/gocron/v2"
19 | "go.uber.org/mock/gomock"
20 | )
21 |
22 | func myFunc(s gocron.Scheduler) {
23 | s.Start()
24 | _ = s.Shutdown()
25 | }
26 |
27 | func TestMyFunc(t *testing.T) {
28 | ctrl := gomock.NewController(t)
29 | s := gocronmocks.NewMockScheduler(ctrl)
30 | s.EXPECT().Start().Times(1)
31 | s.EXPECT().Shutdown().Times(1).Return(nil)
32 |
33 | myFunc(s)
34 | }
35 |
36 | ```
37 |
--------------------------------------------------------------------------------
/mocks/distributed.go:
--------------------------------------------------------------------------------
1 | // Code generated by MockGen. DO NOT EDIT.
2 | // Source: github.com/go-co-op/gocron/v2 (interfaces: Elector,Locker,Lock)
3 | //
4 | // Generated by this command:
5 | //
6 | // mockgen -destination=mocks/distributed.go -package=gocronmocks . Elector,Locker,Lock
7 | //
8 | // Package gocronmocks is a generated GoMock package.
9 | package gocronmocks
10 |
11 | import (
12 | context "context"
13 | reflect "reflect"
14 |
15 | gocron "github.com/go-co-op/gocron/v2"
16 | gomock "go.uber.org/mock/gomock"
17 | )
18 |
19 | // MockElector is a mock of Elector interface.
20 | type MockElector struct {
21 | ctrl *gomock.Controller
22 | recorder *MockElectorMockRecorder
23 | }
24 |
25 | // MockElectorMockRecorder is the mock recorder for MockElector.
26 | type MockElectorMockRecorder struct {
27 | mock *MockElector
28 | }
29 |
30 | // NewMockElector creates a new mock instance.
31 | func NewMockElector(ctrl *gomock.Controller) *MockElector {
32 | mock := &MockElector{ctrl: ctrl}
33 | mock.recorder = &MockElectorMockRecorder{mock}
34 | return mock
35 | }
36 |
37 | // EXPECT returns an object that allows the caller to indicate expected use.
38 | func (m *MockElector) EXPECT() *MockElectorMockRecorder {
39 | return m.recorder
40 | }
41 |
42 | // IsLeader mocks base method.
43 | func (m *MockElector) IsLeader(arg0 context.Context) error {
44 | m.ctrl.T.Helper()
45 | ret := m.ctrl.Call(m, "IsLeader", arg0)
46 | ret0, _ := ret[0].(error)
47 | return ret0
48 | }
49 |
50 | // IsLeader indicates an expected call of IsLeader.
51 | func (mr *MockElectorMockRecorder) IsLeader(arg0 any) *gomock.Call {
52 | mr.mock.ctrl.T.Helper()
53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsLeader", reflect.TypeOf((*MockElector)(nil).IsLeader), arg0)
54 | }
55 |
56 | // MockLocker is a mock of Locker interface.
57 | type MockLocker struct {
58 | ctrl *gomock.Controller
59 | recorder *MockLockerMockRecorder
60 | }
61 |
62 | // MockLockerMockRecorder is the mock recorder for MockLocker.
63 | type MockLockerMockRecorder struct {
64 | mock *MockLocker
65 | }
66 |
67 | // NewMockLocker creates a new mock instance.
68 | func NewMockLocker(ctrl *gomock.Controller) *MockLocker {
69 | mock := &MockLocker{ctrl: ctrl}
70 | mock.recorder = &MockLockerMockRecorder{mock}
71 | return mock
72 | }
73 |
74 | // EXPECT returns an object that allows the caller to indicate expected use.
75 | func (m *MockLocker) EXPECT() *MockLockerMockRecorder {
76 | return m.recorder
77 | }
78 |
79 | // Lock mocks base method.
80 | func (m *MockLocker) Lock(arg0 context.Context, arg1 string) (gocron.Lock, error) {
81 | m.ctrl.T.Helper()
82 | ret := m.ctrl.Call(m, "Lock", arg0, arg1)
83 | ret0, _ := ret[0].(gocron.Lock)
84 | ret1, _ := ret[1].(error)
85 | return ret0, ret1
86 | }
87 |
88 | // Lock indicates an expected call of Lock.
89 | func (mr *MockLockerMockRecorder) Lock(arg0, arg1 any) *gomock.Call {
90 | mr.mock.ctrl.T.Helper()
91 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lock", reflect.TypeOf((*MockLocker)(nil).Lock), arg0, arg1)
92 | }
93 |
94 | // MockLock is a mock of Lock interface.
95 | type MockLock struct {
96 | ctrl *gomock.Controller
97 | recorder *MockLockMockRecorder
98 | }
99 |
100 | // MockLockMockRecorder is the mock recorder for MockLock.
101 | type MockLockMockRecorder struct {
102 | mock *MockLock
103 | }
104 |
105 | // NewMockLock creates a new mock instance.
106 | func NewMockLock(ctrl *gomock.Controller) *MockLock {
107 | mock := &MockLock{ctrl: ctrl}
108 | mock.recorder = &MockLockMockRecorder{mock}
109 | return mock
110 | }
111 |
112 | // EXPECT returns an object that allows the caller to indicate expected use.
113 | func (m *MockLock) EXPECT() *MockLockMockRecorder {
114 | return m.recorder
115 | }
116 |
117 | // Unlock mocks base method.
118 | func (m *MockLock) Unlock(arg0 context.Context) error {
119 | m.ctrl.T.Helper()
120 | ret := m.ctrl.Call(m, "Unlock", arg0)
121 | ret0, _ := ret[0].(error)
122 | return ret0
123 | }
124 |
125 | // Unlock indicates an expected call of Unlock.
126 | func (mr *MockLockMockRecorder) Unlock(arg0 any) *gomock.Call {
127 | mr.mock.ctrl.T.Helper()
128 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockLock)(nil).Unlock), arg0)
129 | }
130 |
--------------------------------------------------------------------------------
/mocks/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/go-co-op/gocron/mocks/v2
2 |
3 | go 1.20
4 |
5 | require (
6 | github.com/go-co-op/gocron/v2 v2.2.10
7 | github.com/google/uuid v1.6.0
8 | go.uber.org/mock v0.4.0
9 | )
10 |
11 | require (
12 | github.com/jonboulle/clockwork v0.4.0 // indirect
13 | github.com/robfig/cron/v3 v3.0.1 // indirect
14 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
15 | )
16 |
--------------------------------------------------------------------------------
/mocks/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/go-co-op/gocron/v2 v2.2.10 h1:o6u+RfvT5rBa39gmsA5cqPPLXTa+Ai70m7EGgHQoXyg=
3 | github.com/go-co-op/gocron/v2 v2.2.10/go.mod h1:mZx3gMSlFnb97k3hRqX3+GdlG3+DUwTh6B8fnsTScXg=
4 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
5 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
6 | github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
7 | github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
9 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
10 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
11 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
12 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
13 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
14 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
15 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
16 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
18 |
--------------------------------------------------------------------------------
/mocks/job.go:
--------------------------------------------------------------------------------
1 | // Code generated by MockGen. DO NOT EDIT.
2 | // Source: github.com/go-co-op/gocron/v2 (interfaces: Job)
3 | //
4 | // Generated by this command:
5 | //
6 | // mockgen -destination=mocks/job.go -package=gocronmocks . Job
7 | //
8 | // Package gocronmocks is a generated GoMock package.
9 | package gocronmocks
10 |
11 | import (
12 | reflect "reflect"
13 | time "time"
14 |
15 | uuid "github.com/google/uuid"
16 | gomock "go.uber.org/mock/gomock"
17 | )
18 |
19 | // MockJob is a mock of Job interface.
20 | type MockJob struct {
21 | ctrl *gomock.Controller
22 | recorder *MockJobMockRecorder
23 | }
24 |
25 | // MockJobMockRecorder is the mock recorder for MockJob.
26 | type MockJobMockRecorder struct {
27 | mock *MockJob
28 | }
29 |
30 | // NewMockJob creates a new mock instance.
31 | func NewMockJob(ctrl *gomock.Controller) *MockJob {
32 | mock := &MockJob{ctrl: ctrl}
33 | mock.recorder = &MockJobMockRecorder{mock}
34 | return mock
35 | }
36 |
37 | // EXPECT returns an object that allows the caller to indicate expected use.
38 | func (m *MockJob) EXPECT() *MockJobMockRecorder {
39 | return m.recorder
40 | }
41 |
42 | // ID mocks base method.
43 | func (m *MockJob) ID() uuid.UUID {
44 | m.ctrl.T.Helper()
45 | ret := m.ctrl.Call(m, "ID")
46 | ret0, _ := ret[0].(uuid.UUID)
47 | return ret0
48 | }
49 |
50 | // ID indicates an expected call of ID.
51 | func (mr *MockJobMockRecorder) ID() *gomock.Call {
52 | mr.mock.ctrl.T.Helper()
53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockJob)(nil).ID))
54 | }
55 |
56 | // LastRun mocks base method.
57 | func (m *MockJob) LastRun() (time.Time, error) {
58 | m.ctrl.T.Helper()
59 | ret := m.ctrl.Call(m, "LastRun")
60 | ret0, _ := ret[0].(time.Time)
61 | ret1, _ := ret[1].(error)
62 | return ret0, ret1
63 | }
64 |
65 | // LastRun indicates an expected call of LastRun.
66 | func (mr *MockJobMockRecorder) LastRun() *gomock.Call {
67 | mr.mock.ctrl.T.Helper()
68 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LastRun", reflect.TypeOf((*MockJob)(nil).LastRun))
69 | }
70 |
71 | // Name mocks base method.
72 | func (m *MockJob) Name() string {
73 | m.ctrl.T.Helper()
74 | ret := m.ctrl.Call(m, "Name")
75 | ret0, _ := ret[0].(string)
76 | return ret0
77 | }
78 |
79 | // Name indicates an expected call of Name.
80 | func (mr *MockJobMockRecorder) Name() *gomock.Call {
81 | mr.mock.ctrl.T.Helper()
82 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockJob)(nil).Name))
83 | }
84 |
85 | // NextRun mocks base method.
86 | func (m *MockJob) NextRun() (time.Time, error) {
87 | m.ctrl.T.Helper()
88 | ret := m.ctrl.Call(m, "NextRun")
89 | ret0, _ := ret[0].(time.Time)
90 | ret1, _ := ret[1].(error)
91 | return ret0, ret1
92 | }
93 |
94 | // NextRun indicates an expected call of NextRun.
95 | func (mr *MockJobMockRecorder) NextRun() *gomock.Call {
96 | mr.mock.ctrl.T.Helper()
97 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextRun", reflect.TypeOf((*MockJob)(nil).NextRun))
98 | }
99 |
100 | // NextRuns mocks base method.
101 | func (m *MockJob) NextRuns(arg0 int) ([]time.Time, error) {
102 | m.ctrl.T.Helper()
103 | ret := m.ctrl.Call(m, "NextRuns", arg0)
104 | ret0, _ := ret[0].([]time.Time)
105 | ret1, _ := ret[1].(error)
106 | return ret0, ret1
107 | }
108 |
109 | // NextRuns indicates an expected call of NextRuns.
110 | func (mr *MockJobMockRecorder) NextRuns(arg0 any) *gomock.Call {
111 | mr.mock.ctrl.T.Helper()
112 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextRuns", reflect.TypeOf((*MockJob)(nil).NextRuns), arg0)
113 | }
114 |
115 | // RunNow mocks base method.
116 | func (m *MockJob) RunNow() error {
117 | m.ctrl.T.Helper()
118 | ret := m.ctrl.Call(m, "RunNow")
119 | ret0, _ := ret[0].(error)
120 | return ret0
121 | }
122 |
123 | // RunNow indicates an expected call of RunNow.
124 | func (mr *MockJobMockRecorder) RunNow() *gomock.Call {
125 | mr.mock.ctrl.T.Helper()
126 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunNow", reflect.TypeOf((*MockJob)(nil).RunNow))
127 | }
128 |
129 | // Tags mocks base method.
130 | func (m *MockJob) Tags() []string {
131 | m.ctrl.T.Helper()
132 | ret := m.ctrl.Call(m, "Tags")
133 | ret0, _ := ret[0].([]string)
134 | return ret0
135 | }
136 |
137 | // Tags indicates an expected call of Tags.
138 | func (mr *MockJobMockRecorder) Tags() *gomock.Call {
139 | mr.mock.ctrl.T.Helper()
140 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Tags", reflect.TypeOf((*MockJob)(nil).Tags))
141 | }
142 |
--------------------------------------------------------------------------------
/mocks/logger.go:
--------------------------------------------------------------------------------
1 | // Code generated by MockGen. DO NOT EDIT.
2 | // Source: github.com/go-co-op/gocron/v2 (interfaces: Logger)
3 | //
4 | // Generated by this command:
5 | //
6 | // mockgen -destination=mocks/logger.go -package=gocronmocks . Logger
7 | //
8 | // Package gocronmocks is a generated GoMock package.
9 | package gocronmocks
10 |
11 | import (
12 | reflect "reflect"
13 |
14 | gomock "go.uber.org/mock/gomock"
15 | )
16 |
17 | // MockLogger is a mock of Logger interface.
18 | type MockLogger struct {
19 | ctrl *gomock.Controller
20 | recorder *MockLoggerMockRecorder
21 | }
22 |
23 | // MockLoggerMockRecorder is the mock recorder for MockLogger.
24 | type MockLoggerMockRecorder struct {
25 | mock *MockLogger
26 | }
27 |
28 | // NewMockLogger creates a new mock instance.
29 | func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
30 | mock := &MockLogger{ctrl: ctrl}
31 | mock.recorder = &MockLoggerMockRecorder{mock}
32 | return mock
33 | }
34 |
35 | // EXPECT returns an object that allows the caller to indicate expected use.
36 | func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
37 | return m.recorder
38 | }
39 |
40 | // Debug mocks base method.
41 | func (m *MockLogger) Debug(arg0 string, arg1 ...any) {
42 | m.ctrl.T.Helper()
43 | varargs := []any{arg0}
44 | for _, a := range arg1 {
45 | varargs = append(varargs, a)
46 | }
47 | m.ctrl.Call(m, "Debug", varargs...)
48 | }
49 |
50 | // Debug indicates an expected call of Debug.
51 | func (mr *MockLoggerMockRecorder) Debug(arg0 any, arg1 ...any) *gomock.Call {
52 | mr.mock.ctrl.T.Helper()
53 | varargs := append([]any{arg0}, arg1...)
54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debug", reflect.TypeOf((*MockLogger)(nil).Debug), varargs...)
55 | }
56 |
57 | // Error mocks base method.
58 | func (m *MockLogger) Error(arg0 string, arg1 ...any) {
59 | m.ctrl.T.Helper()
60 | varargs := []any{arg0}
61 | for _, a := range arg1 {
62 | varargs = append(varargs, a)
63 | }
64 | m.ctrl.Call(m, "Error", varargs...)
65 | }
66 |
67 | // Error indicates an expected call of Error.
68 | func (mr *MockLoggerMockRecorder) Error(arg0 any, arg1 ...any) *gomock.Call {
69 | mr.mock.ctrl.T.Helper()
70 | varargs := append([]any{arg0}, arg1...)
71 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), varargs...)
72 | }
73 |
74 | // Info mocks base method.
75 | func (m *MockLogger) Info(arg0 string, arg1 ...any) {
76 | m.ctrl.T.Helper()
77 | varargs := []any{arg0}
78 | for _, a := range arg1 {
79 | varargs = append(varargs, a)
80 | }
81 | m.ctrl.Call(m, "Info", varargs...)
82 | }
83 |
84 | // Info indicates an expected call of Info.
85 | func (mr *MockLoggerMockRecorder) Info(arg0 any, arg1 ...any) *gomock.Call {
86 | mr.mock.ctrl.T.Helper()
87 | varargs := append([]any{arg0}, arg1...)
88 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), varargs...)
89 | }
90 |
91 | // Warn mocks base method.
92 | func (m *MockLogger) Warn(arg0 string, arg1 ...any) {
93 | m.ctrl.T.Helper()
94 | varargs := []any{arg0}
95 | for _, a := range arg1 {
96 | varargs = append(varargs, a)
97 | }
98 | m.ctrl.Call(m, "Warn", varargs...)
99 | }
100 |
101 | // Warn indicates an expected call of Warn.
102 | func (mr *MockLoggerMockRecorder) Warn(arg0 any, arg1 ...any) *gomock.Call {
103 | mr.mock.ctrl.T.Helper()
104 | varargs := append([]any{arg0}, arg1...)
105 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), varargs...)
106 | }
107 |
--------------------------------------------------------------------------------
/mocks/scheduler.go:
--------------------------------------------------------------------------------
1 | // Code generated by MockGen. DO NOT EDIT.
2 | // Source: github.com/go-co-op/gocron/v2 (interfaces: Scheduler)
3 | //
4 | // Generated by this command:
5 | //
6 | // mockgen -destination=mocks/scheduler.go -package=gocronmocks . Scheduler
7 | //
8 | // Package gocronmocks is a generated GoMock package.
9 | package gocronmocks
10 |
11 | import (
12 | reflect "reflect"
13 |
14 | gocron "github.com/go-co-op/gocron/v2"
15 | uuid "github.com/google/uuid"
16 | gomock "go.uber.org/mock/gomock"
17 | )
18 |
19 | // MockScheduler is a mock of Scheduler interface.
20 | type MockScheduler struct {
21 | ctrl *gomock.Controller
22 | recorder *MockSchedulerMockRecorder
23 | }
24 |
25 | // MockSchedulerMockRecorder is the mock recorder for MockScheduler.
26 | type MockSchedulerMockRecorder struct {
27 | mock *MockScheduler
28 | }
29 |
30 | // NewMockScheduler creates a new mock instance.
31 | func NewMockScheduler(ctrl *gomock.Controller) *MockScheduler {
32 | mock := &MockScheduler{ctrl: ctrl}
33 | mock.recorder = &MockSchedulerMockRecorder{mock}
34 | return mock
35 | }
36 |
37 | // EXPECT returns an object that allows the caller to indicate expected use.
38 | func (m *MockScheduler) EXPECT() *MockSchedulerMockRecorder {
39 | return m.recorder
40 | }
41 |
42 | // Jobs mocks base method.
43 | func (m *MockScheduler) Jobs() []gocron.Job {
44 | m.ctrl.T.Helper()
45 | ret := m.ctrl.Call(m, "Jobs")
46 | ret0, _ := ret[0].([]gocron.Job)
47 | return ret0
48 | }
49 |
50 | // Jobs indicates an expected call of Jobs.
51 | func (mr *MockSchedulerMockRecorder) Jobs() *gomock.Call {
52 | mr.mock.ctrl.T.Helper()
53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Jobs", reflect.TypeOf((*MockScheduler)(nil).Jobs))
54 | }
55 |
56 | // JobsWaitingInQueue mocks base method.
57 | func (m *MockScheduler) JobsWaitingInQueue() int {
58 | m.ctrl.T.Helper()
59 | ret := m.ctrl.Call(m, "JobsWaitingInQueue")
60 | ret0, _ := ret[0].(int)
61 | return ret0
62 | }
63 |
64 | // JobsWaitingInQueue indicates an expected call of JobsWaitingInQueue.
65 | func (mr *MockSchedulerMockRecorder) JobsWaitingInQueue() *gomock.Call {
66 | mr.mock.ctrl.T.Helper()
67 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "JobsWaitingInQueue", reflect.TypeOf((*MockScheduler)(nil).JobsWaitingInQueue))
68 | }
69 |
70 | // NewJob mocks base method.
71 | func (m *MockScheduler) NewJob(arg0 gocron.JobDefinition, arg1 gocron.Task, arg2 ...gocron.JobOption) (gocron.Job, error) {
72 | m.ctrl.T.Helper()
73 | varargs := []any{arg0, arg1}
74 | for _, a := range arg2 {
75 | varargs = append(varargs, a)
76 | }
77 | ret := m.ctrl.Call(m, "NewJob", varargs...)
78 | ret0, _ := ret[0].(gocron.Job)
79 | ret1, _ := ret[1].(error)
80 | return ret0, ret1
81 | }
82 |
83 | // NewJob indicates an expected call of NewJob.
84 | func (mr *MockSchedulerMockRecorder) NewJob(arg0, arg1 any, arg2 ...any) *gomock.Call {
85 | mr.mock.ctrl.T.Helper()
86 | varargs := append([]any{arg0, arg1}, arg2...)
87 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewJob", reflect.TypeOf((*MockScheduler)(nil).NewJob), varargs...)
88 | }
89 |
90 | // RemoveByTags mocks base method.
91 | func (m *MockScheduler) RemoveByTags(arg0 ...string) {
92 | m.ctrl.T.Helper()
93 | varargs := []any{}
94 | for _, a := range arg0 {
95 | varargs = append(varargs, a)
96 | }
97 | m.ctrl.Call(m, "RemoveByTags", varargs...)
98 | }
99 |
100 | // RemoveByTags indicates an expected call of RemoveByTags.
101 | func (mr *MockSchedulerMockRecorder) RemoveByTags(arg0 ...any) *gomock.Call {
102 | mr.mock.ctrl.T.Helper()
103 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveByTags", reflect.TypeOf((*MockScheduler)(nil).RemoveByTags), arg0...)
104 | }
105 |
106 | // RemoveJob mocks base method.
107 | func (m *MockScheduler) RemoveJob(arg0 uuid.UUID) error {
108 | m.ctrl.T.Helper()
109 | ret := m.ctrl.Call(m, "RemoveJob", arg0)
110 | ret0, _ := ret[0].(error)
111 | return ret0
112 | }
113 |
114 | // RemoveJob indicates an expected call of RemoveJob.
115 | func (mr *MockSchedulerMockRecorder) RemoveJob(arg0 any) *gomock.Call {
116 | mr.mock.ctrl.T.Helper()
117 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveJob", reflect.TypeOf((*MockScheduler)(nil).RemoveJob), arg0)
118 | }
119 |
120 | // Shutdown mocks base method.
121 | func (m *MockScheduler) Shutdown() error {
122 | m.ctrl.T.Helper()
123 | ret := m.ctrl.Call(m, "Shutdown")
124 | ret0, _ := ret[0].(error)
125 | return ret0
126 | }
127 |
128 | // Shutdown indicates an expected call of Shutdown.
129 | func (mr *MockSchedulerMockRecorder) Shutdown() *gomock.Call {
130 | mr.mock.ctrl.T.Helper()
131 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockScheduler)(nil).Shutdown))
132 | }
133 |
134 | // Start mocks base method.
135 | func (m *MockScheduler) Start() {
136 | m.ctrl.T.Helper()
137 | m.ctrl.Call(m, "Start")
138 | }
139 |
140 | // Start indicates an expected call of Start.
141 | func (mr *MockSchedulerMockRecorder) Start() *gomock.Call {
142 | mr.mock.ctrl.T.Helper()
143 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockScheduler)(nil).Start))
144 | }
145 |
146 | // StopJobs mocks base method.
147 | func (m *MockScheduler) StopJobs() error {
148 | m.ctrl.T.Helper()
149 | ret := m.ctrl.Call(m, "StopJobs")
150 | ret0, _ := ret[0].(error)
151 | return ret0
152 | }
153 |
154 | // StopJobs indicates an expected call of StopJobs.
155 | func (mr *MockSchedulerMockRecorder) StopJobs() *gomock.Call {
156 | mr.mock.ctrl.T.Helper()
157 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopJobs", reflect.TypeOf((*MockScheduler)(nil).StopJobs))
158 | }
159 |
160 | // Update mocks base method.
161 | func (m *MockScheduler) Update(arg0 uuid.UUID, arg1 gocron.JobDefinition, arg2 gocron.Task, arg3 ...gocron.JobOption) (gocron.Job, error) {
162 | m.ctrl.T.Helper()
163 | varargs := []any{arg0, arg1, arg2}
164 | for _, a := range arg3 {
165 | varargs = append(varargs, a)
166 | }
167 | ret := m.ctrl.Call(m, "Update", varargs...)
168 | ret0, _ := ret[0].(gocron.Job)
169 | ret1, _ := ret[1].(error)
170 | return ret0, ret1
171 | }
172 |
173 | // Update indicates an expected call of Update.
174 | func (mr *MockSchedulerMockRecorder) Update(arg0, arg1, arg2 any, arg3 ...any) *gomock.Call {
175 | mr.mock.ctrl.T.Helper()
176 | varargs := append([]any{arg0, arg1, arg2}, arg3...)
177 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockScheduler)(nil).Update), varargs...)
178 | }
179 |
--------------------------------------------------------------------------------
/monitor.go:
--------------------------------------------------------------------------------
1 | package gocron
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/google/uuid"
7 | )
8 |
9 | // JobStatus is the status of job run that should be collected with the metric.
10 | type JobStatus string
11 |
12 | // The different statuses of job that can be used.
13 | const (
14 | Fail JobStatus = "fail"
15 | Success JobStatus = "success"
16 | Skip JobStatus = "skip"
17 | SingletonRescheduled JobStatus = "singleton_rescheduled"
18 | )
19 |
20 | // Monitor represents the interface to collect jobs metrics.
21 | type Monitor interface {
22 | // IncrementJob will provide details about the job and expects the underlying implementation
23 | // to handle instantiating and incrementing a value
24 | IncrementJob(id uuid.UUID, name string, tags []string, status JobStatus)
25 | // RecordJobTiming will provide details about the job and the timing and expects the underlying implementation
26 | // to handle instantiating and recording the value
27 | RecordJobTiming(startTime, endTime time.Time, id uuid.UUID, name string, tags []string)
28 | }
29 |
30 | // MonitorStatus extends RecordJobTiming with the job status.
31 | type MonitorStatus interface {
32 | Monitor
33 | // RecordJobTimingWithStatus will provide details about the job, its status, error and the timing and expects the underlying implementation
34 | // to handle instantiating and recording the value
35 | RecordJobTimingWithStatus(startTime, endTime time.Time, id uuid.UUID, name string, tags []string, status JobStatus, err error)
36 | }
37 |
--------------------------------------------------------------------------------
/scheduler.go:
--------------------------------------------------------------------------------
1 | //go:generate mockgen -destination=mocks/scheduler.go -package=gocronmocks . Scheduler
2 | package gocron
3 |
4 | import (
5 | "context"
6 | "reflect"
7 | "runtime"
8 | "slices"
9 | "strings"
10 | "time"
11 |
12 | "github.com/google/uuid"
13 | "github.com/jonboulle/clockwork"
14 | )
15 |
16 | var _ Scheduler = (*scheduler)(nil)
17 |
18 | // Scheduler defines the interface for the Scheduler.
19 | type Scheduler interface {
20 | // Jobs returns all the jobs currently in the scheduler.
21 | Jobs() []Job
22 | // NewJob creates a new job in the Scheduler. The job is scheduled per the provided
23 | // definition when the Scheduler is started. If the Scheduler is already running
24 | // the job will be scheduled when the Scheduler is started.
25 | // If you set the first argument of your Task func to be a context.Context,
26 | // gocron will pass in a context (either the default Job context, or one
27 | // provided via WithContext) to the job and will cancel the context on shutdown.
28 | // This allows you to listen for and handle cancellation within your job.
29 | NewJob(JobDefinition, Task, ...JobOption) (Job, error)
30 | // RemoveByTags removes all jobs that have at least one of the provided tags.
31 | RemoveByTags(...string)
32 | // RemoveJob removes the job with the provided id.
33 | RemoveJob(uuid.UUID) error
34 | // Shutdown should be called when you no longer need
35 | // the Scheduler or Job's as the Scheduler cannot
36 | // be restarted after calling Shutdown. This is similar
37 | // to a Close or Cleanup method and is often deferred after
38 | // starting the scheduler.
39 | Shutdown() error
40 | // Start begins scheduling jobs for execution based
41 | // on each job's definition. Job's added to an already
42 | // running scheduler will be scheduled immediately based
43 | // on definition. Start is non-blocking.
44 | Start()
45 | // StopJobs stops the execution of all jobs in the scheduler.
46 | // This can be useful in situations where jobs need to be
47 | // paused globally and then restarted with Start().
48 | StopJobs() error
49 | // Update replaces the existing Job's JobDefinition with the provided
50 | // JobDefinition. The Job's Job.ID() remains the same.
51 | Update(uuid.UUID, JobDefinition, Task, ...JobOption) (Job, error)
52 | // JobsWaitingInQueue number of jobs waiting in Queue in case of LimitModeWait
53 | // In case of LimitModeReschedule or no limit it will be always zero
54 | JobsWaitingInQueue() int
55 | }
56 |
57 | // -----------------------------------------------
58 | // -----------------------------------------------
59 | // ----------------- Scheduler -------------------
60 | // -----------------------------------------------
61 | // -----------------------------------------------
62 |
63 | type scheduler struct {
64 | // context used for shutting down
65 | shutdownCtx context.Context
66 | // cancel used to signal scheduler should shut down
67 | shutdownCancel context.CancelFunc
68 | // the executor, which actually runs the jobs sent to it via the scheduler
69 | exec executor
70 | // the map of jobs registered in the scheduler
71 | jobs map[uuid.UUID]internalJob
72 | // the location used by the scheduler for scheduling when relevant
73 | location *time.Location
74 | // whether the scheduler has been started or not
75 | started bool
76 | // globally applied JobOption's set on all jobs added to the scheduler
77 | // note: individually set JobOption's take precedence.
78 | globalJobOptions []JobOption
79 | // the scheduler's logger
80 | logger Logger
81 |
82 | // used to tell the scheduler to start
83 | startCh chan struct{}
84 | // used to report that the scheduler has started
85 | startedCh chan struct{}
86 | // used to tell the scheduler to stop
87 | stopCh chan struct{}
88 | // used to report that the scheduler has stopped
89 | stopErrCh chan error
90 | // used to send all the jobs out when a request is made by the client
91 | allJobsOutRequest chan allJobsOutRequest
92 | // used to send a jobs out when a request is made by the client
93 | jobOutRequestCh chan jobOutRequest
94 | // used to run a job on-demand when requested by the client
95 | runJobRequestCh chan runJobRequest
96 | // new jobs are received here
97 | newJobCh chan newJobIn
98 | // requests from the client to remove jobs by ID are received here
99 | removeJobCh chan uuid.UUID
100 | // requests from the client to remove jobs by tags are received here
101 | removeJobsByTagsCh chan []string
102 | }
103 |
104 | type newJobIn struct {
105 | ctx context.Context
106 | cancel context.CancelFunc
107 | job internalJob
108 | }
109 |
110 | type jobOutRequest struct {
111 | id uuid.UUID
112 | outChan chan internalJob
113 | }
114 |
115 | type runJobRequest struct {
116 | id uuid.UUID
117 | outChan chan error
118 | }
119 |
120 | type allJobsOutRequest struct {
121 | outChan chan []Job
122 | }
123 |
124 | // NewScheduler creates a new Scheduler instance.
125 | // The Scheduler is not started until Start() is called.
126 | //
127 | // NewJob will add jobs to the Scheduler, but they will not
128 | // be scheduled until Start() is called.
129 | func NewScheduler(options ...SchedulerOption) (Scheduler, error) {
130 | schCtx, cancel := context.WithCancel(context.Background())
131 |
132 | exec := executor{
133 | stopCh: make(chan struct{}),
134 | stopTimeout: time.Second * 10,
135 | singletonRunners: nil,
136 | logger: &noOpLogger{},
137 | clock: clockwork.NewRealClock(),
138 |
139 | jobsIn: make(chan jobIn),
140 | jobsOutForRescheduling: make(chan uuid.UUID),
141 | jobUpdateNextRuns: make(chan uuid.UUID),
142 | jobsOutCompleted: make(chan uuid.UUID),
143 | jobOutRequest: make(chan jobOutRequest, 1000),
144 | done: make(chan error, 1),
145 | }
146 |
147 | s := &scheduler{
148 | shutdownCtx: schCtx,
149 | shutdownCancel: cancel,
150 | exec: exec,
151 | jobs: make(map[uuid.UUID]internalJob),
152 | location: time.Local,
153 | logger: &noOpLogger{},
154 |
155 | newJobCh: make(chan newJobIn),
156 | removeJobCh: make(chan uuid.UUID),
157 | removeJobsByTagsCh: make(chan []string),
158 | startCh: make(chan struct{}),
159 | startedCh: make(chan struct{}),
160 | stopCh: make(chan struct{}),
161 | stopErrCh: make(chan error, 1),
162 | jobOutRequestCh: make(chan jobOutRequest),
163 | runJobRequestCh: make(chan runJobRequest),
164 | allJobsOutRequest: make(chan allJobsOutRequest),
165 | }
166 |
167 | for _, option := range options {
168 | err := option(s)
169 | if err != nil {
170 | return nil, err
171 | }
172 | }
173 |
174 | go func() {
175 | s.logger.Info("gocron: new scheduler created")
176 | for {
177 | select {
178 | case id := <-s.exec.jobsOutForRescheduling:
179 | s.selectExecJobsOutForRescheduling(id)
180 | case id := <-s.exec.jobUpdateNextRuns:
181 | s.updateNextScheduled(id)
182 | case id := <-s.exec.jobsOutCompleted:
183 | s.selectExecJobsOutCompleted(id)
184 |
185 | case in := <-s.newJobCh:
186 | s.selectNewJob(in)
187 |
188 | case id := <-s.removeJobCh:
189 | s.selectRemoveJob(id)
190 |
191 | case tags := <-s.removeJobsByTagsCh:
192 | s.selectRemoveJobsByTags(tags)
193 |
194 | case out := <-s.exec.jobOutRequest:
195 | s.selectJobOutRequest(out)
196 |
197 | case out := <-s.jobOutRequestCh:
198 | s.selectJobOutRequest(out)
199 |
200 | case out := <-s.allJobsOutRequest:
201 | s.selectAllJobsOutRequest(out)
202 |
203 | case run := <-s.runJobRequestCh:
204 | s.selectRunJobRequest(run)
205 |
206 | case <-s.startCh:
207 | s.selectStart()
208 |
209 | case <-s.stopCh:
210 | s.stopScheduler()
211 |
212 | case <-s.shutdownCtx.Done():
213 | s.stopScheduler()
214 | return
215 | }
216 | }
217 | }()
218 |
219 | return s, nil
220 | }
221 |
222 | // -----------------------------------------------
223 | // -----------------------------------------------
224 | // --------- Scheduler Channel Methods -----------
225 | // -----------------------------------------------
226 | // -----------------------------------------------
227 |
228 | // The scheduler's channel functions are broken out here
229 | // to allow prioritizing within the select blocks. The idea
230 | // being that we want to make sure that scheduling tasks
231 | // are not blocked by requests from the caller for information
232 | // about jobs.
233 |
234 | func (s *scheduler) stopScheduler() {
235 | s.logger.Debug("gocron: stopping scheduler")
236 | if s.started {
237 | s.exec.stopCh <- struct{}{}
238 | }
239 |
240 | for _, j := range s.jobs {
241 | j.stop()
242 | }
243 | for _, j := range s.jobs {
244 | <-j.ctx.Done()
245 | }
246 | var err error
247 | if s.started {
248 | t := time.NewTimer(s.exec.stopTimeout + 1*time.Second)
249 | select {
250 | case err = <-s.exec.done:
251 | t.Stop()
252 | case <-t.C:
253 | err = ErrStopExecutorTimedOut
254 | }
255 | }
256 | for id, j := range s.jobs {
257 | oldCtx := j.ctx
258 | if j.parentCtx == nil {
259 | j.parentCtx = s.shutdownCtx
260 | }
261 | j.ctx, j.cancel = context.WithCancel(j.parentCtx)
262 |
263 | // also replace the old context with the new one in the parameters
264 | if len(j.parameters) > 0 && j.parameters[0] == oldCtx {
265 | j.parameters[0] = j.ctx
266 | }
267 |
268 | s.jobs[id] = j
269 | }
270 |
271 | s.stopErrCh <- err
272 | s.started = false
273 | s.logger.Debug("gocron: scheduler stopped")
274 | }
275 |
276 | func (s *scheduler) selectAllJobsOutRequest(out allJobsOutRequest) {
277 | outJobs := make([]Job, len(s.jobs))
278 | var counter int
279 | for _, j := range s.jobs {
280 | outJobs[counter] = s.jobFromInternalJob(j)
281 | counter++
282 | }
283 | slices.SortFunc(outJobs, func(a, b Job) int {
284 | aID, bID := a.ID().String(), b.ID().String()
285 | return strings.Compare(aID, bID)
286 | })
287 | select {
288 | case <-s.shutdownCtx.Done():
289 | case out.outChan <- outJobs:
290 | }
291 | }
292 |
293 | func (s *scheduler) selectRunJobRequest(run runJobRequest) {
294 | j, ok := s.jobs[run.id]
295 | if !ok {
296 | select {
297 | case run.outChan <- ErrJobNotFound:
298 | default:
299 | }
300 | }
301 | select {
302 | case <-s.shutdownCtx.Done():
303 | select {
304 | case run.outChan <- ErrJobRunNowFailed:
305 | default:
306 | }
307 | case s.exec.jobsIn <- jobIn{
308 | id: j.id,
309 | shouldSendOut: false,
310 | }:
311 | select {
312 | case run.outChan <- nil:
313 | default:
314 | }
315 | }
316 | }
317 |
318 | func (s *scheduler) selectRemoveJob(id uuid.UUID) {
319 | j, ok := s.jobs[id]
320 | if !ok {
321 | return
322 | }
323 | j.stop()
324 | delete(s.jobs, id)
325 | }
326 |
327 | // Jobs coming back from the executor to the scheduler that
328 | // need to be evaluated for rescheduling.
329 | func (s *scheduler) selectExecJobsOutForRescheduling(id uuid.UUID) {
330 | select {
331 | case <-s.shutdownCtx.Done():
332 | return
333 | default:
334 | }
335 | j, ok := s.jobs[id]
336 | if !ok {
337 | // the job was removed while it was running, and
338 | // so we don't need to reschedule it.
339 | return
340 | }
341 |
342 | if j.stopTimeReached(s.now()) {
343 | return
344 | }
345 |
346 | var scheduleFrom time.Time
347 | if len(j.nextScheduled) > 0 {
348 | // always grab the last element in the slice as that is the furthest
349 | // out in the future and the time from which we want to calculate
350 | // the subsequent next run time.
351 | slices.SortStableFunc(j.nextScheduled, ascendingTime)
352 | scheduleFrom = j.nextScheduled[len(j.nextScheduled)-1]
353 | }
354 |
355 | if scheduleFrom.IsZero() {
356 | scheduleFrom = j.startTime
357 | }
358 |
359 | next := j.next(scheduleFrom)
360 | if next.IsZero() {
361 | // the job's next function will return zero for OneTime jobs.
362 | // since they are one time only, they do not need rescheduling.
363 | return
364 | }
365 |
366 | if next.Before(s.now()) {
367 | // in some cases the next run time can be in the past, for example:
368 | // - the time on the machine was incorrect and has been synced with ntp
369 | // - the machine went to sleep, and woke up some time later
370 | // in those cases, we want to increment to the next run in the future
371 | // and schedule the job for that time.
372 | for next.Before(s.now()) {
373 | next = j.next(next)
374 | }
375 | }
376 |
377 | if slices.Contains(j.nextScheduled, next) {
378 | // if the next value is a duplicate of what's already in the nextScheduled slice, for example:
379 | // - the job is being rescheduled off the same next run value as before
380 | // increment to the next, next value
381 | for slices.Contains(j.nextScheduled, next) {
382 | next = j.next(next)
383 | }
384 | }
385 |
386 | // Clean up any existing timer to prevent leaks
387 | if j.timer != nil {
388 | j.timer.Stop()
389 | j.timer = nil // Ensure timer is cleared for GC
390 | }
391 |
392 | j.nextScheduled = append(j.nextScheduled, next)
393 | j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() {
394 | // set the actual timer on the job here and listen for
395 | // shut down events so that the job doesn't attempt to
396 | // run if the scheduler has been shutdown.
397 | select {
398 | case <-s.shutdownCtx.Done():
399 | return
400 | case s.exec.jobsIn <- jobIn{
401 | id: j.id,
402 | shouldSendOut: true,
403 | }:
404 | }
405 | })
406 | // update the job with its new next and last run times and timer.
407 | s.jobs[id] = j
408 | }
409 |
410 | func (s *scheduler) updateNextScheduled(id uuid.UUID) {
411 | j, ok := s.jobs[id]
412 | if !ok {
413 | return
414 | }
415 | var newNextScheduled []time.Time
416 | for _, t := range j.nextScheduled {
417 | if t.Before(s.now()) {
418 | continue
419 | }
420 | newNextScheduled = append(newNextScheduled, t)
421 | }
422 | j.nextScheduled = newNextScheduled
423 | s.jobs[id] = j
424 | }
425 |
426 | func (s *scheduler) selectExecJobsOutCompleted(id uuid.UUID) {
427 | j, ok := s.jobs[id]
428 | if !ok {
429 | return
430 | }
431 |
432 | // if the job has nextScheduled time in the past,
433 | // we need to remove any that are in the past.
434 | var newNextScheduled []time.Time
435 | for _, t := range j.nextScheduled {
436 | if t.Before(s.now()) {
437 | continue
438 | }
439 | newNextScheduled = append(newNextScheduled, t)
440 | }
441 | j.nextScheduled = newNextScheduled
442 |
443 | // if the job has a limited number of runs set, we need to
444 | // check how many runs have occurred and stop running this
445 | // job if it has reached the limit.
446 | if j.limitRunsTo != nil {
447 | j.limitRunsTo.runCount = j.limitRunsTo.runCount + 1
448 | if j.limitRunsTo.runCount == j.limitRunsTo.limit {
449 | go func() {
450 | select {
451 | case <-s.shutdownCtx.Done():
452 | return
453 | case s.removeJobCh <- id:
454 | }
455 | }()
456 | return
457 | }
458 | }
459 |
460 | j.lastRun = s.now()
461 | s.jobs[id] = j
462 | }
463 |
464 | func (s *scheduler) selectJobOutRequest(out jobOutRequest) {
465 | if j, ok := s.jobs[out.id]; ok {
466 | select {
467 | case out.outChan <- j:
468 | case <-s.shutdownCtx.Done():
469 | }
470 | }
471 | close(out.outChan)
472 | }
473 |
474 | func (s *scheduler) selectNewJob(in newJobIn) {
475 | j := in.job
476 | if s.started {
477 | next := j.startTime
478 | if j.startImmediately {
479 | next = s.now()
480 | select {
481 | case <-s.shutdownCtx.Done():
482 | case s.exec.jobsIn <- jobIn{
483 | id: j.id,
484 | shouldSendOut: true,
485 | }:
486 | }
487 | } else {
488 | if next.IsZero() {
489 | next = j.next(s.now())
490 | }
491 |
492 | id := j.id
493 | j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() {
494 | select {
495 | case <-s.shutdownCtx.Done():
496 | case s.exec.jobsIn <- jobIn{
497 | id: id,
498 | shouldSendOut: true,
499 | }:
500 | }
501 | })
502 | }
503 | j.startTime = next
504 | j.nextScheduled = append(j.nextScheduled, next)
505 | }
506 |
507 | s.jobs[j.id] = j
508 | in.cancel()
509 | }
510 |
511 | func (s *scheduler) selectRemoveJobsByTags(tags []string) {
512 | for _, j := range s.jobs {
513 | for _, tag := range tags {
514 | if slices.Contains(j.tags, tag) {
515 | j.stop()
516 | delete(s.jobs, j.id)
517 | break
518 | }
519 | }
520 | }
521 | }
522 |
523 | func (s *scheduler) selectStart() {
524 | s.logger.Debug("gocron: scheduler starting")
525 | go s.exec.start()
526 |
527 | s.started = true
528 | for id, j := range s.jobs {
529 | next := j.startTime
530 | if j.startImmediately {
531 | next = s.now()
532 | select {
533 | case <-s.shutdownCtx.Done():
534 | case s.exec.jobsIn <- jobIn{
535 | id: id,
536 | shouldSendOut: true,
537 | }:
538 | }
539 | } else {
540 | if next.IsZero() {
541 | next = j.next(s.now())
542 | }
543 |
544 | jobID := id
545 | j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() {
546 | select {
547 | case <-s.shutdownCtx.Done():
548 | case s.exec.jobsIn <- jobIn{
549 | id: jobID,
550 | shouldSendOut: true,
551 | }:
552 | }
553 | })
554 | }
555 | j.startTime = next
556 | j.nextScheduled = append(j.nextScheduled, next)
557 | s.jobs[id] = j
558 | }
559 | select {
560 | case <-s.shutdownCtx.Done():
561 | case s.startedCh <- struct{}{}:
562 | s.logger.Info("gocron: scheduler started")
563 | }
564 | }
565 |
566 | // -----------------------------------------------
567 | // -----------------------------------------------
568 | // ------------- Scheduler Methods ---------------
569 | // -----------------------------------------------
570 | // -----------------------------------------------
571 |
572 | func (s *scheduler) now() time.Time {
573 | return s.exec.clock.Now().In(s.location)
574 | }
575 |
576 | func (s *scheduler) jobFromInternalJob(in internalJob) job {
577 | return job{
578 | in.id,
579 | in.name,
580 | slices.Clone(in.tags),
581 | s.jobOutRequestCh,
582 | s.runJobRequestCh,
583 | }
584 | }
585 |
586 | func (s *scheduler) Jobs() []Job {
587 | outChan := make(chan []Job)
588 | select {
589 | case <-s.shutdownCtx.Done():
590 | case s.allJobsOutRequest <- allJobsOutRequest{outChan: outChan}:
591 | }
592 |
593 | var jobs []Job
594 | select {
595 | case <-s.shutdownCtx.Done():
596 | case jobs = <-outChan:
597 | }
598 |
599 | return jobs
600 | }
601 |
602 | func (s *scheduler) NewJob(jobDefinition JobDefinition, task Task, options ...JobOption) (Job, error) {
603 | return s.addOrUpdateJob(uuid.Nil, jobDefinition, task, options)
604 | }
605 |
606 | func (s *scheduler) verifyInterfaceVariadic(taskFunc reflect.Value, tsk task, variadicStart int) error {
607 | ifaceType := taskFunc.Type().In(variadicStart).Elem()
608 | for i := variadicStart; i < len(tsk.parameters); i++ {
609 | if !reflect.TypeOf(tsk.parameters[i]).Implements(ifaceType) {
610 | return ErrNewJobWrongTypeOfParameters
611 | }
612 | }
613 | return nil
614 | }
615 |
616 | func (s *scheduler) verifyVariadic(taskFunc reflect.Value, tsk task, variadicStart int) error {
617 | if err := s.verifyNonVariadic(taskFunc, tsk, variadicStart); err != nil {
618 | return err
619 | }
620 | parameterType := taskFunc.Type().In(variadicStart).Elem().Kind()
621 | if parameterType == reflect.Interface {
622 | return s.verifyInterfaceVariadic(taskFunc, tsk, variadicStart)
623 | }
624 | if parameterType == reflect.Pointer {
625 | parameterType = reflect.Indirect(reflect.ValueOf(taskFunc.Type().In(variadicStart))).Kind()
626 | }
627 |
628 | for i := variadicStart; i < len(tsk.parameters); i++ {
629 | argumentType := reflect.TypeOf(tsk.parameters[i]).Kind()
630 | if argumentType == reflect.Interface || argumentType == reflect.Pointer {
631 | argumentType = reflect.TypeOf(tsk.parameters[i]).Elem().Kind()
632 | }
633 | if argumentType != parameterType {
634 | return ErrNewJobWrongTypeOfParameters
635 | }
636 | }
637 | return nil
638 | }
639 |
640 | func (s *scheduler) verifyNonVariadic(taskFunc reflect.Value, tsk task, length int) error {
641 | for i := 0; i < length; i++ {
642 | t1 := reflect.TypeOf(tsk.parameters[i]).Kind()
643 | if t1 == reflect.Interface || t1 == reflect.Pointer {
644 | t1 = reflect.TypeOf(tsk.parameters[i]).Elem().Kind()
645 | }
646 | t2 := reflect.New(taskFunc.Type().In(i)).Elem().Kind()
647 | if t2 == reflect.Interface || t2 == reflect.Pointer {
648 | t2 = reflect.Indirect(reflect.ValueOf(taskFunc.Type().In(i))).Kind()
649 | }
650 | if t1 != t2 {
651 | return ErrNewJobWrongTypeOfParameters
652 | }
653 | }
654 | return nil
655 | }
656 |
657 | func (s *scheduler) verifyParameterType(taskFunc reflect.Value, tsk task) error {
658 | isVariadic := taskFunc.Type().IsVariadic()
659 | if isVariadic {
660 | variadicStart := taskFunc.Type().NumIn() - 1
661 | return s.verifyVariadic(taskFunc, tsk, variadicStart)
662 | }
663 | expectedParameterLength := taskFunc.Type().NumIn()
664 | if len(tsk.parameters) != expectedParameterLength {
665 | return ErrNewJobWrongNumberOfParameters
666 | }
667 | return s.verifyNonVariadic(taskFunc, tsk, expectedParameterLength)
668 | }
669 |
670 | func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition, taskWrapper Task, options []JobOption) (Job, error) {
671 | j := internalJob{}
672 | if id == uuid.Nil {
673 | j.id = uuid.New()
674 | } else {
675 | currentJob := requestJobCtx(s.shutdownCtx, id, s.jobOutRequestCh)
676 | if currentJob != nil && currentJob.id != uuid.Nil {
677 | select {
678 | case <-s.shutdownCtx.Done():
679 | return nil, nil
680 | case s.removeJobCh <- id:
681 | <-currentJob.ctx.Done()
682 | }
683 | }
684 |
685 | j.id = id
686 | }
687 |
688 | if taskWrapper == nil {
689 | return nil, ErrNewJobTaskNil
690 | }
691 |
692 | tsk := taskWrapper()
693 | taskFunc := reflect.ValueOf(tsk.function)
694 | for taskFunc.Kind() == reflect.Ptr {
695 | taskFunc = taskFunc.Elem()
696 | }
697 |
698 | if taskFunc.Kind() != reflect.Func {
699 | return nil, ErrNewJobTaskNotFunc
700 | }
701 |
702 | j.name = runtime.FuncForPC(taskFunc.Pointer()).Name()
703 | j.function = tsk.function
704 | j.parameters = tsk.parameters
705 |
706 | // apply global job options
707 | for _, option := range s.globalJobOptions {
708 | if err := option(&j, s.now()); err != nil {
709 | return nil, err
710 | }
711 | }
712 |
713 | // apply job specific options, which take precedence
714 | for _, option := range options {
715 | if err := option(&j, s.now()); err != nil {
716 | return nil, err
717 | }
718 | }
719 |
720 | if j.parentCtx == nil {
721 | j.parentCtx = s.shutdownCtx
722 | }
723 | j.ctx, j.cancel = context.WithCancel(j.parentCtx)
724 |
725 | if !taskFunc.IsZero() && taskFunc.Type().NumIn() > 0 {
726 | // if the first parameter is a context.Context and params have no context.Context, add current ctx to the params
727 | if taskFunc.Type().In(0) == reflect.TypeOf((*context.Context)(nil)).Elem() {
728 | if len(tsk.parameters) == 0 {
729 | tsk.parameters = []any{j.ctx}
730 | j.parameters = []any{j.ctx}
731 | } else if _, ok := tsk.parameters[0].(context.Context); !ok {
732 | tsk.parameters = append([]any{j.ctx}, tsk.parameters...)
733 | j.parameters = append([]any{j.ctx}, j.parameters...)
734 | }
735 | }
736 | }
737 |
738 | if err := s.verifyParameterType(taskFunc, tsk); err != nil {
739 | return nil, err
740 | }
741 |
742 | if err := definition.setup(&j, s.location, s.exec.clock.Now()); err != nil {
743 | return nil, err
744 | }
745 |
746 | newJobCtx, newJobCancel := context.WithCancel(context.Background())
747 | select {
748 | case <-s.shutdownCtx.Done():
749 | case s.newJobCh <- newJobIn{
750 | ctx: newJobCtx,
751 | cancel: newJobCancel,
752 | job: j,
753 | }:
754 | }
755 |
756 | select {
757 | case <-newJobCtx.Done():
758 | case <-s.shutdownCtx.Done():
759 | }
760 |
761 | out := s.jobFromInternalJob(j)
762 | return &out, nil
763 | }
764 |
765 | func (s *scheduler) RemoveByTags(tags ...string) {
766 | select {
767 | case <-s.shutdownCtx.Done():
768 | case s.removeJobsByTagsCh <- tags:
769 | }
770 | }
771 |
772 | func (s *scheduler) RemoveJob(id uuid.UUID) error {
773 | j := requestJobCtx(s.shutdownCtx, id, s.jobOutRequestCh)
774 | if j == nil || j.id == uuid.Nil {
775 | return ErrJobNotFound
776 | }
777 | select {
778 | case <-s.shutdownCtx.Done():
779 | case s.removeJobCh <- id:
780 | }
781 |
782 | return nil
783 | }
784 |
785 | func (s *scheduler) Start() {
786 | select {
787 | case <-s.shutdownCtx.Done():
788 | case s.startCh <- struct{}{}:
789 | <-s.startedCh
790 | }
791 | }
792 |
793 | func (s *scheduler) StopJobs() error {
794 | select {
795 | case <-s.shutdownCtx.Done():
796 | return nil
797 | case s.stopCh <- struct{}{}:
798 | }
799 |
800 | t := time.NewTimer(s.exec.stopTimeout + 2*time.Second)
801 | select {
802 | case err := <-s.stopErrCh:
803 | t.Stop()
804 | return err
805 | case <-t.C:
806 | return ErrStopSchedulerTimedOut
807 | }
808 | }
809 |
810 | func (s *scheduler) Shutdown() error {
811 | s.shutdownCancel()
812 |
813 | t := time.NewTimer(s.exec.stopTimeout + 2*time.Second)
814 | select {
815 | case err := <-s.stopErrCh:
816 |
817 | t.Stop()
818 | return err
819 | case <-t.C:
820 | return ErrStopSchedulerTimedOut
821 | }
822 | }
823 |
824 | func (s *scheduler) Update(id uuid.UUID, jobDefinition JobDefinition, task Task, options ...JobOption) (Job, error) {
825 | return s.addOrUpdateJob(id, jobDefinition, task, options)
826 | }
827 |
828 | func (s *scheduler) JobsWaitingInQueue() int {
829 | if s.exec.limitMode != nil && s.exec.limitMode.mode == LimitModeWait {
830 | return len(s.exec.limitMode.in)
831 | }
832 | return 0
833 | }
834 |
835 | // -----------------------------------------------
836 | // -----------------------------------------------
837 | // ------------- Scheduler Options ---------------
838 | // -----------------------------------------------
839 | // -----------------------------------------------
840 |
841 | // SchedulerOption defines the function for setting
842 | // options on the Scheduler.
843 | type SchedulerOption func(*scheduler) error
844 |
845 | // WithClock sets the clock used by the Scheduler
846 | // to the clock provided. See https://github.com/jonboulle/clockwork
847 | func WithClock(clock clockwork.Clock) SchedulerOption {
848 | return func(s *scheduler) error {
849 | if clock == nil {
850 | return ErrWithClockNil
851 | }
852 | s.exec.clock = clock
853 | return nil
854 | }
855 | }
856 |
857 | // WithDistributedElector sets the elector to be used by multiple
858 | // Scheduler instances to determine who should be the leader.
859 | // Only the leader runs jobs, while non-leaders wait and continue
860 | // to check if a new leader has been elected.
861 | func WithDistributedElector(elector Elector) SchedulerOption {
862 | return func(s *scheduler) error {
863 | if elector == nil {
864 | return ErrWithDistributedElectorNil
865 | }
866 | s.exec.elector = elector
867 | return nil
868 | }
869 | }
870 |
871 | // WithDistributedLocker sets the locker to be used by multiple
872 | // Scheduler instances to ensure that only one instance of each
873 | // job is run.
874 | // To disable this global locker for specific jobs, see
875 | // WithDisabledDistributedJobLocker.
876 | func WithDistributedLocker(locker Locker) SchedulerOption {
877 | return func(s *scheduler) error {
878 | if locker == nil {
879 | return ErrWithDistributedLockerNil
880 | }
881 | s.exec.locker = locker
882 | return nil
883 | }
884 | }
885 |
886 | // WithGlobalJobOptions sets JobOption's that will be applied to
887 | // all jobs added to the scheduler. JobOption's set on the job
888 | // itself will override if the same JobOption is set globally.
889 | func WithGlobalJobOptions(jobOptions ...JobOption) SchedulerOption {
890 | return func(s *scheduler) error {
891 | s.globalJobOptions = jobOptions
892 | return nil
893 | }
894 | }
895 |
896 | // LimitMode defines the modes used for handling jobs that reach
897 | // the limit provided in WithLimitConcurrentJobs
898 | type LimitMode int
899 |
900 | const (
901 | // LimitModeReschedule causes jobs reaching the limit set in
902 | // WithLimitConcurrentJobs or WithSingletonMode to be skipped
903 | // and rescheduled for the next run time rather than being
904 | // queued up to wait.
905 | LimitModeReschedule = 1
906 |
907 | // LimitModeWait causes jobs reaching the limit set in
908 | // WithLimitConcurrentJobs or WithSingletonMode to wait
909 | // in a queue until a slot becomes available to run.
910 | //
911 | // Note: this mode can produce unpredictable results as
912 | // job execution order isn't guaranteed. For example, a job that
913 | // executes frequently may pile up in the wait queue and be executed
914 | // many times back to back when the queue opens.
915 | //
916 | // Warning: do not use this mode if your jobs will continue to stack
917 | // up beyond the ability of the limit workers to keep up. An example of
918 | // what NOT to do:
919 | //
920 | // s, _ := gocron.NewScheduler(gocron.WithLimitConcurrentJobs)
921 | // s.NewJob(
922 | // gocron.DurationJob(
923 | // time.Second,
924 | // Task{
925 | // Function: func() {
926 | // time.Sleep(10 * time.Second)
927 | // },
928 | // },
929 | // ),
930 | // )
931 | LimitModeWait = 2
932 | )
933 |
934 | // WithLimitConcurrentJobs sets the limit and mode to be used by the
935 | // Scheduler for limiting the number of jobs that may be running at
936 | // a given time.
937 | //
938 | // Note: the limit mode selected for WithLimitConcurrentJobs takes initial
939 | // precedence in the event you are also running a limit mode at the job level
940 | // using WithSingletonMode.
941 | //
942 | // Warning: a single time consuming job can dominate your limit in the event
943 | // you are running both the scheduler limit WithLimitConcurrentJobs(1, LimitModeWait)
944 | // and a job limit WithSingletonMode(LimitModeReschedule).
945 | func WithLimitConcurrentJobs(limit uint, mode LimitMode) SchedulerOption {
946 | return func(s *scheduler) error {
947 | if limit == 0 {
948 | return ErrWithLimitConcurrentJobsZero
949 | }
950 | s.exec.limitMode = &limitModeConfig{
951 | mode: mode,
952 | limit: limit,
953 | in: make(chan jobIn, 1000),
954 | singletonJobs: make(map[uuid.UUID]struct{}),
955 | }
956 | if mode == LimitModeReschedule {
957 | s.exec.limitMode.rescheduleLimiter = make(chan struct{}, limit)
958 | }
959 | return nil
960 | }
961 | }
962 |
963 | // WithLocation sets the location (i.e. timezone) that the scheduler
964 | // should operate within. In many systems time.Local is UTC.
965 | // Default: time.Local
966 | func WithLocation(location *time.Location) SchedulerOption {
967 | return func(s *scheduler) error {
968 | if location == nil {
969 | return ErrWithLocationNil
970 | }
971 | s.location = location
972 | return nil
973 | }
974 | }
975 |
976 | // WithLogger sets the logger to be used by the Scheduler.
977 | func WithLogger(logger Logger) SchedulerOption {
978 | return func(s *scheduler) error {
979 | if logger == nil {
980 | return ErrWithLoggerNil
981 | }
982 | s.logger = logger
983 | s.exec.logger = logger
984 | return nil
985 | }
986 | }
987 |
988 | // WithStopTimeout sets the amount of time the Scheduler should
989 | // wait gracefully for jobs to complete before returning when
990 | // StopJobs() or Shutdown() are called.
991 | // Default: 10 * time.Second
992 | func WithStopTimeout(timeout time.Duration) SchedulerOption {
993 | return func(s *scheduler) error {
994 | if timeout <= 0 {
995 | return ErrWithStopTimeoutZeroOrNegative
996 | }
997 | s.exec.stopTimeout = timeout
998 | return nil
999 | }
1000 | }
1001 |
1002 | // WithMonitor sets the metrics provider to be used by the Scheduler.
1003 | func WithMonitor(monitor Monitor) SchedulerOption {
1004 | return func(s *scheduler) error {
1005 | if monitor == nil {
1006 | return ErrWithMonitorNil
1007 | }
1008 | s.exec.monitor = monitor
1009 | return nil
1010 | }
1011 | }
1012 |
1013 | // WithMonitorStatus sets the metrics provider to be used by the Scheduler.
1014 | func WithMonitorStatus(monitor MonitorStatus) SchedulerOption {
1015 | return func(s *scheduler) error {
1016 | if monitor == nil {
1017 | return ErrWithMonitorNil
1018 | }
1019 | s.exec.monitorStatus = monitor
1020 | return nil
1021 | }
1022 | }
1023 |
--------------------------------------------------------------------------------
/util.go:
--------------------------------------------------------------------------------
1 | package gocron
2 |
3 | import (
4 | "context"
5 | "reflect"
6 | "slices"
7 | "sync"
8 | "time"
9 |
10 | "github.com/google/uuid"
11 | )
12 |
13 | func callJobFuncWithParams(jobFunc any, params ...any) error {
14 | if jobFunc == nil {
15 | return nil
16 | }
17 | f := reflect.ValueOf(jobFunc)
18 | if f.IsZero() {
19 | return nil
20 | }
21 | if len(params) != f.Type().NumIn() {
22 | return nil
23 | }
24 | in := make([]reflect.Value, len(params))
25 | for k, param := range params {
26 | in[k] = reflect.ValueOf(param)
27 | }
28 | returnValues := f.Call(in)
29 | for _, val := range returnValues {
30 | i := val.Interface()
31 | if err, ok := i.(error); ok {
32 | return err
33 | }
34 | }
35 | return nil
36 | }
37 |
38 | func requestJob(id uuid.UUID, ch chan jobOutRequest) *internalJob {
39 | ctx, cancel := context.WithTimeout(context.Background(), time.Second)
40 | defer cancel()
41 | return requestJobCtx(ctx, id, ch)
42 | }
43 |
44 | func requestJobCtx(ctx context.Context, id uuid.UUID, ch chan jobOutRequest) *internalJob {
45 | resp := make(chan internalJob, 1)
46 | select {
47 | case ch <- jobOutRequest{
48 | id: id,
49 | outChan: resp,
50 | }:
51 | case <-ctx.Done():
52 | return nil
53 | }
54 | var j internalJob
55 | select {
56 | case <-ctx.Done():
57 | return nil
58 | case jobReceived := <-resp:
59 | j = jobReceived
60 | }
61 | return &j
62 | }
63 |
64 | func removeSliceDuplicatesInt(in []int) []int {
65 | slices.Sort(in)
66 | return slices.Compact(in)
67 | }
68 |
69 | func convertAtTimesToDateTime(atTimes AtTimes, location *time.Location) ([]time.Time, error) {
70 | if atTimes == nil {
71 | return nil, errAtTimesNil
72 | }
73 | var atTimesDate []time.Time
74 | for _, a := range atTimes() {
75 | if a == nil {
76 | return nil, errAtTimeNil
77 | }
78 | at := a()
79 | if at.hours > 23 {
80 | return nil, errAtTimeHours
81 | } else if at.minutes > 59 || at.seconds > 59 {
82 | return nil, errAtTimeMinSec
83 | }
84 | atTimesDate = append(atTimesDate, at.time(location))
85 | }
86 | slices.SortStableFunc(atTimesDate, ascendingTime)
87 | return atTimesDate, nil
88 | }
89 |
90 | func ascendingTime(a, b time.Time) int {
91 | return a.Compare(b)
92 | }
93 |
94 | type waitGroupWithMutex struct {
95 | wg sync.WaitGroup
96 | mu sync.Mutex
97 | }
98 |
99 | func (w *waitGroupWithMutex) Add(delta int) {
100 | w.mu.Lock()
101 | defer w.mu.Unlock()
102 | w.wg.Add(delta)
103 | }
104 |
105 | func (w *waitGroupWithMutex) Done() {
106 | w.wg.Done()
107 | }
108 |
109 | func (w *waitGroupWithMutex) Wait() {
110 | w.mu.Lock()
111 | defer w.mu.Unlock()
112 | w.wg.Wait()
113 | }
114 |
--------------------------------------------------------------------------------
/util_test.go:
--------------------------------------------------------------------------------
1 | package gocron
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestRemoveSliceDuplicatesInt(t *testing.T) {
12 | tests := []struct {
13 | name string
14 | input []int
15 | expected []int
16 | }{
17 | {
18 | "lots of duplicates",
19 | []int{
20 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
21 | 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
22 | 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
23 | 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
24 | 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
25 | },
26 | []int{1, 2, 3, 4, 5},
27 | },
28 | }
29 |
30 | for _, tt := range tests {
31 | t.Run(tt.name, func(t *testing.T) {
32 | result := removeSliceDuplicatesInt(tt.input)
33 | assert.ElementsMatch(t, tt.expected, result)
34 | })
35 | }
36 | }
37 |
38 | func TestCallJobFuncWithParams(t *testing.T) {
39 | type f1 func()
40 | tests := []struct {
41 | name string
42 | jobFunc any
43 | params []any
44 | expectedErr error
45 | }{
46 | {
47 | "nil jobFunc",
48 | nil,
49 | nil,
50 | nil,
51 | },
52 | {
53 | "zero jobFunc",
54 | f1(nil),
55 | nil,
56 | nil,
57 | },
58 | {
59 | "wrong number of params",
60 | func(_ string, _ int) {},
61 | []any{"one"},
62 | nil,
63 | },
64 | {
65 | "function that returns an error",
66 | func() error {
67 | return fmt.Errorf("test error")
68 | },
69 | nil,
70 | fmt.Errorf("test error"),
71 | },
72 | {
73 | "function that returns no error",
74 | func() error {
75 | return nil
76 | },
77 | nil,
78 | nil,
79 | },
80 | }
81 |
82 | for _, tt := range tests {
83 | t.Run(tt.name, func(t *testing.T) {
84 | err := callJobFuncWithParams(tt.jobFunc, tt.params...)
85 | assert.Equal(t, tt.expectedErr, err)
86 | })
87 | }
88 | }
89 |
90 | func TestConvertAtTimesToDateTime(t *testing.T) {
91 | tests := []struct {
92 | name string
93 | atTimes AtTimes
94 | location *time.Location
95 | expected []time.Time
96 | err error
97 | }{
98 | {
99 | "atTimes is nil",
100 | nil,
101 | time.UTC,
102 | nil,
103 | errAtTimesNil,
104 | },
105 | {
106 | "atTime is nil",
107 | NewAtTimes(nil),
108 | time.UTC,
109 | nil,
110 | errAtTimeNil,
111 | },
112 | {
113 | "atTimes hours is invalid",
114 | NewAtTimes(
115 | NewAtTime(24, 0, 0),
116 | ),
117 | time.UTC,
118 | nil,
119 | errAtTimeHours,
120 | },
121 | {
122 | "atTimes minutes are invalid",
123 | NewAtTimes(
124 | NewAtTime(0, 60, 0),
125 | ),
126 | time.UTC,
127 | nil,
128 | errAtTimeMinSec,
129 | },
130 | {
131 | "atTimes seconds are invalid",
132 | NewAtTimes(
133 | NewAtTime(0, 0, 60),
134 | ),
135 | time.UTC,
136 | nil,
137 | errAtTimeMinSec,
138 | },
139 | {
140 | "atTimes valid",
141 | NewAtTimes(
142 | NewAtTime(0, 0, 3),
143 | NewAtTime(0, 0, 0),
144 | NewAtTime(0, 0, 1),
145 | NewAtTime(0, 0, 2),
146 | ),
147 | time.UTC,
148 | []time.Time{
149 | time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
150 | time.Date(0, 0, 0, 0, 0, 1, 0, time.UTC),
151 | time.Date(0, 0, 0, 0, 0, 2, 0, time.UTC),
152 | time.Date(0, 0, 0, 0, 0, 3, 0, time.UTC),
153 | },
154 | nil,
155 | },
156 | }
157 |
158 | for _, tt := range tests {
159 | t.Run(tt.name, func(t *testing.T) {
160 | result, err := convertAtTimesToDateTime(tt.atTimes, tt.location)
161 | assert.Equal(t, tt.expected, result)
162 | assert.Equal(t, tt.err, err)
163 | })
164 | }
165 | }
166 |
--------------------------------------------------------------------------------