├── .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 | [![CI State](https://github.com/go-co-op/gocron/actions/workflows/go_test.yml/badge.svg?branch=v2&event=push)](https://github.com/go-co-op/gocron/actions) 4 | ![Go Report Card](https://goreportcard.com/badge/github.com/go-co-op/gocron) [![Go Doc](https://godoc.org/github.com/go-co-op/gocron/v2?status.svg)](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 | JetBrains logo 181 | 182 | 183 | 184 | [Sentry](https://sentry.io/welcome/) 185 | 186 | 187 | 188 | 189 | 190 | Sentry logo 191 | 192 | 193 | 194 | ## Star History 195 | 196 | 197 | 198 | 199 | 200 | Star History Chart 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 | --------------------------------------------------------------------------------