Repository: go-co-op/gocron
Branch: v2
Commit: 5b307f56dd52
Files: 44
Total size: 298.0 KB
Directory structure:
gitextract_1w8mopnk/
├── .github/
│ ├── FUNDING.yml
│ ├── copilot-instructions.md
│ ├── 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
├── distributed.go
├── errors.go
├── example_test.go
├── examples/
│ └── elector/
│ └── main.go
├── executor.go
├── go.mod
├── go.sum
├── gocron-monitor-test/
│ ├── debug_restart.go
│ ├── go.mod
│ └── go.sum
├── job.go
├── job_test.go
├── logger.go
├── logger_test.go
├── migration_v1_to_v2.md
├── mocks/
│ ├── README.md
│ ├── distributed.go
│ ├── go.mod
│ ├── go.sum
│ ├── job.go
│ ├── logger.go
│ └── scheduler.go
├── monitor.go
├── scheduler.go
├── scheduler_monitor.go
├── scheduler_monitor_test.go
├── scheduler_test.go
├── util.go
└── util_test.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: go-co-op # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: gocron
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/copilot-instructions.md
================================================
# gocron: Go Job Scheduling Library
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
## Working Effectively
### Bootstrap and Build Commands
- Install dependencies: `go mod tidy`
- Build the library: `go build -v ./...`
- Install required tools:
- `go install go.uber.org/mock/mockgen@latest`
- `export PATH=$PATH:$(go env GOPATH)/bin` (add to shell profile)
- Generate mocks: `make mocks`
- Format code: `make fmt`
### Testing Commands
- Run all tests: `make test` -- takes 50 seconds. NEVER CANCEL. Set timeout to 90+ seconds.
- Run CI tests: `make test_ci` -- takes 50 seconds. NEVER CANCEL. Set timeout to 90+ seconds.
- Run with coverage: `make test_coverage` -- takes 50 seconds. NEVER CANCEL. Set timeout to 90+ seconds.
- Run specific tests: `go test -v -race -count=1 ./...`
### Linting Commands
- Format verification: `grep "^func [a-zA-Z]" example_test.go | sort -c`
- Full linting: `make lint` -- MAY FAIL due to golangci-lint config compatibility issues. This is a known issue.
- Alternative basic linting: `go vet ./...` and `gofmt -d .`
## Validation
### Required Validation Steps
- ALWAYS run `make test` before submitting changes. Tests must pass.
- ALWAYS run `make fmt` to ensure proper formatting.
- ALWAYS run `make mocks` if you change interface definitions.
- ALWAYS verify examples still work by running them: `cd examples/elector && go run main.go`
### Manual Testing Scenarios
Since this is a library, not an application, testing involves:
1. **Basic Scheduler Creation**: Verify you can create a scheduler with `gocron.NewScheduler()`
2. **Job Creation**: Verify you can create jobs with various `JobDefinition` types
3. **Scheduler Lifecycle**: Verify Start() and Shutdown() work correctly
4. **Example Validation**: Run examples in `examples/` directory to ensure functionality
Example validation script:
```go
package main
import (
"fmt"
"time"
"github.com/go-co-op/gocron/v2"
)
func main() {
s, err := gocron.NewScheduler()
if err != nil { panic(err) }
j, err := s.NewJob(
gocron.DurationJob(2*time.Second),
gocron.NewTask(func() { fmt.Println("Working!") }),
)
if err != nil { panic(err) }
fmt.Printf("Job ID: %s\n", j.ID())
s.Start()
time.Sleep(6 * time.Second)
s.Shutdown()
}
```
### CI Requirements
The CI will fail if:
- Tests don't pass (`make test_ci`)
- Function order in `example_test.go` is incorrect
- golangci-lint finds issues (though config compatibility varies)
## Common Tasks
### Repository Structure
```
.
├── README.md # Main documentation
├── CONTRIBUTING.md # Contribution guidelines
├── SECURITY.md # Security policy
├── Makefile # Build automation
├── go.mod # Go module definition
├── .github/ # GitHub workflows and configs
├── .golangci.yaml # Linting configuration
├── examples/ # Usage examples
│ └── elector/ # Distributed elector example
├── mocks/ # Generated mock files
├── *.go # Library source files
└── *_test.go # Test files
```
### Key Source Files
- `scheduler.go` - Main scheduler implementation
- `job.go` - Job definitions and scheduling logic
- `executor.go` - Job execution engine
- `logger.go` - Logging interfaces and implementations
- `distributed.go` - Distributed scheduling support
- `monitor.go` - Job monitoring interfaces
- `util.go` - Utility functions
- `errors.go` - Error definitions
### Dependencies and Versions
- Requires Go 1.23.0+
- Key dependencies automatically managed via `go mod`:
- `github.com/google/uuid` - UUID generation
- `github.com/jonboulle/clockwork` - Time mocking for tests
- `github.com/robfig/cron/v3` - Cron expression parsing
- `github.com/stretchr/testify` - Testing utilities
- `go.uber.org/goleak` - Goroutine leak detection
### Testing Patterns
- Uses table-driven tests following Go best practices
- Extensive use of goroutine leak detection (may be skipped in CI via TEST_ENV)
- Mock-based testing for interfaces
- Race condition detection enabled (`-race` flag)
- 93.8% test coverage expected
### Build and Release
- No application to build - this is a library
- Version managed via Git tags (v2.x.x)
- Distribution via Go module system
- CI tests on Go 1.23 and 1.24
## Troubleshooting
### Common Issues
1. **mockgen not found**: Install with `go install go.uber.org/mock/mockgen@latest`
2. **golangci-lint config errors**: Known compatibility issue - use `go vet` instead
3. **Test timeouts**: Tests can take 50+ seconds, always set adequate timeouts
4. **PATH issues**: Ensure `$(go env GOPATH)/bin` is in PATH
5. **Import errors in examples**: Run `go mod tidy` to resolve dependencies
### Expected Timings
- `make test`: ~50 seconds
- `make test_coverage`: ~50 seconds
- `make test_ci`: ~50 seconds
- `go build`: ~5 seconds
- `make mocks`: ~2 seconds
- `make fmt`: <1 second
### Known Limitations
- golangci-lint configuration may have compatibility issues with certain versions
- Some tests are skipped in CI environments (controlled by TEST_ENV variable)
- Examples directory has no tests but should be manually validated
================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Maintain Go dependencies
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ v2 ]
branches-ignore:
- "dependabot/**"
pull_request:
paths-ignore:
- '**.md'
# The branches below must be a subset of the branches above
branches: [ v2 ]
schedule:
- cron: '34 7 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# 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
steps:
- name: Checkout repository
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
================================================
FILE: .github/workflows/file_formatting.yml
================================================
on:
push:
branches:
- v2
pull_request:
branches:
- v2
name: formatting
jobs:
check-sorted:
name: check sorted
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v6
- name: verify example_test.go
run: |
grep "^func [a-z-A-Z]" example_test.go | sort -c
================================================
FILE: .github/workflows/go_test.yml
================================================
on:
push:
branches:
- v2
pull_request:
branches:
- v2
name: lint and test
jobs:
golangci:
strategy:
matrix:
go-version:
- "1.24"
- "1.25"
name: lint and test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v9.2.0
with:
version: v2.4.0
- name: test
run: make test_ci
================================================
FILE: .gitignore
================================================
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
local_testing
coverage.out
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
vendor/
# IDE project files
.idea
================================================
FILE: .golangci.yaml
================================================
version: "2"
run:
issues-exit-code: 1
tests: true
output:
formats:
text:
path: stdout
print-linter-name: true
print-issued-lines: true
path-prefix: ""
linters:
enable:
- bodyclose
- copyloopvar
- misspell
- revive
- whitespace
exclusions:
generated: lax
presets:
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- revive
path: example_test.go
text: seems to be unused
- linters:
- revive
text: package-comments
paths:
- local
- third_party$
- builtin$
- examples$
issues:
max-same-issues: 100
fix: true
formatters:
enable:
- gofumpt
- goimports
exclusions:
generated: lax
paths:
- local
- third_party$
- builtin$
- examples$
================================================
FILE: .pre-commit-config.yaml
================================================
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
- id: check-case-conflict
- id: check-merge-conflict
- id: check-yaml
- id: detect-private-key
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/golangci/golangci-lint
rev: v2.4.0
hooks:
- id: golangci-lint
- repo: https://github.com/TekWizely/pre-commit-golang
rev: v1.0.0-rc.1
hooks:
- id: go-fumpt
args:
- -w
- id: go-mod-tidy
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone. And we mean everyone!
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and kind language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team initially on Slack to coordinate private communication. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to gocron
Thank you for coming to contribute to gocron! We welcome new ideas, PRs and general feedback.
## Reporting Bugs
If you find a bug then please let the project know by opening an issue after doing the following:
- Do a quick search of the existing issues to make sure the bug isn't already reported
- Try and make a minimal list of steps that can reliably reproduce the bug you are experiencing
- Collect as much information as you can to help identify what the issue is (project version, configuration files, etc)
## Suggesting Enhancements
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:
- Is this a common use case?
- Is it simple to understand?
You can help us out by doing the following before raising a new issue:
- Check that the feature hasn't been requested already by searching existing issues
- Try and reduce your enhancement into a single, concise and deliverable request, rather than a general idea
- Explain your own use cases as the basis of the request
## Adding Features
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.
This allows us to discuss the changes and make sure they are a good fit for the project.
Please always make sure a pull request has been:
- Unit tested with `make test`
- Linted with `make lint`
## Writing Tests
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.
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2014, 辣椒面
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
.PHONY: fmt lint test mocks test_coverage test_ci
GO_PKGS := $(shell go list -f {{.Dir}} ./...)
fmt:
@go list -f {{.Dir}} ./... | xargs -I{} gofmt -w -s {}
lint:
@grep "^func [a-zA-Z]" example_test.go | sort -c
@golangci-lint run
test:
@go test -race -v $(GO_FLAGS) -count=1 $(GO_PKGS)
test_coverage:
@go test -race -v $(GO_FLAGS) -count=1 -coverprofile=coverage.out -covermode=atomic $(GO_PKGS)
test_ci:
@go test -race -v $(GO_FLAGS) -count=1 $(GO_PKGS)
mocks:
@go generate ./...
================================================
FILE: README.md
================================================
# gocron: A Golang Job Scheduling Package
[](https://github.com/go-co-op/gocron/actions)
 [](https://pkg.go.dev/github.com/go-co-op/gocron/v2)
gocron is a job scheduling package which lets you run Go functions at pre-determined intervals.
> Looking for a visual interface?
> Check out [**gocron-ui**](https://github.com/go-co-op/gocron-ui) — a lightweight web dashboard to monitor, trigger, and manage your `gocron` jobs in real time.
If you want to chat, you can find us on Slack at
[](https://gophers.slack.com/archives/CQ7T0T1FW)
## Quick Start
```
go get github.com/go-co-op/gocron/v2
```
```golang
package main
import (
"fmt"
"time"
"github.com/go-co-op/gocron/v2"
)
func main() {
// create a scheduler
s, err := gocron.NewScheduler()
if err != nil {
// handle error
}
// add a job to the scheduler
j, err := s.NewJob(
gocron.DurationJob(
10*time.Second,
),
gocron.NewTask(
func(a string, b int) {
// do things
},
"hello",
1,
),
)
if err != nil {
// handle error
}
// each job has a unique id
fmt.Println(j.ID())
// start the scheduler
s.Start()
// block until you are ready to shut down
select {
case <-time.After(time.Minute):
}
// when you're done, shut it down
err = s.Shutdown()
if err != nil {
// handle error
}
}
```
## Examples
- [Go doc examples](https://pkg.go.dev/github.com/go-co-op/gocron/v2#pkg-examples)
- [Examples directory](examples)
## Articles & Blog Posts
Community articles and tutorials about using gocron:
- [Building a dynamic, highly available scheduler in Go](https://tech.efg.gg/posts/2025/highly-available-scheduler-in-go/) - A deep dive into building a highly available scheduler using gocron, MongoDB change streams, and leader election patterns for the FACEIT Watch platform.
## Concepts
- **Job**: The job encapsulates a "task", which is made up of a go function and any function parameters. The Job then
provides the scheduler with the time the job should next be scheduled to run.
- **Scheduler**: The scheduler keeps track of all the jobs and sends each job to the executor when
it is ready to be run.
- **Executor**: The executor calls the job's task and manages the complexities of different job
execution timing requirements (e.g. singletons that shouldn't overrun each other, limiting the max number of jobs running)
## Features
### Job types
Jobs can be run at various intervals.
- [**Duration**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#DurationJob):
Jobs can be run at a fixed `time.Duration`.
- [**Random duration**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#DurationRandomJob):
Jobs can be run at a random `time.Duration` between a min and max.
- [**Cron**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#CronJob):
Jobs can be run using a crontab.
- [**Daily**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#DailyJob):
Jobs can be run every x days at specific times.
- [**Weekly**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WeeklyJob):
Jobs can be run every x weeks on specific days of the week and at specific times.
- [**Monthly**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#MonthlyJob):
Jobs can be run every x months on specific days of the month and at specific times.
- [**One time**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#OneTimeJob):
Jobs can be run at specific time(s) (either once or many times).
### Interval Timing
Jobs can be scheduled with different interval timing modes.
- [**Interval from scheduled time (default)**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#DurationJob):
By default, jobs calculate their next run time from when they were scheduled to start, resulting in fixed intervals
regardless of execution time. Good for cron-like scheduling at predictable times.
- [**Interval from completion time**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithIntervalFromCompletion):
Jobs can calculate their next run time from when they complete, ensuring consistent rest periods between executions.
Ideal for rate-limited APIs, resource-intensive jobs, and scenarios where execution time varies.
### Concurrency Limits
Jobs can be limited individually or across the entire scheduler.
- [**Per job limiting with singleton mode**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithSingletonMode):
Jobs can be limited to a single concurrent execution that either reschedules (skips overlapping executions)
or queues (waits for the previous execution to finish).
- [**Per scheduler limiting with limit mode**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithLimitConcurrentJobs):
Jobs can be limited to a certain number of concurrent executions across the entire scheduler
using either reschedule (skip when the limit is met) or queue (jobs are added to a queue to
wait for the limit to be available).
- **Note:** A scheduler limit and a job limit can both be enabled.
### Distributed instances of gocron
Multiple instances of gocron can be run.
- [**Elector**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithDistributedElector):
An elector can be used to elect a single instance of gocron to run as the primary with the
other instances checking to see if a new leader needs to be elected.
- Implementations: [go-co-op electors](https://github.com/go-co-op?q=-elector&type=all&language=&sort=)
(don't see what you need? request on slack to get a repo created to contribute it!)
- [**Locker**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithDistributedLocker):
A locker can be used to lock each run of a job to a single instance of gocron.
Locker can be at job or scheduler, if it is defined both at job and scheduler then locker of job will take precedence.
- See Notes in the doc for [Locker](https://pkg.go.dev/github.com/go-co-op/gocron/v2#Locker) for
details and limitations of the locker design.
- Implementations: [go-co-op lockers](https://github.com/go-co-op?q=-lock&type=all&language=&sort=)
(don't see what you need? request on slack to get a repo created to contribute it!)
### Events
Job events can trigger actions.
- [**Listeners**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithEventListeners):
Can be added to a job, with [event listeners](https://pkg.go.dev/github.com/go-co-op/gocron/v2#EventListener),
or all jobs across the
[scheduler](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithGlobalJobOptions)
to listen for job events and trigger actions.
### Options
Many job and scheduler options are available.
- [**Job options**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#JobOption):
Job options can be set when creating a job using `NewJob`.
- [**Global job options**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithGlobalJobOptions):
Global job options can be set when creating a scheduler using `NewScheduler`
and the `WithGlobalJobOptions` option.
- [**Scheduler options**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#SchedulerOption):
Scheduler options can be set when creating a scheduler using `NewScheduler`.
### Logging
Logs can be enabled.
- [Logger](https://pkg.go.dev/github.com/go-co-op/gocron/v2#Logger):
The Logger interface can be implemented with your desired logging library.
The provided NewLogger uses the standard library's log package.
### Metrics
Metrics may be collected from the execution of each job and scheduler lifecycle events.
- [**Monitor**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#Monitor):
A monitor can be used to collect metrics for each job from a scheduler.
- Implementations: There are currently no open source implementations of the Monitor interface available.
We'd love for you to be the first to contribute one! Check out the [Monitor interface documentation](https://pkg.go.dev/github.com/go-co-op/gocron/v2#Monitor)
to get started, or reach out on [Slack](https://gophers.slack.com/archives/CQ7T0T1FW) if you'd like to discuss your implementation.
- [**MonitorStatus**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#MonitorStatus):
Extends Monitor with status and error tracking for each job.
- Implementations: There are currently no open source implementations of the MonitorStatus interface available.
We'd love for you to be the first to contribute one! Check out the [MonitorStatus interface documentation](https://pkg.go.dev/github.com/go-co-op/gocron/v2#MonitorStatus)
to get started, or reach out on [Slack](https://gophers.slack.com/archives/CQ7T0T1FW) if you'd like to discuss your implementation.
- [**SchedulerMonitor**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#SchedulerMonitor):
A scheduler monitor provides comprehensive observability into scheduler and job lifecycle events.
**Available Metrics:**
- **Scheduler Lifecycle**: `SchedulerStarted`, `SchedulerStopped`, `SchedulerShutdown`
- **Job Management**: `JobRegistered`, `JobUnregistered` - track jobs added/removed from scheduler
- **Job Execution**: `JobStarted`, `JobRunning`, `JobCompleted`, `JobFailed` - monitor job execution flow
- **Performance**: `JobExecutionTime`, `JobSchedulingDelay` - measure job duration and scheduling lag
- **Concurrency**: `ConcurrencyLimitReached` - detect when singleton or limit mode constraints are hit
**Derived Metrics** (calculable from events):
- Error rate: `JobFailed / (JobCompleted + JobFailed)`
- Average execution time: from `JobExecutionTime` events
- Active jobs: `JobRegistered - JobUnregistered`
- Current queue depth: `JobStarted - (JobCompleted + JobFailed)`
**Example - Prometheus Integration:**
```go
type PrometheusMonitor struct {
jobsCompleted prometheus.Counter
jobsFailed prometheus.Counter
executionTime prometheus.Histogram
schedulingDelay prometheus.Histogram
}
func (p *PrometheusMonitor) JobExecutionTime(job gocron.Job, duration time.Duration) {
p.executionTime.Observe(duration.Seconds())
}
func (p *PrometheusMonitor) JobSchedulingDelay(job gocron.Job, scheduled, actual time.Time) {
if delay := actual.Sub(scheduled); delay > 0 {
p.schedulingDelay.Observe(delay.Seconds())
}
}
// Initialize scheduler with monitor
s, _ := gocron.NewScheduler(gocron.WithSchedulerMonitor(monitor))
```
**Use Cases:** Prometheus metrics, custom dashboards, alerting systems, performance monitoring
- Implementations: There are currently no open source implementations of the SchedulerMonitor interface available.
We'd love for you to be the first to contribute one! Check out the [SchedulerMonitor interface documentation](https://pkg.go.dev/github.com/go-co-op/gocron/v2#SchedulerMonitor)
to get started, or reach out on [Slack](https://gophers.slack.com/archives/CQ7T0T1FW) if you'd like to discuss your implementation.
### Testing
The gocron library is set up to enable testing.
- Mocks are provided in [the mock package](mocks) using [gomock](https://github.com/uber-go/mock).
- Time can be mocked by passing in a [FakeClock](https://pkg.go.dev/github.com/jonboulle/clockwork#FakeClock)
to [WithClock](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithClock) -
see the [example on WithClock](https://pkg.go.dev/github.com/go-co-op/gocron/v2#example-WithClock).
## Supporters
We appreciate the support for free and open source software!
This project is supported by:
[JetBrains](https://www.jetbrains.com/?from=gocron)
[Sentry](https://sentry.io/welcome/)
## Star History
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Supported Versions
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.
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :heavy_multiplication_x: |
| 2.x.x | :white_check_mark: |
## Reporting a Vulnerability
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)
We will do our best to address any vulnerabilities in an expeditious manner.
================================================
FILE: distributed.go
================================================
//go:generate mockgen -destination=mocks/distributed.go -package=gocronmocks . Elector,Locker,Lock
package gocron
import (
"context"
)
// Elector determines the leader from instances asking to be the leader. Only
// the leader runs jobs. If the leader goes down, a new leader will be elected.
type Elector interface {
// IsLeader should return nil if the job should be scheduled by the instance
// making the request and an error if the job should not be scheduled.
IsLeader(context.Context) error
}
// Locker represents the required interface to lock jobs when running multiple schedulers.
// The lock is held for the duration of the job's run, and it is expected that the
// locker implementation handles time splay between schedulers.
// The lock key passed is the job's name - which, if not set, defaults to the
// go function's name, e.g. "pkg.myJob" for func myJob() {} in pkg
//
// Notes: The locker and scheduler do not handle synchronization of run times across
// schedulers.
//
// 1. If you are using duration based jobs (DurationJob), you can utilize the JobOption
// WithStartAt to set a start time for the job to the nearest time rounded to your
// duration. For example, if you have a job that runs every 5 minutes, you can set
// the start time to the nearest 5 minute e.g. 12:05, 12:10.
//
// 2. For all jobs, the implementation is still vulnerable to clockskew between scheduler
// instances. This may result in a single scheduler instance running the majority of the
// jobs.
//
// For distributed jobs, consider utilizing the Elector option if these notes are not acceptable
// to your use case.
type Locker interface {
// Lock if an error is returned by lock, the job will not be scheduled.
Lock(ctx context.Context, key string) (Lock, error)
}
// Lock represents an obtained lock. The lock is released after the execution of the job
// by the scheduler.
type Lock interface {
Unlock(ctx context.Context) error
}
================================================
FILE: errors.go
================================================
package gocron
import (
"errors"
)
// Public error definitions
var (
ErrCronJobInvalid = errors.New("gocron: CronJob: invalid crontab")
ErrCronJobParse = errors.New("gocron: CronJob: crontab parse failure")
ErrDailyJobAtTimeNil = errors.New("gocron: DailyJob: atTime within atTimes must not be nil")
ErrDailyJobAtTimesNil = errors.New("gocron: DailyJob: atTimes must not be nil")
ErrDailyJobHours = errors.New("gocron: DailyJob: atTimes hours must be between 0 and 23 inclusive")
ErrDailyJobZeroInterval = errors.New("gocron: DailyJob: interval must be greater than 0")
ErrDailyJobMinutesSeconds = errors.New("gocron: DailyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
ErrDurationJobIntervalZero = errors.New("gocron: DurationJob: time interval is 0")
ErrDurationJobIntervalNegative = errors.New("gocron: DurationJob: time interval must be greater than 0")
ErrDurationRandomJobPositive = errors.New("gocron: DurationRandomJob: minimum and maximum durations must be greater than 0")
ErrDurationRandomJobMinMax = errors.New("gocron: DurationRandomJob: minimum duration must be less than maximum duration")
ErrEventListenerFuncNil = errors.New("gocron: eventListenerFunc must not be nil")
ErrJobNotFound = errors.New("gocron: job not found")
ErrJobRunNowFailed = errors.New("gocron: Job: RunNow: scheduler unreachable")
ErrMonthlyJobDays = errors.New("gocron: MonthlyJob: daysOfTheMonth must be between 31 and -31 inclusive, and not 0")
ErrMonthlyJobAtTimeNil = errors.New("gocron: MonthlyJob: atTime within atTimes must not be nil")
ErrMonthlyJobAtTimesNil = errors.New("gocron: MonthlyJob: atTimes must not be nil")
ErrMonthlyJobDaysNil = errors.New("gocron: MonthlyJob: daysOfTheMonth must not be nil")
ErrMonthlyJobHours = errors.New("gocron: MonthlyJob: atTimes hours must be between 0 and 23 inclusive")
ErrMonthlyJobZeroInterval = errors.New("gocron: MonthlyJob: interval must be greater than 0")
ErrMonthlyJobMinutesSeconds = errors.New("gocron: MonthlyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
ErrNewJobTaskNil = errors.New("gocron: NewJob: Task must not be nil")
ErrNewJobTaskNotFunc = errors.New("gocron: NewJob: Task.Function must be of kind reflect.Func")
ErrNewJobWrongNumberOfParameters = errors.New("gocron: NewJob: Number of provided parameters does not match expected")
ErrNewJobWrongTypeOfParameters = errors.New("gocron: NewJob: Type of provided parameters does not match expected")
ErrOneTimeJobStartDateTimePast = errors.New("gocron: OneTimeJob: start must not be in the past")
ErrStopExecutorTimedOut = errors.New("gocron: timed out waiting for executor to stop")
ErrStopJobsTimedOut = errors.New("gocron: timed out waiting for jobs to finish")
ErrStopSchedulerTimedOut = errors.New("gocron: timed out waiting for scheduler to stop")
ErrWeeklyJobAtTimeNil = errors.New("gocron: WeeklyJob: atTime within atTimes must not be nil")
ErrWeeklyJobAtTimesNil = errors.New("gocron: WeeklyJob: atTimes must not be nil")
ErrWeeklyJobDaysOfTheWeekNil = errors.New("gocron: WeeklyJob: daysOfTheWeek must not be nil")
ErrWeeklyJobHours = errors.New("gocron: WeeklyJob: atTimes hours must be between 0 and 23 inclusive")
ErrWeeklyJobZeroInterval = errors.New("gocron: WeeklyJob: interval must be greater than 0")
ErrWeeklyJobMinutesSeconds = errors.New("gocron: WeeklyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
ErrPanicRecovered = errors.New("gocron: panic recovered")
ErrWithClockNil = errors.New("gocron: WithClock: clock must not be nil")
ErrWithContextNil = errors.New("gocron: WithContext: context must not be nil")
ErrWithDistributedElectorNil = errors.New("gocron: WithDistributedElector: elector must not be nil")
ErrWithDistributedLockerNil = errors.New("gocron: WithDistributedLocker: locker must not be nil")
ErrWithDistributedJobLockerNil = errors.New("gocron: WithDistributedJobLocker: locker must not be nil")
ErrWithIdentifierNil = errors.New("gocron: WithIdentifier: identifier must not be nil")
ErrSchedulerMonitorNil = errors.New("gocron: WithSchedulerMonitor: monitor must not be nil")
ErrWithLimitConcurrentJobsZero = errors.New("gocron: WithLimitConcurrentJobs: limit must be greater than 0")
ErrWithLocationNil = errors.New("gocron: WithLocation: location must not be nil")
ErrWithLoggerNil = errors.New("gocron: WithLogger: logger must not be nil")
ErrWithMonitorNil = errors.New("gocron: WithMonitor: monitor must not be nil")
ErrWithNameEmpty = errors.New("gocron: WithName: name must not be empty")
ErrWithStartDateTimePast = errors.New("gocron: WithStartDateTime: start must not be in the past")
ErrWithStartDateTimePastZero = errors.New("gocron: WithStartDateTime: start must not be zero")
ErrWithStopDateTimePast = errors.New("gocron: WithStopDateTime: end must not be in the past")
ErrStartTimeLaterThanEndTime = errors.New("gocron: WithStartDateTime: start must not be later than end")
ErrStopTimeEarlierThanStartTime = errors.New("gocron: WithStopDateTime: end must not be earlier than start")
ErrWithStopTimeoutZeroOrNegative = errors.New("gocron: WithStopTimeout: timeout must be greater than 0")
ErrWithSchedulerMonitorNil = errors.New("gocron: WithSchedulerMonitor: scheduler monitor cannot be nil")
ErrWithLimitedRunsZero = errors.New("gocron: WithLimitedRuns: limit must be greater than 0")
)
// internal errors
var (
errAtTimeNil = errors.New("errAtTimeNil")
errAtTimesNil = errors.New("errAtTimesNil")
errAtTimeHours = errors.New("errAtTimeHours")
errAtTimeMinSec = errors.New("errAtTimeMinSec")
)
================================================
FILE: example_test.go
================================================
package gocron_test
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/jonboulle/clockwork"
)
func ExampleAfterJobRuns() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithEventListeners(
gocron.AfterJobRuns(
func(jobID uuid.UUID, jobName string) {
// do something after the job completes
},
),
),
)
}
func ExampleAfterJobRunsWithError() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithEventListeners(
gocron.AfterJobRunsWithError(
func(jobID uuid.UUID, jobName string, err error) {
// do something when the job returns an error
},
),
),
)
}
var _ gocron.Locker = new(errorLocker)
type errorLocker struct{}
func (e errorLocker) Lock(_ context.Context, _ string) (gocron.Lock, error) {
return nil, errors.New("locked")
}
func ExampleAfterLockError() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithDistributedJobLocker(&errorLocker{}),
gocron.WithEventListeners(
gocron.AfterLockError(
func(jobID uuid.UUID, jobName string, err error) {
// do something immediately before the job is run
},
),
),
)
}
func ExampleBeforeJobRuns() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithEventListeners(
gocron.BeforeJobRuns(
func(jobID uuid.UUID, jobName string) {
// do something immediately before the job is run
},
),
),
)
}
func ExampleBeforeJobRunsSkipIfBeforeFuncErrors() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {
fmt.Println("Will never run, because before job func errors")
},
),
gocron.WithEventListeners(
gocron.BeforeJobRunsSkipIfBeforeFuncErrors(
func(jobID uuid.UUID, jobName string) error {
return errors.New("error")
},
),
),
)
}
func ExampleCronJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.CronJob(
// standard cron tab parsing
"1 * * * *",
false,
),
gocron.NewTask(
func() {},
),
)
_, _ = s.NewJob(
gocron.CronJob(
// optionally include seconds as the first field
"* 1 * * * *",
true,
),
gocron.NewTask(
func() {},
),
)
}
func ExampleDailyJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DailyJob(
1,
gocron.NewAtTimes(
gocron.NewAtTime(10, 30, 0),
gocron.NewAtTime(14, 0, 0),
),
),
gocron.NewTask(
func(a, b string) {},
"a",
"b",
),
)
}
func ExampleDurationJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second*5,
),
gocron.NewTask(
func() {},
),
)
}
func ExampleDurationRandomJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationRandomJob(
time.Second,
5*time.Second,
),
gocron.NewTask(
func() {},
),
)
}
func ExampleJob_id() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
)
fmt.Println(j.ID())
}
func ExampleJob_lastRun() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
)
fmt.Println(j.LastRun())
}
func ExampleJob_name() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithName("foobar"),
)
fmt.Println(j.Name())
// Output:
// foobar
}
func ExampleJob_nextRun() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
)
// NextRun is only available after the scheduler has been started.
s.Start()
nextRun, _ := j.NextRun()
fmt.Println(nextRun)
}
func ExampleJob_nextRuns() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
)
// NextRuns is only available after the scheduler has been started.
s.Start()
nextRuns, _ := j.NextRuns(5)
fmt.Println(nextRuns)
}
func ExampleJob_runNow() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.MonthlyJob(
1,
gocron.NewDaysOfTheMonth(3, -5, -1),
gocron.NewAtTimes(
gocron.NewAtTime(10, 30, 0),
gocron.NewAtTime(11, 15, 0),
),
),
gocron.NewTask(
func() {},
),
)
s.Start()
// Runs the job one time now, without impacting the schedule
_ = j.RunNow()
}
func ExampleJob_tags() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithTags("foo", "bar"),
)
fmt.Println(j.Tags())
// Output:
// [foo bar]
}
func ExampleMonthlyJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.MonthlyJob(
1,
gocron.NewDaysOfTheMonth(3, -5, -1),
gocron.NewAtTimes(
gocron.NewAtTime(10, 30, 0),
gocron.NewAtTime(11, 15, 0),
),
),
gocron.NewTask(
func() {},
),
)
}
func ExampleNewDefaultCron() {
c := gocron.NewDefaultCron(true)
err := c.IsValid("* * * * * *", time.Local, time.Now())
if err != nil {
panic(err)
}
}
func ExampleNewScheduler() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
fmt.Println(s.Jobs())
}
func ExampleNewTask() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(time.Second),
gocron.NewTask(
func(ctx context.Context) {
// gocron will pass in a context (either the default Job context, or one
// provided via WithContext) to the job and will cancel the context on shutdown.
// This allows you to listen for and handle cancellation within your job.
},
),
)
}
func ExampleOneTimeJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
// run a job once, immediately
_, _ = s.NewJob(
gocron.OneTimeJob(
gocron.OneTimeJobStartImmediately(),
),
gocron.NewTask(
func() {},
),
)
// run a job once in 10 seconds
_, _ = s.NewJob(
gocron.OneTimeJob(
gocron.OneTimeJobStartDateTime(time.Now().Add(10*time.Second)),
),
gocron.NewTask(
func() {},
),
)
// run job twice - once in 10 seconds and once in 55 minutes
n := time.Now()
_, _ = s.NewJob(
gocron.OneTimeJob(
gocron.OneTimeJobStartDateTimes(
n.Add(10*time.Second),
n.Add(55*time.Minute),
),
),
gocron.NewTask(func() {}),
)
s.Start()
}
func ExampleScheduler_jobs() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
10*time.Second,
),
gocron.NewTask(
func() {},
),
)
fmt.Println(len(s.Jobs()))
// Output:
// 1
}
func ExampleScheduler_newJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, err := s.NewJob(
gocron.DurationJob(
10*time.Second,
),
gocron.NewTask(
func() {},
),
)
if err != nil {
panic(err)
}
fmt.Println(j.ID())
}
func ExampleScheduler_removeByTags() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithTags("tag1"),
)
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithTags("tag2"),
)
fmt.Println(len(s.Jobs()))
s.RemoveByTags("tag1", "tag2")
fmt.Println(len(s.Jobs()))
// Output:
// 2
// 0
}
func ExampleScheduler_removeJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
)
fmt.Println(len(s.Jobs()))
_ = s.RemoveJob(j.ID())
fmt.Println(len(s.Jobs()))
// Output:
// 1
// 0
}
func ExampleScheduler_shutdown() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
}
func ExampleScheduler_start() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.CronJob(
"* * * * *",
false,
),
gocron.NewTask(
func() {},
),
)
s.Start()
}
func ExampleScheduler_stopJobs() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.CronJob(
"* * * * *",
false,
),
gocron.NewTask(
func() {},
),
)
s.Start()
_ = s.StopJobs()
}
func ExampleScheduler_update() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.CronJob(
"* * * * *",
false,
),
gocron.NewTask(
func() {},
),
)
s.Start()
// after some time, need to change the job
j, _ = s.Update(
j.ID(),
gocron.DurationJob(
5*time.Second,
),
gocron.NewTask(
func() {},
),
)
}
func ExampleWeeklyJob() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.WeeklyJob(
2,
gocron.NewWeekdays(time.Tuesday, time.Wednesday, time.Saturday),
gocron.NewAtTimes(
gocron.NewAtTime(1, 30, 0),
gocron.NewAtTime(12, 0, 30),
),
),
gocron.NewTask(
func() {},
),
)
}
func ExampleWithClock() {
fakeClock := clockwork.NewFakeClock()
s, _ := gocron.NewScheduler(
gocron.WithClock(fakeClock),
)
var wg sync.WaitGroup
wg.Add(1)
_, _ = s.NewJob(
gocron.DurationJob(
time.Second*5,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d\n", one, two)
wg.Done()
},
"one", 2,
),
)
s.Start()
_ = fakeClock.BlockUntilContext(context.Background(), 1)
fakeClock.Advance(time.Second * 5)
wg.Wait()
_ = s.StopJobs()
// Output:
// one, 2
}
func ExampleWithContext() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(ctx context.Context) {
// gocron will pass in the context provided via WithContext
// to the job and will cancel the context on shutdown.
// This allows you to listen for and handle cancellation within your job.
},
),
gocron.WithContext(ctx),
)
}
var _ gocron.Cron = (*customCron)(nil)
type customCron struct{}
func (c customCron) IsValid(crontab string, location *time.Location, now time.Time) error {
return nil
}
func (c customCron) Next(lastRun time.Time) time.Time {
return time.Now().Add(time.Second)
}
func ExampleWithCronImplementation() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.CronJob(
"* * * * *",
false,
),
gocron.NewTask(
func() {},
),
gocron.WithCronImplementation(
&customCron{},
),
)
}
func ExampleWithDisabledDistributedJobLocker() {
// var _ gocron.Locker = (*myLocker)(nil)
//
// type myLocker struct{}
//
// func (m myLocker) Lock(ctx context.Context, key string) (Lock, error) {
// return &testLock{}, nil
// }
//
// var _ gocron.Lock = (*testLock)(nil)
//
// type testLock struct{}
//
// func (t testLock) Unlock(_ context.Context) error {
// return nil
// }
locker := &myLocker{}
s, _ := gocron.NewScheduler(
gocron.WithDistributedLocker(locker),
)
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithDisabledDistributedJobLocker(true),
)
}
var _ gocron.Elector = (*myElector)(nil)
type myElector struct{}
func (m myElector) IsLeader(_ context.Context) error {
return nil
}
func ExampleWithDistributedElector() {
// var _ gocron.Elector = (*myElector)(nil)
//
// type myElector struct{}
//
// func (m myElector) IsLeader(_ context.Context) error {
// return nil
// }
//
elector := &myElector{}
_, _ = gocron.NewScheduler(
gocron.WithDistributedElector(elector),
)
}
var _ gocron.Locker = (*myLocker)(nil)
type myLocker struct{}
func (m myLocker) Lock(ctx context.Context, key string) (gocron.Lock, error) {
return &testLock{}, nil
}
var _ gocron.Lock = (*testLock)(nil)
type testLock struct{}
func (t testLock) Unlock(_ context.Context) error {
return nil
}
func ExampleWithDistributedLocker() {
// var _ gocron.Locker = (*myLocker)(nil)
//
// type myLocker struct{}
//
// func (m myLocker) Lock(ctx context.Context, key string) (Lock, error) {
// return &testLock{}, nil
// }
//
// var _ gocron.Lock = (*testLock)(nil)
//
// type testLock struct{}
//
// func (t testLock) Unlock(_ context.Context) error {
// return nil
// }
locker := &myLocker{}
_, _ = gocron.NewScheduler(
gocron.WithDistributedLocker(locker),
)
}
func ExampleWithEventListeners() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {},
),
gocron.WithEventListeners(
gocron.AfterJobRuns(
func(jobID uuid.UUID, jobName string) {
// do something after the job completes
},
),
gocron.AfterJobRunsWithError(
func(jobID uuid.UUID, jobName string, err error) {
// do something when the job returns an error
},
),
gocron.BeforeJobRuns(
func(jobID uuid.UUID, jobName string) {
// do something immediately before the job is run
},
),
),
)
}
func ExampleWithGlobalJobOptions() {
s, _ := gocron.NewScheduler(
gocron.WithGlobalJobOptions(
gocron.WithTags("tag1", "tag2", "tag3"),
),
)
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
)
// The job will have the globally applied tags
fmt.Println(j.Tags())
s2, _ := gocron.NewScheduler(
gocron.WithGlobalJobOptions(
gocron.WithTags("tag1", "tag2", "tag3"),
),
)
j2, _ := s2.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithTags("tag4", "tag5", "tag6"),
)
// The job will have the tags set specifically on the job
// overriding those set globally by the scheduler
fmt.Println(j2.Tags())
// Output:
// [tag1 tag2 tag3]
// [tag4 tag5 tag6]
}
func ExampleWithIdentifier() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithIdentifier(uuid.MustParse("87b95dfc-3e71-11ef-9454-0242ac120002")),
)
fmt.Println(j.ID())
// Output:
// 87b95dfc-3e71-11ef-9454-0242ac120002
}
func ExampleWithIntervalFromCompletion() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
5*time.Minute,
),
gocron.NewTask(
func() {
time.Sleep(30 * time.Second)
},
),
gocron.WithIntervalFromCompletion(),
)
// Without WithIntervalFromCompletion (default behavior):
// If the job starts at 00:00 and completes at 00:00:30,
// the next job starts at 00:05:00 (only 4m30s rest).
// With WithIntervalFromCompletion:
// If the job starts at 00:00 and completes at 00:00:30,
// the next job starts at 00:05:30 (full 5m rest).
}
func ExampleWithLimitConcurrentJobs() {
_, _ = gocron.NewScheduler(
gocron.WithLimitConcurrentJobs(
1,
gocron.LimitModeReschedule,
),
)
}
func ExampleWithLimitedRuns() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Millisecond,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d\n", one, two)
},
"one", 2,
),
gocron.WithLimitedRuns(1),
)
s.Start()
time.Sleep(100 * time.Millisecond)
_ = s.StopJobs()
fmt.Printf("no jobs in scheduler: %v\n", s.Jobs())
// Output:
// one, 2
// no jobs in scheduler: []
}
func ExampleWithLocation() {
location, _ := time.LoadLocation("Asia/Kolkata")
_, _ = gocron.NewScheduler(
gocron.WithLocation(location),
)
}
func ExampleWithLogger() {
_, _ = gocron.NewScheduler(
gocron.WithLogger(
gocron.NewLogger(gocron.LogLevelDebug),
),
)
}
func ExampleWithMonitor() {
//type exampleMonitor struct {
// mu sync.Mutex
// counter map[string]int
// time map[string][]time.Duration
//}
//
//func newExampleMonitor() *exampleMonitor {
// return &exampleMonitor{
// counter: make(map[string]int),
// time: make(map[string][]time.Duration),
//}
//}
//
//func (t *exampleMonitor) IncrementJob(_ uuid.UUID, name string, _ []string, _ JobStatus) {
// t.mu.Lock()
// defer t.mu.Unlock()
// _, ok := t.counter[name]
// if !ok {
// t.counter[name] = 0
// }
// t.counter[name]++
//}
//
//func (t *exampleMonitor) RecordJobTiming(startTime, endTime time.Time, _ uuid.UUID, name string, _ []string) {
// t.mu.Lock()
// defer t.mu.Unlock()
// _, ok := t.time[name]
// if !ok {
// t.time[name] = make([]time.Duration, 0)
// }
// t.time[name] = append(t.time[name], endTime.Sub(startTime))
//}
//
//monitor := newExampleMonitor()
//s, _ := NewScheduler(
// WithMonitor(monitor),
//)
//name := "example"
//_, _ = s.NewJob(
// DurationJob(
// time.Second,
// ),
// NewTask(
// func() {
// time.Sleep(1 * time.Second)
// },
// ),
// WithName(name),
// WithStartAt(
// WithStartImmediately(),
// ),
//)
//s.Start()
//time.Sleep(5 * time.Second)
//_ = s.Shutdown()
//
//fmt.Printf("Job %q total execute count: %d\n", name, monitor.counter[name])
//for i, val := range monitor.time[name] {
// fmt.Printf("Job %q execute #%d elapsed %.4f seconds\n", name, i+1, val.Seconds())
//}
}
func ExampleWithName() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithName("job 1"),
)
fmt.Println(j.Name())
// Output:
// job 1
}
func ExampleWithSingletonMode() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
_, _ = s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func() {
// this job will skip half it's executions
// and effectively run every 2 seconds
time.Sleep(1500 * time.Second)
},
),
gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
}
func ExampleWithStartAt() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
start := time.Date(9999, 9, 9, 9, 9, 9, 9, time.UTC)
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithStartAt(
gocron.WithStartDateTime(start),
),
)
s.Start()
next, _ := j.NextRun()
fmt.Println(next)
_ = s.StopJobs()
// Output:
// 9999-09-09 09:09:09.000000009 +0000 UTC
}
func ExampleWithStartDateTime() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
start := time.Date(9999, 9, 9, 9, 9, 9, 9, time.UTC)
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithStartAt(
gocron.WithStartDateTime(start),
),
)
s.Start()
next, _ := j.NextRun()
fmt.Println(next)
_ = s.StopJobs()
// Output:
// 9999-09-09 09:09:09.000000009 +0000 UTC
}
func ExampleWithStartDateTimePast() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
start := time.Now().Add(-time.Minute)
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithStartAt(
gocron.WithStartDateTimePast(start),
),
)
s.Start()
time.Sleep(100 * time.Millisecond)
_, _ = j.NextRun()
_ = s.StopJobs()
}
func ExampleWithStartImmediately() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithStartAt(
gocron.WithStartImmediately(),
),
)
s.Start()
_, _ = j.NextRun()
_ = s.StopJobs()
}
func ExampleWithStopTimeout() {
_, _ = gocron.NewScheduler(
gocron.WithStopTimeout(time.Second * 5),
)
}
func ExampleWithTags() {
s, _ := gocron.NewScheduler()
defer func() { _ = s.Shutdown() }()
j, _ := s.NewJob(
gocron.DurationJob(
time.Second,
),
gocron.NewTask(
func(one string, two int) {
fmt.Printf("%s, %d", one, two)
},
"one", 2,
),
gocron.WithTags("tag1", "tag2", "tag3"),
)
fmt.Println(j.Tags())
// Output:
// [tag1 tag2 tag3]
}
================================================
FILE: examples/elector/main.go
================================================
package main
import (
"context"
"errors"
"log"
"time"
"github.com/go-co-op/gocron/v2"
)
var _ gocron.Elector = (*myElector)(nil)
type myElector struct {
num int
leader bool
}
func (m myElector) IsLeader(_ context.Context) error {
if m.leader {
log.Printf("node %d is leader", m.num)
return nil
}
log.Printf("node %d is not leader", m.num)
return errors.New("not leader")
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
for i := 0; i < 3; i++ {
go func(i int) {
elector := &myElector{
num: i,
}
if i == 0 {
elector.leader = true
}
scheduler, err := gocron.NewScheduler(
gocron.WithDistributedElector(elector),
)
if err != nil {
log.Println(err)
return
}
_, err = scheduler.NewJob(
gocron.DurationJob(time.Second),
gocron.NewTask(func() {
log.Println("run job")
}),
)
if err != nil {
log.Println(err)
return
}
scheduler.Start()
if i == 0 {
time.Sleep(5 * time.Second)
elector.leader = false
}
if i == 1 {
time.Sleep(5 * time.Second)
elector.leader = true
}
}(i)
}
select {} // wait forever
}
================================================
FILE: executor.go
================================================
package gocron
import (
"context"
"fmt"
"strconv"
"sync"
"time"
"github.com/jonboulle/clockwork"
"github.com/google/uuid"
)
type executor struct {
// context used for shutting down
ctx context.Context
// cancel used by the executor to signal a stop of it's functions
cancel context.CancelFunc
// clock used for regular time or mocking time
clock clockwork.Clock
// the executor's logger
logger Logger
// receives jobs scheduled to execute
jobsIn chan jobIn
// sends out jobs for rescheduling
jobsOutForRescheduling chan uuid.UUID
// sends out jobs once completed
jobsOutCompleted chan uuid.UUID
// used to request jobs from the scheduler
jobOutRequest chan *jobOutRequest
// sends out job needs to update the next runs
jobUpdateNextRuns chan uuid.UUID
// used by the executor to receive a stop signal from the scheduler
stopCh chan struct{}
// ensure that stop runs before the next call to start and only runs once
stopOnce *sync.Once
// the timeout value when stopping
stopTimeout time.Duration
// used to signal that the executor has completed shutdown
done chan error
// runners for any singleton type jobs
// map[uuid.UUID]singletonRunner
singletonRunners *sync.Map
// config for limit mode
limitMode *limitModeConfig
// the elector when running distributed instances
elector Elector
// the locker when running distributed instances
locker Locker
// monitor for reporting metrics
monitor Monitor
// monitorStatus for reporting metrics
monitorStatus MonitorStatus
// reference to parent scheduler for lifecycle notifications
scheduler *scheduler
}
type jobIn struct {
id uuid.UUID
shouldSendOut bool
}
type singletonRunner struct {
in chan jobIn
rescheduleLimiter chan struct{}
}
type limitModeConfig struct {
started bool
mode LimitMode
limit uint
rescheduleLimiter chan struct{}
in chan jobIn
// singletonJobs is used to track singleton jobs that are running
// in the limit mode runner. This is used to prevent the same job
// from running multiple times across limit mode runners when both
// a limit mode and singleton mode are enabled.
singletonJobs map[uuid.UUID]struct{}
singletonJobsMu sync.Mutex
}
func (e *executor) start() {
e.logger.Debug("gocron: executor started")
// creating the executor's context here as the executor
// is the only goroutine that should access this context
// any other uses within the executor should create a context
// using the executor context as parent.
e.ctx, e.cancel = context.WithCancel(context.Background())
e.stopOnce = &sync.Once{}
// the standardJobsWg tracks
standardJobsWg := &waitGroupWithMutex{}
singletonJobsWg := &waitGroupWithMutex{}
limitModeJobsWg := &waitGroupWithMutex{}
// create a fresh map for tracking singleton runners
e.singletonRunners = &sync.Map{}
// start the for leap that is the executor
// selecting on channels for work to do
for {
select {
// job ids in are sent from 1 of 2 places:
// 1. the scheduler sends directly when jobs
// are run immediately.
// 2. sent from time.AfterFuncs in which job schedules
// are spun up by the scheduler
case jIn := <-e.jobsIn:
select {
case <-e.stopCh:
e.stop(standardJobsWg, singletonJobsWg, limitModeJobsWg)
return
default:
}
// this context is used to handle cancellation of the executor
// on requests for a job to the scheduler via requestJobCtx
ctx, cancel := context.WithCancel(e.ctx)
if e.limitMode != nil && !e.limitMode.started {
// check if we are already running the limit mode runners
// if not, spin up the required number i.e. limit!
e.limitMode.started = true
for i := e.limitMode.limit; i > 0; i-- {
limitModeJobsWg.Add(1)
go e.limitModeRunner("limitMode-"+strconv.Itoa(int(i)), e.limitMode.in, limitModeJobsWg, e.limitMode.mode, e.limitMode.rescheduleLimiter)
}
}
// spin off into a goroutine to unblock the executor and
// allow for processing for more work
go func(executorCtx context.Context) {
// make sure to cancel the above context per the docs
// // Canceling this context releases resources associated with it, so code should
// // call cancel as soon as the operations running in this Context complete.
defer cancel()
// check for limit mode - this spins up a separate runner which handles
// limiting the total number of concurrently running jobs
if e.limitMode != nil {
if e.limitMode.mode == LimitModeReschedule {
select {
// rescheduleLimiter is a channel the size of the limit
// this blocks publishing to the channel and keeps
// the executor from building up a waiting queue
// and forces rescheduling
case e.limitMode.rescheduleLimiter <- struct{}{}:
e.limitMode.in <- jIn
default:
// all runners are busy, reschedule the work for later
// which means we just skip it here and do nothing
// TODO when metrics are added, this should increment a rescheduled metric
// Notify concurrency limit reached if monitor is configured
if e.scheduler != nil && e.scheduler.schedulerMonitor != nil {
ctx2, cancel2 := context.WithCancel(executorCtx)
job := requestJobCtx(ctx2, jIn.id, e.jobOutRequest)
cancel2()
if job != nil {
e.scheduler.notifyConcurrencyLimitReached("limit", e.scheduler.jobFromInternalJob(*job))
}
}
e.sendOutForRescheduling(&jIn)
}
} else {
// since we're not using LimitModeReschedule, but instead using LimitModeWait
// we do want to queue up the work to the limit mode runners and allow them
// to work through the channel backlog. A hard limit of 1000 is in place
// at which point this call would block.
// TODO when metrics are added, this should increment a wait metric
e.sendOutForRescheduling(&jIn)
e.limitMode.in <- jIn
}
} else {
// no limit mode, so we're either running a regular job or
// a job with a singleton mode
//
// get the job, so we can figure out what kind it is and how
// to execute it
j := requestJobCtx(ctx, jIn.id, e.jobOutRequest)
if j == nil {
// safety check as it'd be strange bug if this occurred
return
}
if j.singletonMode {
// for singleton mode, get the existing runner for the job
// or spin up a new one
runner := &singletonRunner{}
runnerSrc, ok := e.singletonRunners.Load(jIn.id)
if !ok {
runner.in = make(chan jobIn, 1000)
if j.singletonLimitMode == LimitModeReschedule {
runner.rescheduleLimiter = make(chan struct{}, 1)
}
e.singletonRunners.Store(jIn.id, runner)
singletonJobsWg.Add(1)
go e.singletonModeRunner("singleton-"+jIn.id.String(), runner.in, singletonJobsWg, j.singletonLimitMode, runner.rescheduleLimiter)
} else {
runner = runnerSrc.(*singletonRunner)
}
if j.singletonLimitMode == LimitModeReschedule {
// reschedule mode uses the limiter channel to check
// for a running job and reschedules if the channel is full.
select {
case runner.rescheduleLimiter <- struct{}{}:
runner.in <- jIn
// For intervalFromCompletion, skip rescheduling here - it will happen after job completes
if !j.intervalFromCompletion {
e.sendOutForRescheduling(&jIn)
}
default:
// runner is busy, reschedule the work for later
// which means we just skip it here and do nothing
e.incrementJobCounter(*j, SingletonRescheduled)
e.sendOutForRescheduling(&jIn)
// Notify concurrency limit reached if monitor is configured
if e.scheduler != nil && e.scheduler.schedulerMonitor != nil {
e.scheduler.notifyConcurrencyLimitReached("singleton", e.scheduler.jobFromInternalJob(*j))
}
}
} else {
// wait mode, fill up that queue (buffered channel, so it's ok)
runner.in <- jIn
// For intervalFromCompletion, skip rescheduling here - it will happen after job completes
if !j.intervalFromCompletion {
e.sendOutForRescheduling(&jIn)
}
}
} else {
select {
case <-executorCtx.Done():
return
default:
}
// we've gotten to the basic / standard jobs --
// the ones without anything special that just want
// to be run. Add to the WaitGroup so that
// stopping or shutting down can wait for the jobs to
// complete.
standardJobsWg.Add(1)
go func(j internalJob) {
e.runJob(j, jIn)
standardJobsWg.Done()
}(*j)
}
}
}(e.ctx)
case <-e.stopCh:
e.stop(standardJobsWg, singletonJobsWg, limitModeJobsWg)
return
}
}
}
func (e *executor) sendOutForRescheduling(jIn *jobIn) {
if jIn.shouldSendOut {
select {
case e.jobsOutForRescheduling <- jIn.id:
case <-e.ctx.Done():
return
}
}
// we need to set this to false now, because to handle
// non-limit jobs, we send out from the e.runJob function
// and in this case we don't want to send out twice.
jIn.shouldSendOut = false
}
func (e *executor) sendOutForNextRunUpdate(jIn *jobIn) {
select {
case e.jobUpdateNextRuns <- jIn.id:
case <-e.ctx.Done():
return
}
}
func (e *executor) limitModeRunner(name string, in chan jobIn, wg *waitGroupWithMutex, limitMode LimitMode, rescheduleLimiter chan struct{}) {
e.logger.Debug("gocron: limitModeRunner starting", "name", name)
for {
select {
case jIn := <-in:
select {
case <-e.ctx.Done():
e.logger.Debug("gocron: limitModeRunner shutting down", "name", name)
wg.Done()
return
default:
}
ctx, cancel := context.WithCancel(e.ctx)
j := requestJobCtx(ctx, jIn.id, e.jobOutRequest)
cancel()
if j != nil {
if j.singletonMode {
e.limitMode.singletonJobsMu.Lock()
_, ok := e.limitMode.singletonJobs[jIn.id]
if ok {
// this job is already running, so don't run it
// but instead reschedule it
e.limitMode.singletonJobsMu.Unlock()
if jIn.shouldSendOut {
select {
case <-e.ctx.Done():
return
case <-j.ctx.Done():
return
case e.jobsOutForRescheduling <- j.id:
}
}
// remove the limiter block, as this particular job
// was a singleton already running, and we want to
// allow another job to be scheduled
if limitMode == LimitModeReschedule {
<-rescheduleLimiter
}
continue
}
e.limitMode.singletonJobs[jIn.id] = struct{}{}
e.limitMode.singletonJobsMu.Unlock()
}
e.runJob(*j, jIn)
if j.singletonMode {
e.limitMode.singletonJobsMu.Lock()
delete(e.limitMode.singletonJobs, jIn.id)
e.limitMode.singletonJobsMu.Unlock()
}
}
// remove the limiter block to allow another job to be scheduled
if limitMode == LimitModeReschedule {
<-rescheduleLimiter
}
case <-e.ctx.Done():
e.logger.Debug("limitModeRunner shutting down", "name", name)
wg.Done()
return
}
}
}
func (e *executor) singletonModeRunner(name string, in chan jobIn, wg *waitGroupWithMutex, limitMode LimitMode, rescheduleLimiter chan struct{}) {
e.logger.Debug("gocron: singletonModeRunner starting", "name", name)
for {
select {
case jIn := <-in:
select {
case <-e.ctx.Done():
e.logger.Debug("gocron: singletonModeRunner shutting down", "name", name)
wg.Done()
return
default:
}
ctx, cancel := context.WithCancel(e.ctx)
j := requestJobCtx(ctx, jIn.id, e.jobOutRequest)
cancel()
if j != nil {
// need to set shouldSendOut = false here, as there is a duplicative call to sendOutForRescheduling
// inside the runJob function that needs to be skipped. sendOutForRescheduling is previously called
// when the job is sent to the singleton mode runner.
// Exception: for intervalFromCompletion, we want rescheduling to happen AFTER job completion
if !j.intervalFromCompletion {
jIn.shouldSendOut = false
}
e.runJob(*j, jIn)
}
// remove the limiter block to allow another job to be scheduled
if limitMode == LimitModeReschedule {
<-rescheduleLimiter
}
case <-e.ctx.Done():
e.logger.Debug("singletonModeRunner shutting down", "name", name)
wg.Done()
return
}
}
}
func (e *executor) runJob(j internalJob, jIn jobIn) {
if j.ctx == nil {
return
}
select {
case <-e.ctx.Done():
return
case <-j.ctx.Done():
return
default:
}
if j.stopTimeReached(e.clock.Now()) {
return
}
if e.elector != nil {
if err := e.elector.IsLeader(j.ctx); err != nil {
e.sendOutForRescheduling(&jIn)
e.incrementJobCounter(j, Skip)
return
}
} else if !j.disabledLocker && j.locker != nil {
lock, err := j.locker.Lock(j.ctx, j.name)
if err != nil {
_ = callJobFuncWithParams(j.afterLockError, j.id, j.name, err)
e.sendOutForRescheduling(&jIn)
e.incrementJobCounter(j, Skip)
e.sendOutForNextRunUpdate(&jIn)
return
}
defer func() { _ = lock.Unlock(j.ctx) }()
} else if !j.disabledLocker && e.locker != nil {
lock, err := e.locker.Lock(j.ctx, j.name)
if err != nil {
_ = callJobFuncWithParams(j.afterLockError, j.id, j.name, err)
e.sendOutForRescheduling(&jIn)
e.incrementJobCounter(j, Skip)
e.sendOutForNextRunUpdate(&jIn)
return
}
defer func() { _ = lock.Unlock(j.ctx) }()
}
_ = callJobFuncWithParams(j.beforeJobRuns, j.id, j.name)
// Notify job started
actualStartTime := time.Now()
if e.scheduler != nil && e.scheduler.schedulerMonitor != nil {
jobObj := e.scheduler.jobFromInternalJob(j)
e.scheduler.notifyJobStarted(jobObj)
// Notify scheduling delay if job had a scheduled time
if len(j.nextScheduled) > 0 {
e.scheduler.notifyJobSchedulingDelay(jobObj, j.nextScheduled[0], actualStartTime)
}
}
err := callJobFuncWithParams(j.beforeJobRunsSkipIfBeforeFuncErrors, j.id, j.name)
if err != nil {
e.sendOutForRescheduling(&jIn)
select {
case e.jobsOutCompleted <- j.id:
case <-e.ctx.Done():
}
// Notify job failed (before actual run)
if e.scheduler != nil && e.scheduler.schedulerMonitor != nil {
e.scheduler.notifyJobFailed(e.scheduler.jobFromInternalJob(j), err)
}
return
}
// Notify job running
if e.scheduler != nil && e.scheduler.schedulerMonitor != nil {
e.scheduler.notifyJobRunning(e.scheduler.jobFromInternalJob(j))
}
// For intervalFromCompletion, we need to reschedule AFTER the job completes,
// not before. For regular jobs, we reschedule before execution (existing behavior).
if !j.intervalFromCompletion {
e.sendOutForRescheduling(&jIn)
select {
case e.jobsOutCompleted <- j.id:
case <-e.ctx.Done():
}
}
startTime := time.Now()
if j.afterJobRunsWithPanic != nil {
err = e.callJobWithRecover(j)
} else {
err = callJobFuncWithParams(j.function, j.parameters...)
}
e.recordJobTiming(startTime, time.Now(), j)
if err != nil {
_ = callJobFuncWithParams(j.afterJobRunsWithError, j.id, j.name, err)
e.incrementJobCounter(j, Fail)
endTime := time.Now()
e.recordJobTimingWithStatus(startTime, endTime, j, Fail, err)
// Notify job failed
if e.scheduler != nil && e.scheduler.schedulerMonitor != nil {
jobObj := e.scheduler.jobFromInternalJob(j)
e.scheduler.notifyJobFailed(jobObj, err)
e.scheduler.notifyJobExecutionTime(jobObj, endTime.Sub(startTime))
}
} else {
_ = callJobFuncWithParams(j.afterJobRuns, j.id, j.name)
e.incrementJobCounter(j, Success)
endTime := time.Now()
e.recordJobTimingWithStatus(startTime, endTime, j, Success, nil)
// Notify job completed
if e.scheduler != nil && e.scheduler.schedulerMonitor != nil {
jobObj := e.scheduler.jobFromInternalJob(j)
e.scheduler.notifyJobCompleted(jobObj)
e.scheduler.notifyJobExecutionTime(jobObj, endTime.Sub(startTime))
}
}
// For intervalFromCompletion, reschedule AFTER the job completes
if j.intervalFromCompletion {
select {
case e.jobsOutCompleted <- j.id:
case <-e.ctx.Done():
}
e.sendOutForRescheduling(&jIn)
}
}
func (e *executor) callJobWithRecover(j internalJob) (err error) {
defer func() {
if recoverData := recover(); recoverData != nil {
_ = callJobFuncWithParams(j.afterJobRunsWithPanic, j.id, j.name, recoverData)
// if panic is occurred, we should return an error
err = fmt.Errorf("%w from %v", ErrPanicRecovered, recoverData)
}
}()
return callJobFuncWithParams(j.function, j.parameters...)
}
func (e *executor) recordJobTiming(start time.Time, end time.Time, j internalJob) {
if e.monitor != nil {
e.monitor.RecordJobTiming(start, end, j.id, j.name, j.tags)
}
}
func (e *executor) recordJobTimingWithStatus(start time.Time, end time.Time, j internalJob, status JobStatus, err error) {
if e.monitorStatus != nil {
e.monitorStatus.RecordJobTimingWithStatus(start, end, j.id, j.name, j.tags, status, err)
}
}
func (e *executor) incrementJobCounter(j internalJob, status JobStatus) {
if e.monitor != nil {
e.monitor.IncrementJob(j.id, j.name, j.tags, status)
}
}
func (e *executor) stop(standardJobsWg, singletonJobsWg, limitModeJobsWg *waitGroupWithMutex) {
e.stopOnce.Do(func() {
e.logger.Debug("gocron: stopping executor")
// we've been asked to stop. This is either because the scheduler has been told
// to stop all jobs or the scheduler has been asked to completely shutdown.
//
// cancel tells all the functions to stop their work and send in a done response
e.cancel()
// the wait for job channels are used to report back whether we successfully waited
// for all jobs to complete or if we hit the configured timeout.
waitForJobs := make(chan struct{}, 1)
waitForSingletons := make(chan struct{}, 1)
waitForLimitMode := make(chan struct{}, 1)
// the waiter context is used to cancel the functions waiting on jobs.
// this is done to avoid goroutine leaks.
waiterCtx, waiterCancel := context.WithCancel(context.Background())
// wait for standard jobs to complete
go func() {
e.logger.Debug("gocron: waiting for standard jobs to complete")
go func() {
// this is done in a separate goroutine, so we aren't
// blocked by the WaitGroup's Wait call in the event
// that the waiter context is cancelled.
// This particular goroutine could leak in the event that
// some long-running standard job doesn't complete.
standardJobsWg.Wait()
e.logger.Debug("gocron: standard jobs completed")
waitForJobs <- struct{}{}
}()
<-waiterCtx.Done()
}()
// wait for per job singleton limit mode runner jobs to complete
go func() {
e.logger.Debug("gocron: waiting for singleton jobs to complete")
go func() {
singletonJobsWg.Wait()
e.logger.Debug("gocron: singleton jobs completed")
waitForSingletons <- struct{}{}
}()
<-waiterCtx.Done()
}()
// wait for limit mode runners to complete
go func() {
e.logger.Debug("gocron: waiting for limit mode jobs to complete")
go func() {
limitModeJobsWg.Wait()
e.logger.Debug("gocron: limitMode jobs completed")
waitForLimitMode <- struct{}{}
}()
<-waiterCtx.Done()
}()
// now either wait for all the jobs to complete,
// or hit the timeout.
var count int
timeout := time.Now().Add(e.stopTimeout)
for time.Now().Before(timeout) && count < 3 {
select {
case <-waitForJobs:
count++
case <-waitForSingletons:
count++
case <-waitForLimitMode:
count++
default:
}
}
if count < 3 {
e.done <- ErrStopJobsTimedOut
e.logger.Debug("gocron: executor stopped - timed out")
} else {
e.done <- nil
e.logger.Debug("gocron: executor stopped")
}
waiterCancel()
if e.limitMode != nil {
e.limitMode.started = false
}
})
}
================================================
FILE: go.mod
================================================
module github.com/go-co-op/gocron/v2
go 1.21.4
require (
github.com/google/uuid v1.6.0
github.com/jonboulle/clockwork v0.5.0
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: gocron-monitor-test/debug_restart.go
================================================
package main
import (
"fmt"
"time"
"github.com/go-co-op/gocron/v2"
)
type DebugMonitor struct {
startCount int
stopCount int
jobRegCount int
jobUnregCount int
jobStartCount int
jobRunningCount int
jobCompletCount int
jobFailCount int
}
func (m *DebugMonitor) SchedulerStarted() {
m.startCount++
fmt.Printf("✓ SchedulerStarted() called (total: %d)\n", m.startCount)
}
func (m *DebugMonitor) SchedulerShutdown() {
m.stopCount++
fmt.Printf("✓ SchedulerShutdown() called (total: %d)\n", m.stopCount)
}
func (m *DebugMonitor) JobRegistered(job *gocron.Job) {
m.jobRegCount++
fmt.Printf("✓ JobRegistered() called (total: %d) - Job ID: %s\n", m.jobRegCount, (*job).ID())
}
func (m *DebugMonitor) JobUnregistered(job *gocron.Job) {
m.jobUnregCount++
fmt.Printf("✓ JobUnregistered() called (total: %d) - Job ID: %s\n", m.jobUnregCount, (*job).ID())
}
func (m *DebugMonitor) JobStarted(job *gocron.Job) {
m.jobStartCount++
fmt.Printf("✓ JobStarted() called (total: %d) - Job ID: %s\n", m.jobStartCount, (*job).ID())
}
func (m *DebugMonitor) JobRunning(job *gocron.Job) {
m.jobRunningCount++
fmt.Printf("✓ JobRunning() called (total: %d) - Job ID: %s\n", m.jobRunningCount, (*job).ID())
}
func (m *DebugMonitor) JobCompleted(job *gocron.Job) {
m.jobCompletCount++
fmt.Printf("✓ JobCompleted() called (total: %d) - Job ID: %s\n", m.jobCompletCount, (*job).ID())
}
func (m *DebugMonitor) JobFailed(job *gocron.Job, err error) {
m.jobFailCount++
fmt.Printf("✓ JobFailed() called (total: %d) - Job ID: %s, Error: %v\n", m.jobFailCount, (*job).ID(), err)
}
func main() {
// ONE monitor, multiple scheduler instances
monitor := &DebugMonitor{}
fmt.Println("=== Cycle 1 (Scheduler Instance 1) ===")
s1, err := gocron.NewScheduler(
gocron.WithSchedulerMonitor(monitor),
)
if err != nil {
panic(err)
}
// Create and register some test jobs
fmt.Println("Creating jobs...")
_, err = s1.NewJob(
gocron.DurationJob(1*time.Second),
gocron.NewTask(func() { fmt.Println("Job 1 running") }),
)
if err != nil {
panic(err)
}
_, err = s1.NewJob(
gocron.DurationJob(2*time.Second),
gocron.NewTask(func() error {
fmt.Println("Job 2 executing and returning error")
return fmt.Errorf("simulated job failure")
}), // This job will fail with error
)
if err != nil {
panic(err)
}
fmt.Println("Calling Start()...")
s1.Start()
time.Sleep(3 * time.Second) // Wait for jobs to execute
fmt.Println("Calling Shutdown()...")
err = s1.Shutdown()
if err != nil {
fmt.Printf("Shutdown error: %v\n", err)
}
fmt.Println("\n=== Cycle 2 (Job Updates) ===")
s2, err := gocron.NewScheduler(
gocron.WithSchedulerMonitor(monitor),
)
if err != nil {
panic(err)
}
fmt.Println("Creating and updating jobs...")
job3, err := s2.NewJob(
gocron.DurationJob(1*time.Second),
gocron.NewTask(func() { fmt.Println("Job 3 running") }),
)
if err != nil {
panic(err)
}
// Update the job
_, err = s2.Update(
job3.ID(),
gocron.DurationJob(2*time.Second),
gocron.NewTask(func() { fmt.Println("Job 3 updated") }),
)
if err != nil {
panic(err)
}
fmt.Println("Calling Start()...")
s2.Start()
time.Sleep(3 * time.Second)
fmt.Println("Calling Shutdown()...")
err = s2.Shutdown()
if err != nil {
fmt.Printf("Shutdown error: %v\n", err)
}
fmt.Println("\n=== Summary ===")
fmt.Printf("Total Scheduler Starts: %d\n", monitor.startCount)
fmt.Printf("Total Scheduler Stops: %d\n", monitor.stopCount)
fmt.Printf("Total Jobs Registered: %d\n", monitor.jobRegCount)
fmt.Printf("Total Jobs Unregistered: %d\n", monitor.jobUnregCount)
fmt.Printf("Total Jobs Started: %d\n", monitor.jobStartCount)
fmt.Printf("Total Jobs Running: %d\n", monitor.jobRunningCount)
fmt.Printf("Total Jobs Completed: %d\n", monitor.jobCompletCount)
fmt.Printf("Total Jobs Failed: %d\n", monitor.jobFailCount)
}
================================================
FILE: gocron-monitor-test/go.mod
================================================
module test
go 1.21.4
require github.com/go-co-op/gocron/v2 v2.17.0
require (
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
)
replace github.com/go-co-op/gocron/v2 => ../
================================================
FILE: gocron-monitor-test/go.sum
================================================
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: job.go
================================================
//go:generate mockgen -destination=mocks/job.go -package=gocronmocks . Job
package gocron
import (
"context"
"errors"
"fmt"
"math/rand"
"slices"
"strings"
"time"
"github.com/google/uuid"
"github.com/jonboulle/clockwork"
"github.com/robfig/cron/v3"
)
// internalJob stores the information needed by the scheduler
// to manage scheduling, starting and stopping the job
type internalJob struct {
ctx context.Context
parentCtx context.Context
cancel context.CancelFunc
id uuid.UUID
name string
tags []string
cron Cron
jobSchedule
// as some jobs may queue up, it's possible to
// have multiple nextScheduled times
nextScheduled []time.Time
lastRun time.Time
function any
parameters []any
timer clockwork.Timer
singletonMode bool
singletonLimitMode LimitMode
limitRunsTo *limitRunsTo
startTime time.Time
startImmediately bool
stopTime time.Time
intervalFromCompletion bool
// event listeners
afterJobRuns func(jobID uuid.UUID, jobName string)
beforeJobRuns func(jobID uuid.UUID, jobName string)
beforeJobRunsSkipIfBeforeFuncErrors func(jobID uuid.UUID, jobName string) error
afterJobRunsWithError func(jobID uuid.UUID, jobName string, err error)
afterJobRunsWithPanic func(jobID uuid.UUID, jobName string, recoverData any)
afterLockError func(jobID uuid.UUID, jobName string, err error)
disabledLocker bool
locker Locker
}
// stop is used to stop the job's timer and cancel the context
// stopping the timer is critical for cleaning up jobs that are
// sleeping in a time.AfterFunc timer when the job is being stopped.
// cancelling the context keeps the executor from continuing to try
// and run the job.
func (j *internalJob) stop() {
if j.timer != nil {
j.timer.Stop()
}
j.cancel()
}
func (j *internalJob) stopTimeReached(now time.Time) bool {
if j.stopTime.IsZero() {
return false
}
return j.stopTime.Before(now)
}
// task stores the function and parameters
// that are actually run when the job is executed.
type task struct {
function any
parameters []any
}
// Task defines a function that returns the task
// function and parameters.
type Task func() task
// NewTask provides the job's task function and parameters.
// If you set the first argument of your Task func to be a context.Context,
// gocron will pass in a context (either the default Job context, or one
// provided via WithContext) to the job and will cancel the context on shutdown.
// This allows you to listen for and handle cancellation within your job.
func NewTask(function any, parameters ...any) Task {
return func() task {
return task{
function: function,
parameters: parameters,
}
}
}
// limitRunsTo is used for managing the number of runs
// when the user only wants the job to run a certain
// number of times and then be removed from the scheduler.
type limitRunsTo struct {
limit uint
runCount uint
}
// -----------------------------------------------
// -----------------------------------------------
// --------------- Custom Cron -------------------
// -----------------------------------------------
// -----------------------------------------------
// Cron defines the interface that must be
// implemented to provide a custom cron implementation for
// the job. Pass in the implementation using the JobOption WithCronImplementation.
type Cron interface {
IsValid(crontab string, location *time.Location, now time.Time) error
Next(lastRun time.Time) time.Time
}
// -----------------------------------------------
// -----------------------------------------------
// --------------- Job Variants ------------------
// -----------------------------------------------
// -----------------------------------------------
// JobDefinition defines the interface that must be
// implemented to create a job from the definition.
type JobDefinition interface {
setup(j *internalJob, l *time.Location, now time.Time) error
}
// Default cron implementation
func newDefaultCronImplementation(withSeconds bool) Cron {
return &defaultCron{
withSeconds: withSeconds,
}
}
// NewDefaultCron returns the default cron implementation for use outside the
// scheduling of a job. For example, validating crontab syntax before passing to the
// NewJob function.
func NewDefaultCron(cronStatementsIncludeSeconds bool) Cron {
return &defaultCron{
withSeconds: cronStatementsIncludeSeconds,
}
}
var _ Cron = (*defaultCron)(nil)
type defaultCron struct {
cronSchedule cron.Schedule
withSeconds bool
}
func (c *defaultCron) IsValid(crontab string, location *time.Location, now time.Time) error {
var withLocation string
if strings.HasPrefix(crontab, "TZ=") || strings.HasPrefix(crontab, "CRON_TZ=") {
withLocation = crontab
} else {
// since the user didn't provide a timezone default to the location
// passed in by the scheduler. Default: time.Local
withLocation = fmt.Sprintf("CRON_TZ=%s %s", location.String(), crontab)
}
var (
cronSchedule cron.Schedule
err error
)
if c.withSeconds {
p := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
cronSchedule, err = p.Parse(withLocation)
} else {
cronSchedule, err = cron.ParseStandard(withLocation)
}
if err != nil {
return errors.Join(ErrCronJobParse, err)
}
if cronSchedule.Next(now).IsZero() {
return ErrCronJobInvalid
}
c.cronSchedule = cronSchedule
return nil
}
func (c *defaultCron) Next(lastRun time.Time) time.Time {
return c.cronSchedule.Next(lastRun)
}
// default cron job implementation
var _ JobDefinition = (*cronJobDefinition)(nil)
type cronJobDefinition struct {
crontab string
cron Cron
}
func (c cronJobDefinition) setup(j *internalJob, location *time.Location, now time.Time) error {
if j.cron != nil {
c.cron = j.cron
}
if err := c.cron.IsValid(c.crontab, location, now); err != nil {
return err
}
j.jobSchedule = &cronJob{crontab: c.crontab, cronSchedule: c.cron}
return nil
}
// CronJob defines a new job using the crontab syntax: `* * * * *`.
// An optional 6th field can be used at the beginning if withSeconds
// is set to true: `* * * * * *`.
// The timezone can be set on the Scheduler using WithLocation, or in the
// crontab in the form `TZ=America/Chicago * * * * *` or
// `CRON_TZ=America/Chicago * * * * *`
func CronJob(crontab string, withSeconds bool) JobDefinition {
return cronJobDefinition{
crontab: crontab,
cron: newDefaultCronImplementation(withSeconds),
}
}
var _ JobDefinition = (*durationJobDefinition)(nil)
type durationJobDefinition struct {
duration time.Duration
}
func (d durationJobDefinition) setup(j *internalJob, _ *time.Location, _ time.Time) error {
if d.duration == 0 {
return ErrDurationJobIntervalZero
}
if d.duration < 0 {
return ErrDurationJobIntervalNegative
}
j.jobSchedule = &durationJob{duration: d.duration}
return nil
}
// DurationJob defines a new job using time.Duration
// for the interval.
func DurationJob(duration time.Duration) JobDefinition {
return durationJobDefinition{
duration: duration,
}
}
var _ JobDefinition = (*durationRandomJobDefinition)(nil)
type durationRandomJobDefinition struct {
min, max time.Duration
}
func (d durationRandomJobDefinition) setup(j *internalJob, _ *time.Location, _ time.Time) error {
if d.min >= d.max {
return ErrDurationRandomJobMinMax
}
if d.min <= 0 || d.max <= 0 {
return ErrDurationRandomJobPositive
}
j.jobSchedule = &durationRandomJob{
min: d.min,
max: d.max,
rand: rand.New(rand.NewSource(time.Now().UnixNano())), // nolint:gosec
}
return nil
}
// DurationRandomJob defines a new job that runs on a random interval
// between the min and max duration values provided.
//
// To achieve a similar behavior as tools that use a splay/jitter technique
// consider the median value as the baseline and the difference between the
// max-median or median-min as the splay/jitter.
//
// For example, if you want a job to run every 5 minutes, but want to add
// up to 1 min of jitter to the interval, you could use
// DurationRandomJob(4*time.Minute, 6*time.Minute)
func DurationRandomJob(minDuration, maxDuration time.Duration) JobDefinition {
return durationRandomJobDefinition{
min: minDuration,
max: maxDuration,
}
}
// DailyJob runs the job on the interval of days, and at the set times.
// By default, the job will start the next available day, considering the last run to be now,
// and the time and day based on the interval and times you input. This means, if you
// select an interval greater than 1, your job by default will run X (interval) days from now
// if there are no atTimes left in the current day. You can use WithStartAt to tell the
// scheduler to start the job sooner.
func DailyJob(interval uint, atTimes AtTimes) JobDefinition {
return dailyJobDefinition{
interval: interval,
atTimes: atTimes,
}
}
var _ JobDefinition = (*dailyJobDefinition)(nil)
type dailyJobDefinition struct {
interval uint
atTimes AtTimes
}
func (d dailyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error {
atTimesDate, err := convertAtTimesToDateTime(d.atTimes, location)
switch {
case errors.Is(err, errAtTimesNil):
return ErrDailyJobAtTimesNil
case errors.Is(err, errAtTimeNil):
return ErrDailyJobAtTimeNil
case errors.Is(err, errAtTimeHours):
return ErrDailyJobHours
case errors.Is(err, errAtTimeMinSec):
return ErrDailyJobMinutesSeconds
}
if d.interval == 0 {
return ErrDailyJobZeroInterval
}
ds := dailyJob{
interval: d.interval,
atTimes: atTimesDate,
}
j.jobSchedule = ds
return nil
}
var _ JobDefinition = (*weeklyJobDefinition)(nil)
type weeklyJobDefinition struct {
interval uint
daysOfTheWeek Weekdays
atTimes AtTimes
}
func (w weeklyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error {
var ws weeklyJob
if w.interval == 0 {
return ErrWeeklyJobZeroInterval
}
ws.interval = w.interval
if w.daysOfTheWeek == nil {
return ErrWeeklyJobDaysOfTheWeekNil
}
daysOfTheWeek := w.daysOfTheWeek()
slices.Sort(daysOfTheWeek)
ws.daysOfWeek = daysOfTheWeek
atTimesDate, err := convertAtTimesToDateTime(w.atTimes, location)
switch {
case errors.Is(err, errAtTimesNil):
return ErrWeeklyJobAtTimesNil
case errors.Is(err, errAtTimeNil):
return ErrWeeklyJobAtTimeNil
case errors.Is(err, errAtTimeHours):
return ErrWeeklyJobHours
case errors.Is(err, errAtTimeMinSec):
return ErrWeeklyJobMinutesSeconds
}
ws.atTimes = atTimesDate
j.jobSchedule = ws
return nil
}
// Weekdays defines a function that returns a list of week days.
type Weekdays func() []time.Weekday
// NewWeekdays provide the days of the week the job should run.
func NewWeekdays(weekday time.Weekday, weekdays ...time.Weekday) Weekdays {
return func() []time.Weekday {
return append([]time.Weekday{weekday}, weekdays...)
}
}
// WeeklyJob runs the job on the interval of weeks, on the specific days of the week
// specified, and at the set times.
//
// By default, the job will start the next available day, considering the last run to be now,
// and the time and day based on the interval, days and times you input. This means, if you
// select an interval greater than 1, your job by default will run X (interval) weeks from now
// if there are no daysOfTheWeek left in the current week. You can use WithStartAt to tell the
// scheduler to start the job sooner.
func WeeklyJob(interval uint, daysOfTheWeek Weekdays, atTimes AtTimes) JobDefinition {
return weeklyJobDefinition{
interval: interval,
daysOfTheWeek: daysOfTheWeek,
atTimes: atTimes,
}
}
var _ JobDefinition = (*monthlyJobDefinition)(nil)
type monthlyJobDefinition struct {
interval uint
daysOfTheMonth DaysOfTheMonth
atTimes AtTimes
}
func (m monthlyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error {
var ms monthlyJob
if m.interval == 0 {
return ErrMonthlyJobZeroInterval
}
ms.interval = m.interval
if m.daysOfTheMonth == nil {
return ErrMonthlyJobDaysNil
}
var daysStart, daysEnd []int
for _, day := range m.daysOfTheMonth() {
if day > 31 || day == 0 || day < -31 {
return ErrMonthlyJobDays
}
if day > 0 {
daysStart = append(daysStart, day)
} else {
daysEnd = append(daysEnd, day)
}
}
daysStart = removeSliceDuplicatesInt(daysStart)
ms.days = daysStart
daysEnd = removeSliceDuplicatesInt(daysEnd)
ms.daysFromEnd = daysEnd
atTimesDate, err := convertAtTimesToDateTime(m.atTimes, location)
switch {
case errors.Is(err, errAtTimesNil):
return ErrMonthlyJobAtTimesNil
case errors.Is(err, errAtTimeNil):
return ErrMonthlyJobAtTimeNil
case errors.Is(err, errAtTimeHours):
return ErrMonthlyJobHours
case errors.Is(err, errAtTimeMinSec):
return ErrMonthlyJobMinutesSeconds
}
ms.atTimes = atTimesDate
j.jobSchedule = ms
return nil
}
type days []int
// DaysOfTheMonth defines a function that returns a list of days.
type DaysOfTheMonth func() days
// NewDaysOfTheMonth provide the days of the month the job should
// run. The days can be positive 1 to 31 and/or negative -31 to -1.
// Negative values count backwards from the end of the month.
// For example: -1 == the last day of the month.
//
// -5 == 5 days before the end of the month.
func NewDaysOfTheMonth(day int, moreDays ...int) DaysOfTheMonth {
return func() days {
return append([]int{day}, moreDays...)
}
}
type atTime struct {
hours, minutes, seconds uint
}
func (a atTime) time(location *time.Location) time.Time {
return time.Date(0, 0, 0, int(a.hours), int(a.minutes), int(a.seconds), 0, location)
}
// TimeFromAtTime is a helper function to allow converting AtTime into a time.Time value
// Note: the time.Time value will have zero values for all Time fields except Hours, Minutes, Seconds.
//
// For example: time.Date(0, 0, 0, 1, 1, 1, 0, time.UTC)
func TimeFromAtTime(at AtTime, loc *time.Location) time.Time {
return at().time(loc)
}
// AtTime defines a function that returns the internal atTime
type AtTime func() atTime
// NewAtTime provide the hours, minutes and seconds at which
// the job should be run
func NewAtTime(hours, minutes, seconds uint) AtTime {
return func() atTime {
return atTime{hours: hours, minutes: minutes, seconds: seconds}
}
}
// AtTimes define a list of AtTime
type AtTimes func() []AtTime
// NewAtTimes provide the hours, minutes and seconds at which
// the job should be run
func NewAtTimes(atTime AtTime, atTimes ...AtTime) AtTimes {
return func() []AtTime {
return append([]AtTime{atTime}, atTimes...)
}
}
// MonthlyJob runs the job on the interval of months, on the specific days of the month
// specified, and at the set times. Days of the month can be 1 to 31 or negative (-1 to -31), which
// count backwards from the end of the month. E.g. -1 is the last day of the month.
//
// If a day of the month is selected that does not exist in all months (e.g. 31st)
// any month that does not have that day will be skipped.
//
// By default, the job will start the next available day, considering the last run to be now,
// and the time and month based on the interval, days and times you input.
// This means, if you select an interval greater than 1, your job by default will run
// X (interval) months from now if there are no daysOfTheMonth left in the current month.
// You can use WithStartAt to tell the scheduler to start the job sooner.
//
// Carefully consider your configuration!
// - For example: an interval of 2 months on the 31st of each month, starting 12/31
// would skip Feb, April, June, and next run would be in August.
func MonthlyJob(interval uint, daysOfTheMonth DaysOfTheMonth, atTimes AtTimes) JobDefinition {
return monthlyJobDefinition{
interval: interval,
daysOfTheMonth: daysOfTheMonth,
atTimes: atTimes,
}
}
var _ JobDefinition = (*oneTimeJobDefinition)(nil)
type oneTimeJobDefinition struct {
startAt OneTimeJobStartAtOption
}
func (o oneTimeJobDefinition) setup(j *internalJob, _ *time.Location, now time.Time) error {
sortedTimes := o.startAt(j)
slices.SortStableFunc(sortedTimes, ascendingTime)
// deduplicate the times
sortedTimes = removeSliceDuplicatesTimeOnSortedSlice(sortedTimes)
// keep only schedules that are in the future
idx, found := slices.BinarySearchFunc(sortedTimes, now, ascendingTime)
if found {
idx++
}
sortedTimes = sortedTimes[idx:]
if !j.startImmediately && len(sortedTimes) == 0 {
return ErrOneTimeJobStartDateTimePast
}
j.jobSchedule = oneTimeJob{sortedTimes: sortedTimes}
return nil
}
func removeSliceDuplicatesTimeOnSortedSlice(times []time.Time) []time.Time {
ret := make([]time.Time, 0, len(times))
for i, t := range times {
if i == 0 || t != times[i-1] {
ret = append(ret, t)
}
}
return ret
}
// OneTimeJobStartAtOption defines when the one time job is run
type OneTimeJobStartAtOption func(*internalJob) []time.Time
// OneTimeJobStartImmediately tells the scheduler to run the one time job immediately.
func OneTimeJobStartImmediately() OneTimeJobStartAtOption {
return func(j *internalJob) []time.Time {
j.startImmediately = true
return []time.Time{}
}
}
// OneTimeJobStartDateTime sets the date & time at which the job should run.
// This datetime must be in the future (according to the scheduler clock).
func OneTimeJobStartDateTime(start time.Time) OneTimeJobStartAtOption {
return func(_ *internalJob) []time.Time {
return []time.Time{start}
}
}
// OneTimeJobStartDateTimes sets the date & times at which the job should run.
// At least one of the date/times must be in the future (according to the scheduler clock).
func OneTimeJobStartDateTimes(times ...time.Time) OneTimeJobStartAtOption {
return func(_ *internalJob) []time.Time {
return times
}
}
// OneTimeJob is to run a job once at a specified time and not on
// any regular schedule.
func OneTimeJob(startAt OneTimeJobStartAtOption) JobDefinition {
return oneTimeJobDefinition{
startAt: startAt,
}
}
// -----------------------------------------------
// -----------------------------------------------
// ----------------- Job Options -----------------
// -----------------------------------------------
// -----------------------------------------------
// JobOption defines the constructor for job options.
type JobOption func(*internalJob, time.Time) error
// WithDistributedJobLocker sets the locker to be used by multiple
// Scheduler instances to ensure that only one instance of each
// job is run.
func WithDistributedJobLocker(locker Locker) JobOption {
return func(j *internalJob, _ time.Time) error {
if locker == nil {
return ErrWithDistributedJobLockerNil
}
j.locker = locker
return nil
}
}
// WithDisabledDistributedJobLocker disables the distributed job locker.
// This is useful when a global distributed locker has been set on the scheduler
// level using WithDistributedLocker and need to be disabled for specific jobs.
func WithDisabledDistributedJobLocker(disabled bool) JobOption {
return func(j *internalJob, _ time.Time) error {
j.disabledLocker = disabled
return nil
}
}
// WithEventListeners sets the event listeners that should be
// run for the job.
func WithEventListeners(eventListeners ...EventListener) JobOption {
return func(j *internalJob, _ time.Time) error {
for _, eventListener := range eventListeners {
if err := eventListener(j); err != nil {
return err
}
}
return nil
}
}
// WithLimitedRuns limits the number of executions of this job to n.
// Upon reaching the limit, the job is removed from the scheduler.
func WithLimitedRuns(limit uint) JobOption {
return func(j *internalJob, _ time.Time) error {
if limit == 0 {
return ErrWithLimitedRunsZero
}
j.limitRunsTo = &limitRunsTo{
limit: limit,
runCount: 0,
}
return nil
}
}
// WithName sets the name of the job. Name provides
// a human-readable identifier for the job.
func WithName(name string) JobOption {
return func(j *internalJob, _ time.Time) error {
if name == "" {
return ErrWithNameEmpty
}
j.name = name
return nil
}
}
// WithCronImplementation sets the custom Cron implementation for the job.
// This is only utilized for the CronJob type.
func WithCronImplementation(c Cron) JobOption {
return func(j *internalJob, _ time.Time) error {
j.cron = c
return nil
}
}
// WithSingletonMode keeps the job from running again if it is already running.
// This is useful for jobs that should not overlap, and that occasionally
// (but not consistently) run longer than the interval between job runs.
func WithSingletonMode(mode LimitMode) JobOption {
return func(j *internalJob, _ time.Time) error {
j.singletonMode = true
j.singletonLimitMode = mode
return nil
}
}
// WithIntervalFromCompletion configures the job to calculate the next run time
// from the job's completion time rather than its scheduled start time.
// This ensures consistent rest periods between job executions regardless of
// how long each execution takes.
//
// By default (without this option), a job scheduled to run every N time units
// will start N time units after its previous scheduled start time. For example,
// if a job is scheduled to run every 5 minutes starting at 09:00 and takes 2 minutes
// to complete, the next run will start at 09:05 (5 minutes from 09:00), giving
// only 3 minutes of rest between completion and the next start.
//
// With this option enabled, the next run will start N time units after the job
// completes. Using the same example, if the job completes at 09:02, the next run
// will start at 09:07 (5 minutes from 09:02), ensuring a full 5 minutes of rest.
//
// Note: This option only makes sense with interval-based jobs (DurationJob, DurationRandomJob).
// For time-based jobs (CronJob, DailyJob, etc.) that run at specific times, this option
// will be ignored as those jobs are inherently scheduled at fixed times.
//
// Example:
//
// s.NewJob(
// gocron.DurationJob(5*time.Minute),
// gocron.NewTask(func() {
// // Job that takes variable time to complete
// doWork()
// }),
// gocron.WithIntervalFromCompletion(),
// )
//
// In this example, no matter how long doWork() takes, there will always be
// exactly 5 minutes between when it completes and when it starts again.
func WithIntervalFromCompletion() JobOption {
return func(j *internalJob, _ time.Time) error {
j.intervalFromCompletion = true
return nil
}
}
// WithStartAt sets the option for starting the job at
// a specific datetime.
func WithStartAt(option StartAtOption) JobOption {
return func(j *internalJob, now time.Time) error {
return option(j, now)
}
}
// StartAtOption defines options for starting the job
type StartAtOption func(*internalJob, time.Time) error
// WithStartImmediately tells the scheduler to run the job immediately
// regardless of the type or schedule of job. After this immediate run
// the job is scheduled from this time based on the job definition.
func WithStartImmediately() StartAtOption {
return func(j *internalJob, _ time.Time) error {
j.startImmediately = true
return nil
}
}
// WithStartDateTime sets the first date & time at which the job should run.
// This datetime must be in the future.
func WithStartDateTime(start time.Time) StartAtOption {
return func(j *internalJob, now time.Time) error {
if start.IsZero() || start.Before(now) {
return ErrWithStartDateTimePast
}
if !j.stopTime.IsZero() && j.stopTime.Before(start) {
return ErrStartTimeLaterThanEndTime
}
j.startTime = start
return nil
}
}
// WithStartDateTimePast sets the first date & time at which the job should run
// from a time in the past. This is useful when you want to backdate
// the start time of a job to a time in the past, for example
// if you want to start a job from a specific date in the past
// and have it run on its schedule from then.
// The start time can be in the past, but not zero.
// If the start time is in the future, it behaves the same as WithStartDateTime.
func WithStartDateTimePast(start time.Time) StartAtOption {
return func(j *internalJob, _ time.Time) error {
if start.IsZero() {
return ErrWithStartDateTimePastZero
}
if !j.stopTime.IsZero() && j.stopTime.Before(start) {
return ErrStartTimeLaterThanEndTime
}
j.startTime = start
return nil
}
}
// WithStopAt sets the option for stopping the job from running
// after the specified time.
func WithStopAt(option StopAtOption) JobOption {
return func(j *internalJob, now time.Time) error {
return option(j, now)
}
}
// StopAtOption defines options for stopping the job
type StopAtOption func(*internalJob, time.Time) error
// WithStopDateTime sets the final date & time after which the job should stop.
// This must be in the future and should be after the startTime (if specified).
// The job's final run may be at the stop time, but not after.
func WithStopDateTime(end time.Time) StopAtOption {
return func(j *internalJob, now time.Time) error {
if end.IsZero() || end.Before(now) {
return ErrWithStopDateTimePast
}
if end.Before(j.startTime) {
return ErrStopTimeEarlierThanStartTime
}
j.stopTime = end
return nil
}
}
// WithTags sets the tags for the job. Tags provide
// a way to identify jobs by a set of tags and remove
// multiple jobs by tag.
func WithTags(tags ...string) JobOption {
return func(j *internalJob, _ time.Time) error {
j.tags = tags
return nil
}
}
// WithIdentifier sets the identifier for the job. The identifier
// is used to uniquely identify the job and is used for logging
// and metrics.
func WithIdentifier(id uuid.UUID) JobOption {
return func(j *internalJob, _ time.Time) error {
if id == uuid.Nil {
return ErrWithIdentifierNil
}
j.id = id
return nil
}
}
// WithContext sets the parent context for the job.
// If you set the first argument of your Task func to be a context.Context,
// gocron will pass in the provided context to the job and will cancel the
// context on shutdown. If you cancel the context the job will no longer be
// scheduled as well. This allows you to both control the job via a context
// and listen for and handle cancellation within your job.
func WithContext(ctx context.Context) JobOption {
return func(j *internalJob, _ time.Time) error {
if ctx == nil {
return ErrWithContextNil
}
j.parentCtx = ctx
return nil
}
}
// -----------------------------------------------
// -----------------------------------------------
// ------------- Job Event Listeners -------------
// -----------------------------------------------
// -----------------------------------------------
// EventListener defines the constructor for event
// listeners that can be used to listen for job events.
type EventListener func(*internalJob) error
// BeforeJobRuns is used to listen for when a job is about to run and
// then run the provided function.
func BeforeJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) EventListener {
return func(j *internalJob) error {
if eventListenerFunc == nil {
return ErrEventListenerFuncNil
}
j.beforeJobRuns = eventListenerFunc
return nil
}
}
// BeforeJobRunsSkipIfBeforeFuncErrors is used to listen for when a job is about to run and
// then runs the provided function. If the provided function returns an error, the job will be
// rescheduled and the current run will be skipped.
func BeforeJobRunsSkipIfBeforeFuncErrors(eventListenerFunc func(jobID uuid.UUID, jobName string) error) EventListener {
return func(j *internalJob) error {
if eventListenerFunc == nil {
return ErrEventListenerFuncNil
}
j.beforeJobRunsSkipIfBeforeFuncErrors = eventListenerFunc
return nil
}
}
// AfterJobRuns is used to listen for when a job has run
// without an error, and then run the provided function.
func AfterJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) EventListener {
return func(j *internalJob) error {
if eventListenerFunc == nil {
return ErrEventListenerFuncNil
}
j.afterJobRuns = eventListenerFunc
return nil
}
}
// AfterJobRunsWithError is used to listen for when a job has run and
// returned an error, and then run the provided function.
func AfterJobRunsWithError(eventListenerFunc func(jobID uuid.UUID, jobName string, err error)) EventListener {
return func(j *internalJob) error {
if eventListenerFunc == nil {
return ErrEventListenerFuncNil
}
j.afterJobRunsWithError = eventListenerFunc
return nil
}
}
// AfterJobRunsWithPanic is used to listen for when a job has run and
// returned panicked recover data, and then run the provided function.
func AfterJobRunsWithPanic(eventListenerFunc func(jobID uuid.UUID, jobName string, recoverData any)) EventListener {
return func(j *internalJob) error {
if eventListenerFunc == nil {
return ErrEventListenerFuncNil
}
j.afterJobRunsWithPanic = eventListenerFunc
return nil
}
}
// AfterLockError is used to when the distributed locker returns an error and
// then run the provided function.
func AfterLockError(eventListenerFunc func(jobID uuid.UUID, jobName string, err error)) EventListener {
return func(j *internalJob) error {
if eventListenerFunc == nil {
return ErrEventListenerFuncNil
}
j.afterLockError = eventListenerFunc
return nil
}
}
// -----------------------------------------------
// -----------------------------------------------
// ---------------- Job Schedules ----------------
// -----------------------------------------------
// -----------------------------------------------
type jobSchedule interface {
next(lastRun time.Time) time.Time
}
var _ jobSchedule = (*cronJob)(nil)
type cronJob struct {
crontab string
cronSchedule Cron
}
func (j *cronJob) next(lastRun time.Time) time.Time {
return j.cronSchedule.Next(lastRun)
}
var _ jobSchedule = (*durationJob)(nil)
type durationJob struct {
duration time.Duration
}
func (j *durationJob) next(lastRun time.Time) time.Time {
return lastRun.Add(j.duration)
}
var _ jobSchedule = (*durationRandomJob)(nil)
type durationRandomJob struct {
min, max time.Duration
rand *rand.Rand
}
func (j *durationRandomJob) next(lastRun time.Time) time.Time {
r := j.rand.Int63n(int64(j.max - j.min))
return lastRun.Add(j.min + time.Duration(r))
}
var _ jobSchedule = (*dailyJob)(nil)
type dailyJob struct {
interval uint
atTimes []time.Time
}
func (d dailyJob) next(lastRun time.Time) time.Time {
firstPass := true
next := d.nextDay(lastRun, firstPass)
if !next.IsZero() {
return next
}
firstPass = false
startNextDay := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day()+int(d.interval), 0, 0, 0, 0, lastRun.Location())
return d.nextDay(startNextDay, firstPass)
}
func (d dailyJob) nextDay(lastRun time.Time, firstPass bool) time.Time {
for _, at := range d.atTimes {
// sub the at time hour/min/sec onto the lastScheduledRun's values
// to use in checks to see if we've got our next run time
atDate := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day(), at.Hour(), at.Minute(), at.Second(), 0, lastRun.Location())
if firstPass && atDate.After(lastRun) {
// checking to see if it is after i.e. greater than,
// and not greater or equal as our lastScheduledRun day/time
// will be in the loop, and we don't want to select it again
return atDate
} else if !firstPass && !atDate.Before(lastRun) {
// now that we're looking at the next day, it's ok to consider
// the same at time that was last run (as lastScheduledRun has been incremented)
return atDate
}
}
return time.Time{}
}
var _ jobSchedule = (*weeklyJob)(nil)
type weeklyJob struct {
interval uint
daysOfWeek []time.Weekday
atTimes []time.Time
}
func (w weeklyJob) next(lastRun time.Time) time.Time {
next := w.nextWeekDayAtTime(lastRun, true)
if !next.IsZero() {
return next
}
startOfTheNextIntervalWeek := (lastRun.Day() - int(lastRun.Weekday())) + int(w.interval*7)
from := time.Date(lastRun.Year(), lastRun.Month(), startOfTheNextIntervalWeek, 0, 0, 0, 0, lastRun.Location())
return w.nextWeekDayAtTime(from, false)
}
func (w weeklyJob) nextWeekDayAtTime(lastRun time.Time, firstPass bool) time.Time {
for _, wd := range w.daysOfWeek {
// checking if we're on the same day or later in the same week
if wd >= lastRun.Weekday() {
// weekDayDiff is used to add the correct amount to the atDate day below
weekDayDiff := wd - lastRun.Weekday()
for _, at := range w.atTimes {
// sub the at time hour/min/sec onto the lastScheduledRun's values
// to use in checks to see if we've got our next run time
atDate := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day()+int(weekDayDiff), at.Hour(), at.Minute(), at.Second(), 0, lastRun.Location())
if firstPass && atDate.After(lastRun) {
// checking to see if it is after i.e. greater than,
// and not greater or equal as our lastScheduledRun day/time
// will be in the loop, and we don't want to select it again
return atDate
} else if !firstPass && !atDate.Before(lastRun) {
// now that we're looking at the next week, it's ok to consider
// the same at time that was last run (as lastScheduledRun has been incremented)
return atDate
}
}
}
}
return time.Time{}
}
var _ jobSchedule = (*monthlyJob)(nil)
type monthlyJob struct {
interval uint
days []int
daysFromEnd []int
atTimes []time.Time
}
func (m monthlyJob) next(lastRun time.Time) time.Time {
daysList := make([]int, len(m.days))
copy(daysList, m.days)
daysFromEnd := m.handleNegativeDays(lastRun, daysList, m.daysFromEnd)
next := m.nextMonthDayAtTime(lastRun, daysFromEnd, true)
if !next.IsZero() {
return next
}
from := time.Date(lastRun.Year(), lastRun.Month()+time.Month(m.interval), 1, 0, 0, 0, 0, lastRun.Location())
for next.IsZero() {
daysFromEnd = m.handleNegativeDays(from, daysList, m.daysFromEnd)
next = m.nextMonthDayAtTime(from, daysFromEnd, false)
from = from.AddDate(0, int(m.interval), 0)
}
return next
}
func (m monthlyJob) handleNegativeDays(from time.Time, days, negativeDays []int) []int {
var out []int
// getting a list of the days from the end of the following month
// -1 == the last day of the month
firstDayNextMonth := time.Date(from.Year(), from.Month()+1, 1, 0, 0, 0, 0, from.Location())
for _, daySub := range negativeDays {
day := firstDayNextMonth.AddDate(0, 0, daySub).Day()
out = append(out, day)
}
out = append(out, days...)
slices.Sort(out)
return out
}
func (m monthlyJob) nextMonthDayAtTime(lastRun time.Time, days []int, firstPass bool) time.Time {
// find the next day in the month that should run and then check for an at time
for _, day := range days {
if day >= lastRun.Day() {
for _, at := range m.atTimes {
// sub the day, and the at time hour/min/sec onto the lastScheduledRun's values
// to use in checks to see if we've got our next run time
atDate := time.Date(lastRun.Year(), lastRun.Month(), day, at.Hour(), at.Minute(), at.Second(), 0, lastRun.Location())
if atDate.Month() != lastRun.Month() {
// this check handles if we're setting a day not in the current month
// e.g. setting day 31 in Feb results in March 2nd
continue
}
if firstPass && atDate.After(lastRun) {
// checking to see if it is after i.e. greater than,
// and not greater or equal as our lastScheduledRun day/time
// will be in the loop, and we don't want to select it again
return atDate
} else if !firstPass && !atDate.Before(lastRun) {
// now that we're looking at the next month, it's ok to consider
// the same at time that was lastScheduledRun (as lastScheduledRun has been incremented)
return atDate
}
}
continue
}
}
return time.Time{}
}
var _ jobSchedule = (*oneTimeJob)(nil)
type oneTimeJob struct {
sortedTimes []time.Time
}
// next finds the next item in a sorted list of times using binary-search.
//
// example: sortedTimes: [2, 4, 6, 8]
//
// lastRun: 1 => [idx=0,found=false] => next is 2 - sorted[idx] idx=0
// lastRun: 2 => [idx=0,found=true] => next is 4 - sorted[idx+1] idx=1
// lastRun: 3 => [idx=1,found=false] => next is 4 - sorted[idx] idx=1
// lastRun: 4 => [idx=1,found=true] => next is 6 - sorted[idx+1] idx=2
// lastRun: 7 => [idx=3,found=false] => next is 8 - sorted[idx] idx=3
// lastRun: 8 => [idx=3,found=found] => next is none
// lastRun: 9 => [idx=3,found=found] => next is none
func (o oneTimeJob) next(lastRun time.Time) time.Time {
idx, found := slices.BinarySearchFunc(o.sortedTimes, lastRun, ascendingTime)
// if found, the next run is the following index
if found {
idx++
}
// exhausted runs
if idx >= len(o.sortedTimes) {
return time.Time{}
}
return o.sortedTimes[idx]
}
// -----------------------------------------------
// -----------------------------------------------
// ---------------- Job Interface ----------------
// -----------------------------------------------
// -----------------------------------------------
// Job provides the available methods on the job
// available to the caller.
type Job interface {
// ID returns the job's unique identifier.
ID() uuid.UUID
// LastRun returns the time of the job's last run
LastRun() (time.Time, error)
// Name returns the name defined on the job.
Name() string
// NextRun returns the time of the job's next scheduled run.
// This value is only available once the scheduler has been started
// with Scheduler.Start(). Before that, it returns the zero time value.
NextRun() (time.Time, error)
// NextRuns returns the requested number of calculated next run values.
// These values are only available once the scheduler has been started
// with Scheduler.Start(). Before that, it returns nil.
NextRuns(int) ([]time.Time, error)
// RunNow runs the job once, now. This does not alter
// the existing run schedule, and will respect all job
// and scheduler limits. This means that running a job now may
// cause the job's regular interval to be rescheduled due to
// the instance being run by RunNow blocking your run limit.
RunNow() error
// Tags returns the job's string tags.
Tags() []string
}
var _ Job = (*job)(nil)
// job is the internal struct that implements
// the public interface. This is used to avoid
// leaking information the caller never needs
// to have or tinker with.
type job struct {
id uuid.UUID
name string
tags []string
jobOutRequest chan *jobOutRequest
runJobRequest chan runJobRequest
}
func (j job) ID() uuid.UUID {
return j.id
}
func (j job) LastRun() (time.Time, error) {
ij := requestJob(j.id, j.jobOutRequest)
if ij == nil || ij.id == uuid.Nil {
return time.Time{}, ErrJobNotFound
}
return ij.lastRun, nil
}
func (j job) Name() string {
return j.name
}
func (j job) NextRun() (time.Time, error) {
ij := requestJob(j.id, j.jobOutRequest)
if ij == nil || ij.id == uuid.Nil {
return time.Time{}, ErrJobNotFound
}
if len(ij.nextScheduled) == 0 {
return time.Time{}, nil
}
// the first element is the next scheduled run with subsequent
// runs following after in the slice
return ij.nextScheduled[0], nil
}
func (j job) NextRuns(count int) ([]time.Time, error) {
ij := requestJob(j.id, j.jobOutRequest)
if ij == nil || ij.id == uuid.Nil {
return nil, ErrJobNotFound
}
lengthNextScheduled := len(ij.nextScheduled)
if lengthNextScheduled == 0 {
return nil, nil
} else if count <= lengthNextScheduled {
return ij.nextScheduled[:count], nil
}
out := make([]time.Time, count)
for i := 0; i < count; i++ {
if i < lengthNextScheduled {
out[i] = ij.nextScheduled[i]
continue
}
from := out[i-1]
out[i] = ij.next(from)
}
return out, nil
}
func (j job) Tags() []string {
return j.tags
}
func (j job) RunNow() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
resp := make(chan error, 1)
t := time.NewTimer(100 * time.Millisecond)
select {
case j.runJobRequest <- runJobRequest{
id: j.id,
outChan: resp,
}:
t.Stop()
case <-t.C:
return ErrJobRunNowFailed
}
var err error
select {
case <-ctx.Done():
return ErrJobRunNowFailed
case errReceived := <-resp:
err = errReceived
}
return err
}
================================================
FILE: job_test.go
================================================
package gocron
import (
"math/rand"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDurationJob_next(t *testing.T) {
tests := []time.Duration{
time.Millisecond,
time.Second,
100 * time.Second,
1000 * time.Second,
5 * time.Second,
50 * time.Second,
time.Minute,
5 * time.Minute,
100 * time.Minute,
time.Hour,
2 * time.Hour,
100 * time.Hour,
1000 * time.Hour,
}
lastRun := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
for _, duration := range tests {
t.Run(duration.String(), func(t *testing.T) {
d := durationJob{duration: duration}
next := d.next(lastRun)
expected := lastRun.Add(duration)
assert.Equal(t, expected, next)
})
}
}
func TestDailyJob_next(t *testing.T) {
americaChicago, err := time.LoadLocation("America/Chicago")
require.NoError(t, err)
tests := []struct {
name string
interval uint
atTimes []time.Time
lastRun time.Time
expectedNextRun time.Time
expectedDurationToNextRun time.Duration
}{
{
"daily at midnight",
1,
[]time.Time{
time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
24 * time.Hour,
},
{
"daily multiple at times",
1,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
time.Date(0, 0, 0, 12, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 1, 5, 30, 0, 0, time.UTC),
time.Date(2000, 1, 1, 12, 30, 0, 0, time.UTC),
7 * time.Hour,
},
{
"every 2 days multiple at times",
2,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
time.Date(0, 0, 0, 12, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 1, 12, 30, 0, 0, time.UTC),
time.Date(2000, 1, 3, 5, 30, 0, 0, time.UTC),
41 * time.Hour,
},
{
"daily at time with daylight savings time",
1,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, americaChicago),
},
time.Date(2023, 3, 11, 5, 30, 0, 0, americaChicago),
time.Date(2023, 3, 12, 5, 30, 0, 0, americaChicago),
23 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := dailyJob{
interval: tt.interval,
atTimes: tt.atTimes,
}
next := d.next(tt.lastRun)
assert.Equal(t, tt.expectedNextRun, next)
assert.Equal(t, tt.expectedDurationToNextRun, next.Sub(tt.lastRun))
})
}
}
func TestWeeklyJob_next(t *testing.T) {
americaChicago, err := time.LoadLocation("America/Chicago")
require.NoError(t, err)
tests := []struct {
name string
interval uint
daysOfWeek []time.Weekday
atTimes []time.Time
lastRun time.Time
expectedNextRun time.Time
expectedDurationToNextRun time.Duration
}{
{
"last run Monday, next run is Thursday",
1,
[]time.Weekday{time.Monday, time.Thursday},
[]time.Time{
time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
time.Date(2000, 1, 3, 0, 0, 0, 0, time.UTC),
time.Date(2000, 1, 6, 0, 0, 0, 0, time.UTC),
3 * 24 * time.Hour,
},
{
"last run Thursday, next run is Monday",
1,
[]time.Weekday{time.Monday, time.Thursday},
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 6, 5, 30, 0, 0, time.UTC),
time.Date(2000, 1, 10, 5, 30, 0, 0, time.UTC),
4 * 24 * time.Hour,
},
{
"last run before daylight savings time, next run after",
1,
[]time.Weekday{time.Saturday},
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, americaChicago),
},
time.Date(2023, 3, 11, 5, 30, 0, 0, americaChicago),
time.Date(2023, 3, 18, 5, 30, 0, 0, americaChicago),
7*24*time.Hour - time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := weeklyJob{
interval: tt.interval,
daysOfWeek: tt.daysOfWeek,
atTimes: tt.atTimes,
}
next := w.next(tt.lastRun)
assert.Equal(t, tt.expectedNextRun, next)
assert.Equal(t, tt.expectedDurationToNextRun, next.Sub(tt.lastRun))
})
}
}
func TestMonthlyJob_next(t *testing.T) {
americaChicago, err := time.LoadLocation("America/Chicago")
require.NoError(t, err)
tests := []struct {
name string
interval uint
days []int
daysFromEnd []int
atTimes []time.Time
lastRun time.Time
expectedNextRun time.Time
expectedDurationToNextRun time.Duration
}{
{
"same day - before at time",
1,
[]int{1},
nil,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2000, 1, 1, 5, 30, 0, 0, time.UTC),
5*time.Hour + 30*time.Minute,
},
{
"same day - after at time, runs next available date",
1,
[]int{1, 10},
nil,
[]time.Time{
time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2000, 1, 10, 0, 0, 0, 0, time.UTC),
9 * 24 * time.Hour,
},
{
"same day - after at time, runs next available date, following interval month",
2,
[]int{1},
nil,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 1, 5, 30, 0, 0, time.UTC),
time.Date(2000, 3, 1, 5, 30, 0, 0, time.UTC),
60 * 24 * time.Hour,
},
{
"daylight savings time",
1,
[]int{5},
nil,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, americaChicago),
},
time.Date(2023, 11, 1, 0, 0, 0, 0, americaChicago),
time.Date(2023, 11, 5, 5, 30, 0, 0, americaChicago),
4*24*time.Hour + 6*time.Hour + 30*time.Minute,
},
{
"negative days",
1,
nil,
[]int{-1, -3, -5},
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 29, 5, 30, 0, 0, time.UTC),
time.Date(2000, 1, 31, 5, 30, 0, 0, time.UTC),
2 * 24 * time.Hour,
},
{
"day not in current month, runs next month (leap year)",
1,
[]int{31},
nil,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 31, 5, 30, 0, 0, time.UTC),
time.Date(2000, 3, 31, 5, 30, 0, 0, time.UTC),
29*24*time.Hour + 31*24*time.Hour,
},
{
"multiple days not in order",
1,
[]int{10, 7, 19, 2},
nil,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2000, 1, 2, 5, 30, 0, 0, time.UTC),
time.Date(2000, 1, 7, 5, 30, 0, 0, time.UTC),
5 * 24 * time.Hour,
},
{
"day not in next interval month, selects next available option, skips Feb, April & June",
2,
[]int{31},
nil,
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(1999, 12, 31, 5, 30, 0, 0, time.UTC),
time.Date(2000, 8, 31, 5, 30, 0, 0, time.UTC),
244 * 24 * time.Hour,
},
{
"handle -1 with differing month's day count",
1,
nil,
[]int{-1},
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2024, 1, 31, 5, 30, 0, 0, time.UTC),
time.Date(2024, 2, 29, 5, 30, 0, 0, time.UTC),
29 * 24 * time.Hour,
},
{
"handle -1 with another differing month's day count",
1,
nil,
[]int{-1},
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2024, 2, 29, 5, 30, 0, 0, time.UTC),
time.Date(2024, 3, 31, 5, 30, 0, 0, time.UTC),
31 * 24 * time.Hour,
},
{
"handle -1 every 3 months next run in February",
3,
nil,
[]int{-1},
[]time.Time{
time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
},
time.Date(2023, 11, 30, 5, 30, 0, 0, time.UTC),
time.Date(2024, 2, 29, 5, 30, 0, 0, time.UTC),
91 * 24 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := monthlyJob{
interval: tt.interval,
days: tt.days,
daysFromEnd: tt.daysFromEnd,
atTimes: tt.atTimes,
}
next := m.next(tt.lastRun)
assert.Equal(t, tt.expectedNextRun, next)
assert.Equal(t, tt.expectedDurationToNextRun, next.Sub(tt.lastRun))
})
}
}
func TestDurationRandomJob_next(t *testing.T) {
tests := []struct {
name string
min time.Duration
max time.Duration
lastRun time.Time
expectedMin time.Time
expectedMax time.Time
}{
{
"min 1s, max 5s",
time.Second,
5 * time.Second,
time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2000, 1, 1, 0, 0, 1, 0, time.UTC),
time.Date(2000, 1, 1, 0, 0, 5, 0, time.UTC),
},
{
"min 100ms, max 1s",
100 * time.Millisecond,
1 * time.Second,
time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2000, 1, 1, 0, 0, 0, 100000000, time.UTC),
time.Date(2000, 1, 1, 0, 0, 1, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rj := durationRandomJob{
min: tt.min,
max: tt.max,
rand: rand.New(rand.NewSource(time.Now().UnixNano())), // nolint:gosec
}
for i := 0; i < 100; i++ {
next := rj.next(tt.lastRun)
assert.GreaterOrEqual(t, next, tt.expectedMin)
assert.LessOrEqual(t, next, tt.expectedMax)
}
})
}
}
func TestOneTimeJob_next(t *testing.T) {
otj := oneTimeJob{}
assert.Zero(t, otj.next(time.Time{}))
}
func TestJob_RunNow_Error(t *testing.T) {
s := newTestScheduler(t)
j, err := s.NewJob(
DurationJob(time.Second),
NewTask(func() {}),
)
require.NoError(t, err)
require.NoError(t, s.Shutdown())
assert.EqualError(t, j.RunNow(), ErrJobRunNowFailed.Error())
}
func TestJob_LastRun(t *testing.T) {
testTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)
fakeClock := clockwork.NewFakeClockAt(testTime)
s := newTestScheduler(t,
WithClock(fakeClock),
)
j, err := s.NewJob(
DurationJob(
time.Second,
),
NewTask(
func() {},
),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
s.Start()
time.Sleep(10 * time.Millisecond)
lastRun, err := j.LastRun()
assert.NoError(t, err)
err = s.Shutdown()
require.NoError(t, err)
assert.Equal(t, testTime, lastRun)
}
func TestWithEventListeners(t *testing.T) {
tests := []struct {
name string
eventListeners []EventListener
err error
}{
{
"no event listeners",
nil,
nil,
},
{
"beforeJobRuns",
[]EventListener{
BeforeJobRuns(func(_ uuid.UUID, _ string) {}),
},
nil,
},
{
"afterJobRuns",
[]EventListener{
AfterJobRuns(func(_ uuid.UUID, _ string) {}),
},
nil,
},
{
"afterJobRunsWithError",
[]EventListener{
AfterJobRunsWithError(func(_ uuid.UUID, _ string, _ error) {}),
},
nil,
},
{
"afterJobRunsWithPanic",
[]EventListener{
AfterJobRunsWithPanic(func(_ uuid.UUID, _ string, _ any) {}),
},
nil,
},
{
"afterLockError",
[]EventListener{
AfterLockError(func(_ uuid.UUID, _ string, _ error) {}),
},
nil,
},
{
"multiple event listeners",
[]EventListener{
AfterJobRuns(func(_ uuid.UUID, _ string) {}),
AfterJobRunsWithError(func(_ uuid.UUID, _ string, _ error) {}),
BeforeJobRuns(func(_ uuid.UUID, _ string) {}),
AfterLockError(func(_ uuid.UUID, _ string, _ error) {}),
},
nil,
},
{
"nil after job runs listener",
[]EventListener{
AfterJobRuns(nil),
},
ErrEventListenerFuncNil,
},
{
"nil after job runs with error listener",
[]EventListener{
AfterJobRunsWithError(nil),
},
ErrEventListenerFuncNil,
},
{
"nil before job runs listener",
[]EventListener{
BeforeJobRuns(nil),
},
ErrEventListenerFuncNil,
},
{
"nil before job runs error listener",
[]EventListener{
BeforeJobRunsSkipIfBeforeFuncErrors(nil),
},
ErrEventListenerFuncNil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var ij internalJob
err := WithEventListeners(tt.eventListeners...)(&ij, time.Now())
assert.Equal(t, tt.err, err)
if err != nil {
return
}
var count int
if ij.beforeJobRuns != nil {
count++
}
if ij.afterJobRuns != nil {
count++
}
if ij.afterJobRunsWithError != nil {
count++
}
if ij.afterJobRunsWithPanic != nil {
count++
}
if ij.afterLockError != nil {
count++
}
assert.Equal(t, len(tt.eventListeners), count)
})
}
}
func TestJob_NextRun(t *testing.T) {
tests := []struct {
name string
f func()
}{
{
"simple",
func() {},
},
{
"sleep 3 seconds",
func() {
time.Sleep(300 * time.Millisecond)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testTime := time.Now()
s := newTestScheduler(t)
j, err := s.NewJob(
DurationJob(
100*time.Millisecond,
),
NewTask(
func() {},
),
WithStartAt(WithStartDateTime(testTime.Add(100*time.Millisecond))),
WithSingletonMode(LimitModeReschedule),
)
require.NoError(t, err)
s.Start()
nextRun, err := j.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(100*time.Millisecond), nextRun)
time.Sleep(150 * time.Millisecond)
nextRun, err = j.NextRun()
assert.NoError(t, err)
assert.Equal(t, testTime.Add(200*time.Millisecond), nextRun)
assert.Equal(t, 200*time.Millisecond, nextRun.Sub(testTime))
err = s.Shutdown()
require.NoError(t, err)
})
}
}
func TestJob_NextRuns(t *testing.T) {
tests := []struct {
name string
jd JobDefinition
assertion func(t *testing.T, previousRun, nextRun time.Time)
}{
{
"simple - milliseconds",
DurationJob(
100 * time.Millisecond,
),
func(t *testing.T, previousRun, nextRun time.Time) {
assert.Equal(t, previousRun.UnixMilli()+100, nextRun.UnixMilli())
},
},
{
"weekly",
WeeklyJob(
2,
NewWeekdays(time.Tuesday),
NewAtTimes(
NewAtTime(0, 0, 0),
),
),
func(t *testing.T, previousRun, nextRun time.Time) {
// With the fix for NextRun accuracy, the immediate run (Jan 1) is removed
// from nextScheduled after it completes. So all intervals should be 14 days
// (2 weeks as configured).
diff := time.Hour * 14 * 24
assert.Equal(t, previousRun.Add(diff).Day(), nextRun.Day())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)
fakeClock := clockwork.NewFakeClockAt(testTime)
s := newTestScheduler(t,
WithClock(fakeClock),
)
j, err := s.NewJob(
tt.jd,
NewTask(
func() {},
),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
s.Start()
time.Sleep(10 * time.Millisecond)
nextRuns, err := j.NextRuns(5)
require.NoError(t, err)
assert.Len(t, nextRuns, 5)
for i := range nextRuns {
if i == 0 {
// skipping because there is no previous run
continue
}
tt.assertion(t, nextRuns[i-1], nextRuns[i])
}
assert.NoError(t, s.Shutdown())
})
}
}
func TestJob_PanicOccurred(t *testing.T) {
gotCh := make(chan any)
errCh := make(chan error)
s := newTestScheduler(t)
_, err := s.NewJob(
DurationJob(10*time.Millisecond),
NewTask(func() {
a := 0
_ = 1 / a
}),
WithEventListeners(
AfterJobRunsWithPanic(func(_ uuid.UUID, _ string, recoverData any) {
gotCh <- recoverData
}), AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
errCh <- err
}),
),
)
require.NoError(t, err)
s.Start()
got := <-gotCh
require.EqualError(t, got.(error), "runtime error: integer divide by zero")
err = <-errCh
require.ErrorIs(t, err, ErrPanicRecovered)
require.EqualError(t, err, "gocron: panic recovered from runtime error: integer divide by zero")
require.NoError(t, s.Shutdown())
close(gotCh)
close(errCh)
}
func TestTimeFromAtTime(t *testing.T) {
testTimeUTC := time.Date(0, 0, 0, 1, 1, 1, 0, time.UTC)
cst, err := time.LoadLocation("America/Chicago")
require.NoError(t, err)
testTimeCST := time.Date(0, 0, 0, 1, 1, 1, 0, cst)
tests := []struct {
name string
at AtTime
loc *time.Location
expectedTime time.Time
expectedStr string
}{
{
"UTC",
NewAtTime(
uint(testTimeUTC.Hour()),
uint(testTimeUTC.Minute()),
uint(testTimeUTC.Second()),
),
time.UTC,
testTimeUTC,
"01:01:01",
},
{
"CST",
NewAtTime(
uint(testTimeCST.Hour()),
uint(testTimeCST.Minute()),
uint(testTimeCST.Second()),
),
cst,
testTimeCST,
"01:01:01",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TimeFromAtTime(tt.at, tt.loc)
assert.Equal(t, tt.expectedTime, result)
resultFmt := result.Format("15:04:05")
assert.Equal(t, tt.expectedStr, resultFmt)
})
}
}
func TestNewAtTimes(t *testing.T) {
at := NewAtTimes(
NewAtTime(1, 1, 1),
NewAtTime(2, 2, 2),
)
var times []string
for _, att := range at() {
timeStr := TimeFromAtTime(att, time.UTC).Format("15:04")
times = append(times, timeStr)
}
var timesAgain []string
for _, att := range at() {
timeStr := TimeFromAtTime(att, time.UTC).Format("15:04")
timesAgain = append(timesAgain, timeStr)
}
assert.Equal(t, times, timesAgain)
}
func TestNewWeekdays(t *testing.T) {
wd := NewWeekdays(
time.Monday,
time.Tuesday,
)
var dayStrings []string
for _, w := range wd() {
dayStrings = append(dayStrings, w.String())
}
var dayStringsAgain []string
for _, w := range wd() {
dayStringsAgain = append(dayStringsAgain, w.String())
}
assert.Equal(t, dayStrings, dayStringsAgain)
}
func TestNewDaysOfTheMonth(t *testing.T) {
dom := NewDaysOfTheMonth(1, 2, 3)
var domInts []int
for _, d := range dom() {
domInts = append(domInts, d)
}
var domIntsAgain []int
for _, d := range dom() {
domIntsAgain = append(domIntsAgain, d)
}
assert.Equal(t, domInts, domIntsAgain)
}
func TestWithIntervalFromCompletion_BasicFunctionality(t *testing.T) {
t.Run("interval calculated from completion time", func(t *testing.T) {
s, err := NewScheduler()
require.NoError(t, err)
defer func() { _ = s.Shutdown() }()
var mu sync.Mutex
executions := []struct {
startTime time.Time
completeTime time.Time
}{}
jobExecutionTime := 2 * time.Second
scheduledInterval := 5 * time.Second
_, err = s.NewJob(
DurationJob(scheduledInterval),
NewTask(func() {
start := time.Now()
time.Sleep(jobExecutionTime)
complete := time.Now()
mu.Lock()
executions = append(executions, struct {
startTime time.Time
completeTime time.Time
}{start, complete})
mu.Unlock()
}),
WithIntervalFromCompletion(),
)
require.NoError(t, err)
s.Start()
// Wait for at least 3 executions
// With intervalFromCompletion:
// Execution 1: 0s-2s
// Wait: 5s (from 2s to 7s)
// Execution 2: 7s-9s
// Wait: 5s (from 9s to 14s)
// Execution 3: 14s-16s
time.Sleep(18 * time.Second)
mu.Lock()
executionCount := len(executions)
mu.Unlock()
require.GreaterOrEqual(t, executionCount, 2,
"Expected at least 2 executions")
mu.Lock()
defer mu.Unlock()
for i := 1; i < len(executions); i++ {
prev := executions[i-1]
curr := executions[i]
completionToStartGap := curr.startTime.Sub(prev.completeTime)
assert.InDelta(t, scheduledInterval.Seconds(), completionToStartGap.Seconds(), 0.5,
"Gap from completion to start should match the interval")
}
})
}
func TestWithIntervalFromCompletion_VariableExecutionTime(t *testing.T) {
s, err := NewScheduler()
require.NoError(t, err)
defer func() { _ = s.Shutdown() }()
var mu sync.Mutex
executions := []struct {
startTime time.Time
completeTime time.Time
executionDur time.Duration
}{}
executionTimes := []time.Duration{
1 * time.Second,
3 * time.Second,
500 * time.Millisecond,
}
currentExecution := atomic.Int32{}
scheduledInterval := 4 * time.Second
_, err = s.NewJob(
DurationJob(scheduledInterval),
NewTask(func() {
idx := int(currentExecution.Add(1)) - 1
if idx >= len(executionTimes) {
return
}
start := time.Now()
executionTime := executionTimes[idx]
time.Sleep(executionTime)
complete := time.Now()
mu.Lock()
executions = append(executions, struct {
startTime time.Time
completeTime time.Time
executionDur time.Duration
}{start, complete, executionTime})
mu.Unlock()
}),
WithIntervalFromCompletion(),
)
require.NoError(t, err)
s.Start()
// Wait for all 3 executions
// Execution 1: 0s-1s, wait 4s → next at 5s
// Execution 2: 5s-8s, wait 4s → next at 12s
// Execution 3: 12s-12.5s
time.Sleep(15 * time.Second)
mu.Lock()
defer mu.Unlock()
require.GreaterOrEqual(t, len(executions), 2, "Expected at least 2 executions")
for i := 1; i < len(executions); i++ {
prev := executions[i-1]
curr := executions[i]
restPeriod := curr.startTime.Sub(prev.completeTime)
assert.InDelta(t, scheduledInterval.Seconds(), restPeriod.Seconds(), 0.5,
"Rest period should be consistent regardless of execution time")
}
}
func TestWithIntervalFromCompletion_LongRunningJob(t *testing.T) {
s, err := NewScheduler()
require.NoError(t, err)
defer func() { _ = s.Shutdown() }()
var mu sync.Mutex
executions := []struct {
startTime time.Time
completeTime time.Time
}{}
jobExecutionTime := 6 * time.Second
scheduledInterval := 3 * time.Second
_, err = s.NewJob(
DurationJob(scheduledInterval),
NewTask(func() {
start := time.Now()
time.Sleep(jobExecutionTime)
complete := time.Now()
mu.Lock()
executions = append(executions, struct {
startTime time.Time
completeTime time.Time
}{start, complete})
mu.Unlock()
}),
WithIntervalFromCompletion(),
WithSingletonMode(LimitModeReschedule),
)
require.NoError(t, err)
s.Start()
// Wait for 2 executions
// Execution 1: 0s-6s, wait 3s → next at 9s
// Execution 2: 9s-15s, wait 3s → next at 18s
// Need to wait at least 16 seconds for 2 executions + buffer
time.Sleep(22 * time.Second)
mu.Lock()
defer mu.Unlock()
require.GreaterOrEqual(t, len(executions), 2, "Expected at least 2 executions")
if len(executions) < 2 {
t.Logf("Only got %d execution(s), skipping gap assertion", len(executions))
return
}
prev := executions[0]
curr := executions[1]
completionGap := curr.startTime.Sub(prev.completeTime)
assert.InDelta(t, scheduledInterval.Seconds(), completionGap.Seconds(), 0.5,
"Gap should be the full interval even when execution time exceeds interval")
}
func TestWithIntervalFromCompletion_ComparedToDefault(t *testing.T) {
jobExecutionTime := 2 * time.Second
scheduledInterval := 5 * time.Second
t.Run("default behavior - interval from scheduled time", func(t *testing.T) {
s, err := NewScheduler()
require.NoError(t, err)
defer func() { _ = s.Shutdown() }()
var mu sync.Mutex
executions := []struct {
startTime time.Time
completeTime time.Time
}{}
_, err = s.NewJob(
DurationJob(scheduledInterval),
NewTask(func() {
start := time.Now()
time.Sleep(jobExecutionTime)
complete := time.Now()
mu.Lock()
executions = append(executions, struct {
startTime time.Time
completeTime time.Time
}{start, complete})
mu.Unlock()
}),
)
require.NoError(t, err)
s.Start()
time.Sleep(13 * time.Second)
mu.Lock()
defer mu.Unlock()
require.GreaterOrEqual(t, len(executions), 2, "Expected at least 2 executions")
prev := executions[0]
curr := executions[1]
completionGap := curr.startTime.Sub(prev.completeTime)
expectedGap := scheduledInterval - jobExecutionTime
assert.InDelta(t, expectedGap.Seconds(), completionGap.Seconds(), 0.5,
"Default behavior: gap should be interval minus execution time")
})
t.Run("with intervalFromCompletion - interval from completion time", func(t *testing.T) {
s, err := NewScheduler()
require.NoError(t, err)
defer func() { _ = s.Shutdown() }()
var mu sync.Mutex
executions := []struct {
startTime time.Time
completeTime time.Time
}{}
_, err = s.NewJob(
DurationJob(scheduledInterval),
NewTask(func() {
start := time.Now()
time.Sleep(jobExecutionTime)
complete := time.Now()
mu.Lock()
executions = append(executions, struct {
startTime time.Time
completeTime time.Time
}{start, complete})
mu.Unlock()
}),
WithIntervalFromCompletion(),
)
require.NoError(t, err)
s.Start()
time.Sleep(15 * time.Second)
mu.Lock()
defer mu.Unlock()
require.GreaterOrEqual(t, len(executions), 2, "Expected at least 2 executions")
prev := executions[0]
curr := executions[1]
completionGap := curr.startTime.Sub(prev.completeTime)
assert.InDelta(t, scheduledInterval.Seconds(), completionGap.Seconds(), 0.5,
"With intervalFromCompletion: gap should be the full interval")
})
}
func TestWithIntervalFromCompletion_DurationRandomJob(t *testing.T) {
s, err := NewScheduler()
require.NoError(t, err)
defer func() { _ = s.Shutdown() }()
var mu sync.Mutex
executions := []struct {
startTime time.Time
completeTime time.Time
}{}
jobExecutionTime := 1 * time.Second
minInterval := 3 * time.Second
maxInterval := 4 * time.Second
_, err = s.NewJob(
DurationRandomJob(minInterval, maxInterval),
NewTask(func() {
start := time.Now()
time.Sleep(jobExecutionTime)
complete := time.Now()
mu.Lock()
executions = append(executions, struct {
startTime time.Time
completeTime time.Time
}{start, complete})
mu.Unlock()
}),
WithIntervalFromCompletion(),
)
require.NoError(t, err)
s.Start()
time.Sleep(15 * time.Second)
mu.Lock()
defer mu.Unlock()
require.GreaterOrEqual(t, len(executions), 2, "Expected at least 2 executions")
for i := 1; i < len(executions); i++ {
prev := executions[i-1]
curr := executions[i]
restPeriod := curr.startTime.Sub(prev.completeTime)
assert.GreaterOrEqual(t, restPeriod.Seconds(), minInterval.Seconds()-0.5,
"Rest period should be at least minInterval")
assert.LessOrEqual(t, restPeriod.Seconds(), maxInterval.Seconds()+0.5,
"Rest period should be at most maxInterval")
}
}
func TestWithIntervalFromCompletion_FirstRun(t *testing.T) {
s, err := NewScheduler()
require.NoError(t, err)
defer func() { _ = s.Shutdown() }()
var mu sync.Mutex
var firstRunTime time.Time
_, err = s.NewJob(
DurationJob(5*time.Second),
NewTask(func() {
mu.Lock()
if firstRunTime.IsZero() {
firstRunTime = time.Now()
}
mu.Unlock()
}),
WithIntervalFromCompletion(),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
startTime := time.Now()
s.Start()
time.Sleep(1 * time.Second)
mu.Lock()
defer mu.Unlock()
require.False(t, firstRunTime.IsZero(), "Job should have run at least once")
timeSinceStart := firstRunTime.Sub(startTime)
assert.Less(t, timeSinceStart.Seconds(), 1.0,
"First run should happen quickly with WithStartImmediately")
}
func TestJob_NextRun_MultipleJobsSimultaneously(t *testing.T) {
// This test reproduces the bug where multiple jobs completing simultaneously
// would cause NextRun() to return stale values due to race conditions in
// nextScheduled cleanup.
testTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
fakeClock := clockwork.NewFakeClockAt(testTime)
s := newTestScheduler(t,
WithClock(fakeClock),
WithLocation(time.UTC),
)
jobsCompleted := make(chan struct{}, 4)
// Create multiple jobs with different intervals that will complete around the same time
job1, err := s.NewJob(
DurationJob(1*time.Minute),
NewTask(func() {
jobsCompleted <- struct{}{}
}),
WithName("job1"),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
job2, err := s.NewJob(
DurationJob(2*time.Minute),
NewTask(func() {
jobsCompleted <- struct{}{}
}),
WithName("job2"),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
job3, err := s.NewJob(
DurationJob(3*time.Minute),
NewTask(func() {
jobsCompleted <- struct{}{}
}),
WithName("job3"),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
job4, err := s.NewJob(
DurationJob(4*time.Minute),
NewTask(func() {
jobsCompleted <- struct{}{}
}),
WithName("job4"),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
s.Start()
// Wait for all 4 jobs to complete their immediate run
for i := 0; i < 4; i++ {
<-jobsCompleted
}
// Give the scheduler time to process the completions and reschedule
time.Sleep(50 * time.Millisecond)
// Verify that NextRun() returns the correct next scheduled time for each job
// and not a stale value from the just-completed run
nextRun1, err := job1.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(1*time.Minute), nextRun1, "job1 NextRun should be 1 minute from start")
nextRun2, err := job2.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(2*time.Minute), nextRun2, "job2 NextRun should be 2 minutes from start")
nextRun3, err := job3.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(3*time.Minute), nextRun3, "job3 NextRun should be 3 minutes from start")
nextRun4, err := job4.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(4*time.Minute), nextRun4, "job4 NextRun should be 4 minutes from start")
// Advance time to trigger job1's next run
fakeClock.Advance(1 * time.Minute)
// Wait for job1 to complete
<-jobsCompleted
time.Sleep(50 * time.Millisecond)
// After job1's second run, it should be scheduled for +2 minutes from start
nextRun1, err = job1.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(2*time.Minute), nextRun1, "job1 NextRun should be 2 minutes from start after first interval")
require.NoError(t, s.Shutdown())
}
func TestJob_NextRun_ConcurrentCompletions(t *testing.T) {
// This test verifies that when multiple jobs complete at exactly the same time,
// their NextRun() values are correctly updated without race conditions.
testTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
fakeClock := clockwork.NewFakeClockAt(testTime)
s := newTestScheduler(t,
WithClock(fakeClock),
WithLocation(time.UTC), // Set scheduler to use UTC to match our test time
)
var wg sync.WaitGroup
jobCompletionBarrier := make(chan struct{})
// Create jobs that will all complete at the same instant
createJob := func(name string, interval time.Duration) Job {
job, err := s.NewJob(
DurationJob(interval),
NewTask(func() {
wg.Done()
<-jobCompletionBarrier // Wait until all jobs are ready to complete
}),
WithName(name),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
return job
}
wg.Add(4)
job1 := createJob("concurrent-job1", 1*time.Minute)
job2 := createJob("concurrent-job2", 2*time.Minute)
job3 := createJob("concurrent-job3", 3*time.Minute)
job4 := createJob("concurrent-job4", 4*time.Minute)
s.Start()
wg.Wait()
close(jobCompletionBarrier)
// Give the scheduler time to process all completions
time.Sleep(100 * time.Millisecond)
// Verify NextRun() for all jobs concurrently to stress test the race condition
var testWg sync.WaitGroup
testWg.Add(4)
go func() {
defer testWg.Done()
for i := 0; i < 10; i++ {
nextRun, err := job1.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(1*time.Minute), nextRun)
}
}()
go func() {
defer testWg.Done()
for i := 0; i < 10; i++ {
nextRun, err := job2.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(2*time.Minute), nextRun)
}
}()
go func() {
defer testWg.Done()
for i := 0; i < 10; i++ {
nextRun, err := job3.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(3*time.Minute), nextRun)
}
}()
go func() {
defer testWg.Done()
for i := 0; i < 10; i++ {
nextRun, err := job4.NextRun()
require.NoError(t, err)
assert.Equal(t, testTime.Add(4*time.Minute), nextRun)
}
}()
testWg.Wait()
require.NoError(t, s.Shutdown())
}
================================================
FILE: logger.go
================================================
//go:generate mockgen -destination=mocks/logger.go -package=gocronmocks . Logger
package gocron
import (
"fmt"
"log"
"os"
"strings"
)
// Logger is the interface that wraps the basic logging methods
// used by gocron. The methods are modeled after the standard
// library slog package. The default logger is a no-op logger.
// To enable logging, use one of the provided New*Logger functions
// or implement your own Logger. The actual level of Log that is logged
// is handled by the implementation.
type Logger interface {
Debug(msg string, args ...any)
Error(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
}
var _ Logger = (*noOpLogger)(nil)
type noOpLogger struct{}
func (l noOpLogger) Debug(_ string, _ ...any) {}
func (l noOpLogger) Error(_ string, _ ...any) {}
func (l noOpLogger) Info(_ string, _ ...any) {}
func (l noOpLogger) Warn(_ string, _ ...any) {}
var _ Logger = (*logger)(nil)
// LogLevel is the level of logging that should be logged
// when using the basic NewLogger.
type LogLevel int
// The different log levels that can be used.
const (
LogLevelError LogLevel = iota
LogLevelWarn
LogLevelInfo
LogLevelDebug
)
type logger struct {
log *log.Logger
level LogLevel
}
// NewLogger returns a new Logger that logs at the given level.
func NewLogger(level LogLevel) Logger {
l := log.New(os.Stdout, "", log.LstdFlags)
return &logger{
log: l,
level: level,
}
}
func (l *logger) Debug(msg string, args ...any) {
if l.level < LogLevelDebug {
return
}
l.log.Printf("DEBUG: %s%s\n", msg, logFormatArgs(args...))
}
func (l *logger) Error(msg string, args ...any) {
if l.level < LogLevelError {
return
}
l.log.Printf("ERROR: %s%s\n", msg, logFormatArgs(args...))
}
func (l *logger) Info(msg string, args ...any) {
if l.level < LogLevelInfo {
return
}
l.log.Printf("INFO: %s%s\n", msg, logFormatArgs(args...))
}
func (l *logger) Warn(msg string, args ...any) {
if l.level < LogLevelWarn {
return
}
l.log.Printf("WARN: %s%s\n", msg, logFormatArgs(args...))
}
func logFormatArgs(args ...any) string {
if len(args) == 0 {
return ""
}
if len(args)%2 != 0 {
return ", " + fmt.Sprint(args...)
}
var pairs []string
for i := 0; i < len(args); i += 2 {
pairs = append(pairs, fmt.Sprintf("%s=%v", args[i], args[i+1]))
}
return ", " + strings.Join(pairs, ", ")
}
================================================
FILE: logger_test.go
================================================
package gocron
import (
"bytes"
"log"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNoOpLogger(_ *testing.T) {
noOp := noOpLogger{}
noOp.Debug("debug", "arg1", "arg2")
noOp.Error("error", "arg1", "arg2")
noOp.Info("info", "arg1", "arg2")
noOp.Warn("warn", "arg1", "arg2")
}
func TestNewLogger(t *testing.T) {
tests := []struct {
name string
level LogLevel
}{
{
"debug",
LogLevelDebug,
},
{
"info",
LogLevelInfo,
},
{
"warn",
LogLevelWarn,
},
{
"error",
LogLevelError,
},
{
"Less than error",
-1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var results bytes.Buffer
l := &logger{
level: tt.level,
log: log.New(&results, "", log.LstdFlags),
}
var noArgs []any
oneArg := []any{"arg1"}
twoArgs := []any{"arg1", "arg2"}
var noArgsStr []string
oneArgStr := []string{"arg1"}
twoArgsStr := []string{"arg1", "arg2"}
for _, args := range []struct {
argsAny []any
argsStr []string
}{
{noArgs, noArgsStr},
{oneArg, oneArgStr},
{twoArgs, twoArgsStr},
} {
l.Debug("debug", args.argsAny...)
if tt.level >= LogLevelDebug {
r := results.String()
assert.Contains(t, r, "DEBUG: debug")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else {
assert.Empty(t, results.String())
}
results.Reset()
l.Info("info", args.argsAny...)
if tt.level >= LogLevelInfo {
r := results.String()
assert.Contains(t, r, "INFO: info")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else {
assert.Empty(t, results.String())
}
results.Reset()
l.Warn("warn", args.argsAny...)
if tt.level >= LogLevelWarn {
r := results.String()
assert.Contains(t, r, "WARN: warn")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else {
assert.Empty(t, results.String())
}
results.Reset()
l.Error("error", args.argsAny...)
if tt.level >= LogLevelError {
r := results.String()
assert.Contains(t, r, "ERROR: error")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else {
assert.Empty(t, results.String())
}
results.Reset()
}
})
}
}
================================================
FILE: migration_v1_to_v2.md
================================================
# Migration Guide: `gocron` v1 → v2
This guide helps you migrate your code from the `v1` branch to the `v2` branch of [go-co-op/gocron](https://github.com/go-co-op/gocron).
Version 2 is a major rewrite focusing on improving the internals of gocron, while also enhancing the user interfaces and error handling.
All major functionality has been ported over.
---
## Table of Contents
- [Overview of Major Changes](#overview-of-major-changes)
- [Installation](#installation)
- [API Changes](#api-changes)
- [Scheduler Creation](#scheduler-creation)
- [Job Definition](#job-definition)
- [Starting and Stopping the Scheduler](#starting-and-stopping-the-scheduler)
- [Error Handling](#error-handling)
- [Distributed Scheduling](#distributed-scheduling)
- [Examples Migration](#examples-migration)
- [Testing and Validation](#testing-and-validation)
- [Troubleshooting](#troubleshooting)
- [References](#references)
---
## Overview of Major Changes
- **Breaking API changes**: All major interfaces and types have changed.
- **Improved error reporting**: Most functions now return errors.
- **Job IDs and cancellation**: Jobs have unique IDs and can be cancelled.
- **Distributed and monitored scheduling**: Built-in support for distributed schedulers and job monitors.
- **Context and logging enhancements**: Improved support for cancellation, context, and custom logging interfaces.
---
## Installation
Update your dependency to v2:
```sh
go get github.com/go-co-op/gocron/v2
```
**Note:** The import path is `github.com/go-co-op/gocron/v2`.
---
## API Changes
### 1. Scheduler Creation
**v1:**
```go
import "github.com/go-co-op/gocron"
s := gocron.NewScheduler(time.UTC)
```
**v2:**
```go
import "github.com/go-co-op/gocron/v2"
s, err := gocron.NewScheduler()
if err != nil { panic(err) }
```
- **v2** returns an error on creation.
- **v2** does not require a location/timezone argument. Use `WithLocation()` if needed.
---
### 2. Job Creation
**v1:**
```go
s.Every(1).Second().Do(taskFunc)
```
**v2:**
```go
j, err := s.NewJob(
gocron.DurationJob(1*time.Second),
gocron.NewTask(taskFunc),
)
if err != nil { panic(err) }
```
- **v2** uses explicit job types (`DurationJob`, `CronJob`, etc).
- **v2** jobs have unique IDs: `j.ID()`.
- **v2** returns an error on job creation.
#### Cron Expressions
**v1:**
```go
s.Cron("*/5 * * * *").Do(taskFunc)
```
**v2:**
```go
j, err := s.NewJob(
gocron.CronJob("*/5 * * * *"),
gocron.NewTask(taskFunc),
)
```
#### Arguments
**v1:**
```go
s.Every(1).Second().Do(taskFunc, arg1, arg2)
```
**v2:**
```go
j, err := s.NewJob(
gocron.DurationJob(1*time.Second),
gocron.NewTask(taskFunc, arg1, arg2),
)
```
---
### 3. Starting and Stopping the Scheduler
**v1:**
```go
s.StartAsync()
s.Stop()
```
**v2:**
```go
s.Start()
s.Shutdown()
```
- Always call `Shutdown()` for graceful cleanup.
---
### 4. Error Handling
- Most v2 methods return errors. Always check `err`.
- Use `errors.go` for error definitions.
---
## References
- [v2 API Documentation](https://pkg.go.dev/github.com/go-co-op/gocron/v2)
- [Examples](https://pkg.go.dev/github.com/go-co-op/gocron/v2#pkg-examples)
- [Release Notes](https://github.com/go-co-op/gocron/releases)
---
**If you encounter issues, open a GitHub Issue or consider contributing a fix by checking out the [CONTRIBUTING.md](CONTRIBUTING.md) guide.**
================================================
FILE: mocks/README.md
================================================
# gocron mocks
## Quick Start
```
go get github.com/go-co-op/gocron/mocks/v2
```
write a test
```golang
package main
import (
"testing"
"github.com/go-co-op/gocron/mocks/v2"
"github.com/go-co-op/gocron/v2"
"go.uber.org/mock/gomock"
)
func myFunc(s gocron.Scheduler) {
s.Start()
_ = s.Shutdown()
}
func TestMyFunc(t *testing.T) {
ctrl := gomock.NewController(t)
s := gocronmocks.NewMockScheduler(ctrl)
s.EXPECT().Start().Times(1)
s.EXPECT().Shutdown().Times(1).Return(nil)
myFunc(s)
}
```
================================================
FILE: mocks/distributed.go
================================================
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/go-co-op/gocron/v2 (interfaces: Elector,Locker,Lock)
//
// Generated by this command:
//
// mockgen -destination=mocks/distributed.go -package=gocronmocks . Elector,Locker,Lock
//
// Package gocronmocks is a generated GoMock package.
package gocronmocks
import (
context "context"
reflect "reflect"
v2 "github.com/go-co-op/gocron/v2"
gomock "go.uber.org/mock/gomock"
)
// MockElector is a mock of Elector interface.
type MockElector struct {
ctrl *gomock.Controller
recorder *MockElectorMockRecorder
isgomock struct{}
}
// MockElectorMockRecorder is the mock recorder for MockElector.
type MockElectorMockRecorder struct {
mock *MockElector
}
// NewMockElector creates a new mock instance.
func NewMockElector(ctrl *gomock.Controller) *MockElector {
mock := &MockElector{ctrl: ctrl}
mock.recorder = &MockElectorMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockElector) EXPECT() *MockElectorMockRecorder {
return m.recorder
}
// IsLeader mocks base method.
func (m *MockElector) IsLeader(arg0 context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsLeader", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// IsLeader indicates an expected call of IsLeader.
func (mr *MockElectorMockRecorder) IsLeader(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsLeader", reflect.TypeOf((*MockElector)(nil).IsLeader), arg0)
}
// MockLocker is a mock of Locker interface.
type MockLocker struct {
ctrl *gomock.Controller
recorder *MockLockerMockRecorder
isgomock struct{}
}
// MockLockerMockRecorder is the mock recorder for MockLocker.
type MockLockerMockRecorder struct {
mock *MockLocker
}
// NewMockLocker creates a new mock instance.
func NewMockLocker(ctrl *gomock.Controller) *MockLocker {
mock := &MockLocker{ctrl: ctrl}
mock.recorder = &MockLockerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLocker) EXPECT() *MockLockerMockRecorder {
return m.recorder
}
// Lock mocks base method.
func (m *MockLocker) Lock(ctx context.Context, key string) (v2.Lock, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Lock", ctx, key)
ret0, _ := ret[0].(v2.Lock)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Lock indicates an expected call of Lock.
func (mr *MockLockerMockRecorder) Lock(ctx, key any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lock", reflect.TypeOf((*MockLocker)(nil).Lock), ctx, key)
}
// MockLock is a mock of Lock interface.
type MockLock struct {
ctrl *gomock.Controller
recorder *MockLockMockRecorder
isgomock struct{}
}
// MockLockMockRecorder is the mock recorder for MockLock.
type MockLockMockRecorder struct {
mock *MockLock
}
// NewMockLock creates a new mock instance.
func NewMockLock(ctrl *gomock.Controller) *MockLock {
mock := &MockLock{ctrl: ctrl}
mock.recorder = &MockLockMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLock) EXPECT() *MockLockMockRecorder {
return m.recorder
}
// Unlock mocks base method.
func (m *MockLock) Unlock(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Unlock", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// Unlock indicates an expected call of Unlock.
func (mr *MockLockMockRecorder) Unlock(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockLock)(nil).Unlock), ctx)
}
================================================
FILE: mocks/go.mod
================================================
module github.com/go-co-op/gocron/mocks/v2
go 1.20
require (
github.com/go-co-op/gocron/v2 v2.2.10
github.com/google/uuid v1.6.0
go.uber.org/mock v0.4.0
)
require (
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
)
================================================
FILE: mocks/go.sum
================================================
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-co-op/gocron/v2 v2.2.10 h1:o6u+RfvT5rBa39gmsA5cqPPLXTa+Ai70m7EGgHQoXyg=
github.com/go-co-op/gocron/v2 v2.2.10/go.mod h1:mZx3gMSlFnb97k3hRqX3+GdlG3+DUwTh6B8fnsTScXg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
================================================
FILE: mocks/job.go
================================================
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/go-co-op/gocron/v2 (interfaces: Job)
//
// Generated by this command:
//
// mockgen -destination=mocks/job.go -package=gocronmocks . Job
//
// Package gocronmocks is a generated GoMock package.
package gocronmocks
import (
reflect "reflect"
time "time"
uuid "github.com/google/uuid"
gomock "go.uber.org/mock/gomock"
)
// MockJob is a mock of Job interface.
type MockJob struct {
ctrl *gomock.Controller
recorder *MockJobMockRecorder
isgomock struct{}
}
// MockJobMockRecorder is the mock recorder for MockJob.
type MockJobMockRecorder struct {
mock *MockJob
}
// NewMockJob creates a new mock instance.
func NewMockJob(ctrl *gomock.Controller) *MockJob {
mock := &MockJob{ctrl: ctrl}
mock.recorder = &MockJobMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockJob) EXPECT() *MockJobMockRecorder {
return m.recorder
}
// ID mocks base method.
func (m *MockJob) ID() uuid.UUID {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ID")
ret0, _ := ret[0].(uuid.UUID)
return ret0
}
// ID indicates an expected call of ID.
func (mr *MockJobMockRecorder) ID() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockJob)(nil).ID))
}
// LastRun mocks base method.
func (m *MockJob) LastRun() (time.Time, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LastRun")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LastRun indicates an expected call of LastRun.
func (mr *MockJobMockRecorder) LastRun() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LastRun", reflect.TypeOf((*MockJob)(nil).LastRun))
}
// Name mocks base method.
func (m *MockJob) Name() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Name")
ret0, _ := ret[0].(string)
return ret0
}
// Name indicates an expected call of Name.
func (mr *MockJobMockRecorder) Name() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockJob)(nil).Name))
}
// NextRun mocks base method.
func (m *MockJob) NextRun() (time.Time, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NextRun")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NextRun indicates an expected call of NextRun.
func (mr *MockJobMockRecorder) NextRun() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextRun", reflect.TypeOf((*MockJob)(nil).NextRun))
}
// NextRuns mocks base method.
func (m *MockJob) NextRuns(arg0 int) ([]time.Time, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NextRuns", arg0)
ret0, _ := ret[0].([]time.Time)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NextRuns indicates an expected call of NextRuns.
func (mr *MockJobMockRecorder) NextRuns(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextRuns", reflect.TypeOf((*MockJob)(nil).NextRuns), arg0)
}
// RunNow mocks base method.
func (m *MockJob) RunNow() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RunNow")
ret0, _ := ret[0].(error)
return ret0
}
// RunNow indicates an expected call of RunNow.
func (mr *MockJobMockRecorder) RunNow() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunNow", reflect.TypeOf((*MockJob)(nil).RunNow))
}
// Tags mocks base method.
func (m *MockJob) Tags() []string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Tags")
ret0, _ := ret[0].([]string)
return ret0
}
// Tags indicates an expected call of Tags.
func (mr *MockJobMockRecorder) Tags() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Tags", reflect.TypeOf((*MockJob)(nil).Tags))
}
================================================
FILE: mocks/logger.go
================================================
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/go-co-op/gocron/v2 (interfaces: Logger)
//
// Generated by this command:
//
// mockgen -destination=mocks/logger.go -package=gocronmocks . Logger
//
// Package gocronmocks is a generated GoMock package.
package gocronmocks
import (
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockLogger is a mock of Logger interface.
type MockLogger struct {
ctrl *gomock.Controller
recorder *MockLoggerMockRecorder
isgomock struct{}
}
// MockLoggerMockRecorder is the mock recorder for MockLogger.
type MockLoggerMockRecorder struct {
mock *MockLogger
}
// NewMockLogger creates a new mock instance.
func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
mock := &MockLogger{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
return m.recorder
}
// Debug mocks base method.
func (m *MockLogger) Debug(msg string, args ...any) {
m.ctrl.T.Helper()
varargs := []any{msg}
for _, a := range args {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Debug", varargs...)
}
// Debug indicates an expected call of Debug.
func (mr *MockLoggerMockRecorder) Debug(msg any, args ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{msg}, args...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debug", reflect.TypeOf((*MockLogger)(nil).Debug), varargs...)
}
// Error mocks base method.
func (m *MockLogger) Error(msg string, args ...any) {
m.ctrl.T.Helper()
varargs := []any{msg}
for _, a := range args {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Error", varargs...)
}
// Error indicates an expected call of Error.
func (mr *MockLoggerMockRecorder) Error(msg any, args ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{msg}, args...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), varargs...)
}
// Info mocks base method.
func (m *MockLogger) Info(msg string, args ...any) {
m.ctrl.T.Helper()
varargs := []any{msg}
for _, a := range args {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Info", varargs...)
}
// Info indicates an expected call of Info.
func (mr *MockLoggerMockRecorder) Info(msg any, args ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{msg}, args...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), varargs...)
}
// Warn mocks base method.
func (m *MockLogger) Warn(msg string, args ...any) {
m.ctrl.T.Helper()
varargs := []any{msg}
for _, a := range args {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Warn", varargs...)
}
// Warn indicates an expected call of Warn.
func (mr *MockLoggerMockRecorder) Warn(msg any, args ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{msg}, args...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), varargs...)
}
================================================
FILE: mocks/scheduler.go
================================================
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/go-co-op/gocron/v2 (interfaces: Scheduler)
//
// Generated by this command:
//
// mockgen -destination=mocks/scheduler.go -package=gocronmocks . Scheduler
//
// Package gocronmocks is a generated GoMock package.
package gocronmocks
import (
reflect "reflect"
v2 "github.com/go-co-op/gocron/v2"
uuid "github.com/google/uuid"
gomock "go.uber.org/mock/gomock"
)
// MockScheduler is a mock of Scheduler interface.
type MockScheduler struct {
ctrl *gomock.Controller
recorder *MockSchedulerMockRecorder
isgomock struct{}
}
// MockSchedulerMockRecorder is the mock recorder for MockScheduler.
type MockSchedulerMockRecorder struct {
mock *MockScheduler
}
// NewMockScheduler creates a new mock instance.
func NewMockScheduler(ctrl *gomock.Controller) *MockScheduler {
mock := &MockScheduler{ctrl: ctrl}
mock.recorder = &MockSchedulerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockScheduler) EXPECT() *MockSchedulerMockRecorder {
return m.recorder
}
// Jobs mocks base method.
func (m *MockScheduler) Jobs() []v2.Job {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Jobs")
ret0, _ := ret[0].([]v2.Job)
return ret0
}
// Jobs indicates an expected call of Jobs.
func (mr *MockSchedulerMockRecorder) Jobs() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Jobs", reflect.TypeOf((*MockScheduler)(nil).Jobs))
}
// JobsWaitingInQueue mocks base method.
func (m *MockScheduler) JobsWaitingInQueue() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "JobsWaitingInQueue")
ret0, _ := ret[0].(int)
return ret0
}
// JobsWaitingInQueue indicates an expected call of JobsWaitingInQueue.
func (mr *MockSchedulerMockRecorder) JobsWaitingInQueue() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "JobsWaitingInQueue", reflect.TypeOf((*MockScheduler)(nil).JobsWaitingInQueue))
}
// NewJob mocks base method.
func (m *MockScheduler) NewJob(arg0 v2.JobDefinition, arg1 v2.Task, arg2 ...v2.JobOption) (v2.Job, error) {
m.ctrl.T.Helper()
varargs := []any{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "NewJob", varargs...)
ret0, _ := ret[0].(v2.Job)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NewJob indicates an expected call of NewJob.
func (mr *MockSchedulerMockRecorder) NewJob(arg0, arg1 any, arg2 ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewJob", reflect.TypeOf((*MockScheduler)(nil).NewJob), varargs...)
}
// RemoveByTags mocks base method.
func (m *MockScheduler) RemoveByTags(arg0 ...string) {
m.ctrl.T.Helper()
varargs := []any{}
for _, a := range arg0 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "RemoveByTags", varargs...)
}
// RemoveByTags indicates an expected call of RemoveByTags.
func (mr *MockSchedulerMockRecorder) RemoveByTags(arg0 ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveByTags", reflect.TypeOf((*MockScheduler)(nil).RemoveByTags), arg0...)
}
// RemoveJob mocks base method.
func (m *MockScheduler) RemoveJob(arg0 uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveJob", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveJob indicates an expected call of RemoveJob.
func (mr *MockSchedulerMockRecorder) RemoveJob(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveJob", reflect.TypeOf((*MockScheduler)(nil).RemoveJob), arg0)
}
// Shutdown mocks base method.
func (m *MockScheduler) Shutdown() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Shutdown")
ret0, _ := ret[0].(error)
return ret0
}
// Shutdown indicates an expected call of Shutdown.
func (mr *MockSchedulerMockRecorder) Shutdown() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockScheduler)(nil).Shutdown))
}
// Start mocks base method.
func (m *MockScheduler) Start() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Start")
}
// Start indicates an expected call of Start.
func (mr *MockSchedulerMockRecorder) Start() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockScheduler)(nil).Start))
}
// StopJobs mocks base method.
func (m *MockScheduler) StopJobs() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "StopJobs")
ret0, _ := ret[0].(error)
return ret0
}
// StopJobs indicates an expected call of StopJobs.
func (mr *MockSchedulerMockRecorder) StopJobs() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopJobs", reflect.TypeOf((*MockScheduler)(nil).StopJobs))
}
// Update mocks base method.
func (m *MockScheduler) Update(arg0 uuid.UUID, arg1 v2.JobDefinition, arg2 v2.Task, arg3 ...v2.JobOption) (v2.Job, error) {
m.ctrl.T.Helper()
varargs := []any{arg0, arg1, arg2}
for _, a := range arg3 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Update", varargs...)
ret0, _ := ret[0].(v2.Job)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Update indicates an expected call of Update.
func (mr *MockSchedulerMockRecorder) Update(arg0, arg1, arg2 any, arg3 ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{arg0, arg1, arg2}, arg3...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockScheduler)(nil).Update), varargs...)
}
================================================
FILE: monitor.go
================================================
package gocron
import (
"time"
"github.com/google/uuid"
)
// JobStatus is the status of job run that should be collected with the metric.
type JobStatus string
// The different statuses of job that can be used.
const (
Fail JobStatus = "fail"
Success JobStatus = "success"
Skip JobStatus = "skip"
SingletonRescheduled JobStatus = "singleton_rescheduled"
)
// Monitor represents the interface to collect jobs metrics.
type Monitor interface {
// IncrementJob will provide details about the job and expects the underlying implementation
// to handle instantiating and incrementing a value
IncrementJob(id uuid.UUID, name string, tags []string, status JobStatus)
// RecordJobTiming will provide details about the job and the timing and expects the underlying implementation
// to handle instantiating and recording the value
RecordJobTiming(startTime, endTime time.Time, id uuid.UUID, name string, tags []string)
}
// MonitorStatus extends RecordJobTiming with the job status.
type MonitorStatus interface {
Monitor
// RecordJobTimingWithStatus will provide details about the job, its status, error and the timing and expects the underlying implementation
// to handle instantiating and recording the value
RecordJobTimingWithStatus(startTime, endTime time.Time, id uuid.UUID, name string, tags []string, status JobStatus, err error)
}
================================================
FILE: scheduler.go
================================================
//go:generate mockgen -destination=mocks/scheduler.go -package=gocronmocks . Scheduler
package gocron
import (
"context"
"reflect"
"runtime"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/jonboulle/clockwork"
)
var _ Scheduler = (*scheduler)(nil)
// Scheduler defines the interface for the Scheduler.
type Scheduler interface {
// Jobs returns all the jobs currently in the scheduler.
Jobs() []Job
// NewJob creates a new job in the Scheduler. The job is scheduled per the provided
// definition when the Scheduler is started. If the Scheduler is already running
// the job will be scheduled when the Scheduler is started.
// If you set the first argument of your Task func to be a context.Context,
// gocron will pass in a context (either the default Job context, or one
// provided via WithContext) to the job and will cancel the context on shutdown.
// This allows you to listen for and handle cancellation within your job.
NewJob(JobDefinition, Task, ...JobOption) (Job, error)
// RemoveByTags removes all jobs that have at least one of the provided tags.
RemoveByTags(...string)
// RemoveJob removes the job with the provided id.
RemoveJob(uuid.UUID) error
// Shutdown should be called when you no longer need
// the Scheduler or Job's as the Scheduler cannot
// be restarted after calling Shutdown. This is similar
// to a Close or Cleanup method and is often deferred after
// starting the scheduler.
Shutdown() error
// Start begins scheduling jobs for execution based
// on each job's definition. Job's added to an already
// running scheduler will be scheduled immediately based
// on definition. Start is non-blocking.
Start()
// StopJobs stops the execution of all jobs in the scheduler.
// This can be useful in situations where jobs need to be
// paused globally and then restarted with Start().
StopJobs() error
// Update replaces the existing Job's JobDefinition with the provided
// JobDefinition. The Job's Job.ID() remains the same.
Update(uuid.UUID, JobDefinition, Task, ...JobOption) (Job, error)
// JobsWaitingInQueue number of jobs waiting in Queue in case of LimitModeWait
// In case of LimitModeReschedule or no limit it will be always zero
JobsWaitingInQueue() int
}
// -----------------------------------------------
// -----------------------------------------------
// ----------------- Scheduler -------------------
// -----------------------------------------------
// -----------------------------------------------
type scheduler struct {
// context used for shutting down
shutdownCtx context.Context
// cancel used to signal scheduler should shut down
shutdownCancel context.CancelFunc
// the executor, which actually runs the jobs sent to it via the scheduler
exec executor
// the map of jobs registered in the scheduler
jobs map[uuid.UUID]internalJob
// the location used by the scheduler for scheduling when relevant
location *time.Location
// whether the scheduler has been started or not
started atomic.Bool
// globally applied JobOption's set on all jobs added to the scheduler
// note: individually set JobOption's take precedence.
globalJobOptions []JobOption
// the scheduler's logger
logger Logger
// used to tell the scheduler to start
startCh chan struct{}
// used to report that the scheduler has started
startedCh chan struct{}
// used to tell the scheduler to stop
stopCh chan struct{}
// used to report that the scheduler has stopped
stopErrCh chan error
// used to send all the jobs out when a request is made by the client
allJobsOutRequest chan allJobsOutRequest
// used to send a jobs out when a request is made by the client
jobOutRequestCh chan *jobOutRequest
// used to run a job on-demand when requested by the client
runJobRequestCh chan runJobRequest
// new jobs are received here
newJobCh chan newJobIn
// requests from the client to remove jobs by ID are received here
removeJobCh chan uuid.UUID
// requests from the client to remove jobs by tags are received here
removeJobsByTagsCh chan []string
// scheduler monitor from which metrics can be collected
schedulerMonitor SchedulerMonitor
}
type newJobIn struct {
ctx context.Context
cancel context.CancelFunc
job internalJob
}
type jobOutRequest struct {
id uuid.UUID
outChan chan internalJob
}
type runJobRequest struct {
id uuid.UUID
outChan chan error
}
type allJobsOutRequest struct {
outChan chan []Job
}
// NewScheduler creates a new Scheduler instance.
// The Scheduler is not started until Start() is called.
//
// NewJob will add jobs to the Scheduler, but they will not
// be scheduled until Start() is called.
func NewScheduler(options ...SchedulerOption) (Scheduler, error) {
schCtx, cancel := context.WithCancel(context.Background())
exec := executor{
stopCh: make(chan struct{}),
stopTimeout: time.Second * 10,
singletonRunners: nil,
logger: &noOpLogger{},
clock: clockwork.NewRealClock(),
jobsIn: make(chan jobIn),
jobsOutForRescheduling: make(chan uuid.UUID),
jobUpdateNextRuns: make(chan uuid.UUID),
jobsOutCompleted: make(chan uuid.UUID),
jobOutRequest: make(chan *jobOutRequest, 100),
done: make(chan error, 1),
}
s := &scheduler{
shutdownCtx: schCtx,
shutdownCancel: cancel,
jobs: make(map[uuid.UUID]internalJob),
location: time.Local,
logger: &noOpLogger{},
newJobCh: make(chan newJobIn),
removeJobCh: make(chan uuid.UUID),
removeJobsByTagsCh: make(chan []string),
startCh: make(chan struct{}),
startedCh: make(chan struct{}),
stopCh: make(chan struct{}),
stopErrCh: make(chan error, 1),
jobOutRequestCh: make(chan *jobOutRequest),
runJobRequestCh: make(chan runJobRequest),
allJobsOutRequest: make(chan allJobsOutRequest),
}
exec.scheduler = s
s.exec = exec
for _, option := range options {
err := option(s)
if err != nil {
return nil, err
}
}
go func() {
s.logger.Info("gocron: new scheduler created")
for {
select {
case id := <-s.exec.jobsOutForRescheduling:
s.selectExecJobsOutForRescheduling(id)
case id := <-s.exec.jobUpdateNextRuns:
s.updateNextScheduled(id)
case id := <-s.exec.jobsOutCompleted:
s.selectExecJobsOutCompleted(id)
case in := <-s.newJobCh:
s.selectNewJob(in)
case id := <-s.removeJobCh:
s.selectRemoveJob(id)
case tags := <-s.removeJobsByTagsCh:
s.selectRemoveJobsByTags(tags)
case out := <-s.exec.jobOutRequest:
s.selectJobOutRequest(out)
case out := <-s.jobOutRequestCh:
s.selectJobOutRequest(out)
case out := <-s.allJobsOutRequest:
s.selectAllJobsOutRequest(out)
case run := <-s.runJobRequestCh:
s.selectRunJobRequest(run)
case <-s.startCh:
s.selectStart()
case <-s.stopCh:
s.stopScheduler()
case <-s.shutdownCtx.Done():
s.stopScheduler()
return
}
}
}()
return s, nil
}
// -----------------------------------------------
// -----------------------------------------------
// --------- Scheduler Channel Methods -----------
// -----------------------------------------------
// -----------------------------------------------
// The scheduler's channel functions are broken out here
// to allow prioritizing within the select blocks. The idea
// being that we want to make sure that scheduling tasks
// are not blocked by requests from the caller for information
// about jobs.
func (s *scheduler) stopScheduler() {
s.logger.Debug("gocron: stopping scheduler")
if s.started.Load() {
s.exec.stopCh <- struct{}{}
}
for _, j := range s.jobs {
j.stop()
}
for _, j := range s.jobs {
<-j.ctx.Done()
}
var err error
if s.started.Load() {
t := time.NewTimer(s.exec.stopTimeout + 1*time.Second)
select {
case err = <-s.exec.done:
t.Stop()
case <-t.C:
err = ErrStopExecutorTimedOut
}
}
for id, j := range s.jobs {
oldCtx := j.ctx
if j.parentCtx == nil {
j.parentCtx = s.shutdownCtx
}
j.ctx, j.cancel = context.WithCancel(j.parentCtx)
// also replace the old context with the new one in the parameters
if len(j.parameters) > 0 && j.parameters[0] == oldCtx {
j.parameters[0] = j.ctx
}
s.jobs[id] = j
}
s.stopErrCh <- err
s.started.Store(false)
s.logger.Debug("gocron: scheduler stopped")
// Notify monitor that scheduler has stopped
s.notifySchedulerStopped()
}
func (s *scheduler) selectAllJobsOutRequest(out allJobsOutRequest) {
outJobs := make([]Job, len(s.jobs))
var counter int
for _, j := range s.jobs {
outJobs[counter] = s.jobFromInternalJob(j)
counter++
}
slices.SortFunc(outJobs, func(a, b Job) int {
aID, bID := a.ID().String(), b.ID().String()
return strings.Compare(aID, bID)
})
select {
case <-s.shutdownCtx.Done():
case out.outChan <- outJobs:
}
}
func (s *scheduler) selectRunJobRequest(run runJobRequest) {
j, ok := s.jobs[run.id]
if !ok {
select {
case run.outChan <- ErrJobNotFound:
default:
}
return
}
select {
case <-s.shutdownCtx.Done():
select {
case run.outChan <- ErrJobRunNowFailed:
default:
}
case s.exec.jobsIn <- jobIn{
id: j.id,
shouldSendOut: false,
}:
select {
case run.outChan <- nil:
default:
}
}
}
func (s *scheduler) selectRemoveJob(id uuid.UUID) {
j, ok := s.jobs[id]
if !ok {
return
}
if s.schedulerMonitor != nil {
out := s.jobFromInternalJob(j)
s.notifyJobUnregistered(out)
}
j.stop()
delete(s.jobs, id)
}
// Jobs coming back from the executor to the scheduler that
// need to be evaluated for rescheduling.
func (s *scheduler) selectExecJobsOutForRescheduling(id uuid.UUID) {
select {
case <-s.shutdownCtx.Done():
return
default:
}
j, ok := s.jobs[id]
if !ok {
// the job was removed while it was running, and
// so we don't need to reschedule it.
return
}
if j.stopTimeReached(s.now()) {
return
}
var scheduleFrom time.Time
// If intervalFromCompletion is enabled, calculate the next run time
// from when the job completed (lastRun) rather than when it was scheduled.
if j.intervalFromCompletion {
// Use the completion time (lastRun is set when the job completes)
scheduleFrom = j.lastRun
if scheduleFrom.IsZero() {
// For the first run, use the start time or current time
scheduleFrom = j.startTime
if scheduleFrom.IsZero() {
scheduleFrom = s.now()
}
}
} else {
// Default behavior: use the scheduled time
if len(j.nextScheduled) > 0 {
// always grab the last element in the slice as that is the furthest
// out in the future and the time from which we want to calculate
// the subsequent next run time.
slices.SortStableFunc(j.nextScheduled, ascendingTime)
scheduleFrom = j.nextScheduled[len(j.nextScheduled)-1]
}
if scheduleFrom.IsZero() {
scheduleFrom = j.startTime
}
}
next := j.next(scheduleFrom)
if next.IsZero() {
// the job's next function will return zero for OneTime jobs.
// since they are one time only, they do not need rescheduling.
return
}
if next.Before(s.now()) {
// in some cases the next run time can be in the past, for example:
// - the time on the machine was incorrect and has been synced with ntp
// - the machine went to sleep, and woke up some time later
// in those cases, we want to increment to the next run in the future
// and schedule the job for that time.
for next.Before(s.now()) {
next = j.next(next)
}
}
if slices.Contains(j.nextScheduled, next) {
// if the next value is a duplicate of what's already in the nextScheduled slice, for example:
// - the job is being rescheduled off the same next run value as before
// increment to the next, next value
for slices.Contains(j.nextScheduled, next) {
next = j.next(next)
}
}
// Clean up any existing timer to prevent leaks
if j.timer != nil {
j.timer.Stop()
j.timer = nil // Ensure timer is cleared for GC
}
j.nextScheduled = append(j.nextScheduled, next)
j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() {
// set the actual timer on the job here and listen for
// shut down events so that the job doesn't attempt to
// run if the scheduler has been shutdown.
select {
case <-s.shutdownCtx.Done():
return
case s.exec.jobsIn <- jobIn{
id: j.id,
shouldSendOut: true,
}:
}
})
// update the job with its new next and last run times and timer.
s.jobs[id] = j
}
func (s *scheduler) updateNextScheduled(id uuid.UUID) {
j, ok := s.jobs[id]
if !ok {
return
}
var newNextScheduled []time.Time
now := s.now()
for _, t := range j.nextScheduled {
if t.After(now) { // Changed to match selectExecJobsOutCompleted
newNextScheduled = append(newNextScheduled, t)
}
}
j.nextScheduled = newNextScheduled
s.jobs[id] = j
}
func (s *scheduler) selectExecJobsOutCompleted(id uuid.UUID) {
j, ok := s.jobs[id]
if !ok {
return
}
// if the job has nextScheduled time in the past,
// we need to remove any that are in the past or at the current time (just executed).
var newNextScheduled []time.Time
now := s.now()
for _, t := range j.nextScheduled {
if t.After(now) {
newNextScheduled = append(newNextScheduled, t)
}
}
j.nextScheduled = newNextScheduled
// if the job has a limited number of runs set, we need to
// check how many runs have occurred and stop running this
// job if it has reached the limit.
if j.limitRunsTo != nil {
j.limitRunsTo.runCount = j.limitRunsTo.runCount + 1
if j.limitRunsTo.runCount == j.limitRunsTo.limit {
go func() {
select {
case <-s.shutdownCtx.Done():
return
case s.removeJobCh <- id:
}
}()
return
}
}
j.lastRun = s.now()
s.jobs[id] = j
}
func (s *scheduler) selectJobOutRequest(out *jobOutRequest) {
if j, ok := s.jobs[out.id]; ok {
select {
case out.outChan <- j:
case <-s.shutdownCtx.Done():
}
}
close(out.outChan)
}
func (s *scheduler) selectNewJob(in newJobIn) {
j := in.job
if s.started.Load() {
next := j.startTime
if j.startImmediately {
next = s.now()
select {
case <-s.shutdownCtx.Done():
case s.exec.jobsIn <- jobIn{
id: j.id,
shouldSendOut: true,
}:
}
} else {
if next.IsZero() {
next = j.next(s.now())
}
if next.Before(s.now()) {
for next.Before(s.now()) {
next = j.next(next)
}
}
id := j.id
j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() {
select {
case <-s.shutdownCtx.Done():
case s.exec.jobsIn <- jobIn{
id: id,
shouldSendOut: true,
}:
}
})
}
j.startTime = next
j.nextScheduled = append(j.nextScheduled, next)
}
s.jobs[j.id] = j
in.cancel()
}
func (s *scheduler) selectRemoveJobsByTags(tags []string) {
for _, j := range s.jobs {
for _, tag := range tags {
if slices.Contains(j.tags, tag) {
if s.schedulerMonitor != nil {
out := s.jobFromInternalJob(j)
s.notifyJobUnregistered(out)
}
j.stop()
delete(s.jobs, j.id)
break
}
}
}
}
func (s *scheduler) selectStart() {
s.logger.Debug("gocron: scheduler starting")
go s.exec.start()
s.started.Store(true)
for id, j := range s.jobs {
next := j.startTime
if j.startImmediately {
next = s.now()
select {
case <-s.shutdownCtx.Done():
case s.exec.jobsIn <- jobIn{
id: id,
shouldSendOut: true,
}:
}
} else {
if next.IsZero() {
next = j.next(s.now())
}
if next.Before(s.now()) {
for next.Before(s.now()) {
next = j.next(next)
}
}
jobID := id
j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() {
select {
case <-s.shutdownCtx.Done():
case s.exec.jobsIn <- jobIn{
id: jobID,
shouldSendOut: true,
}:
}
})
}
j.startTime = next
j.nextScheduled = append(j.nextScheduled, next)
s.jobs[id] = j
}
select {
case <-s.shutdownCtx.Done():
case s.startedCh <- struct{}{}:
s.logger.Info("gocron: scheduler started")
}
}
// -----------------------------------------------
// -----------------------------------------------
// ------------- Scheduler Methods ---------------
// -----------------------------------------------
// -----------------------------------------------
func (s *scheduler) now() time.Time {
return s.exec.clock.Now().In(s.location)
}
func (s *scheduler) jobFromInternalJob(in internalJob) job {
return job{
in.id,
in.name,
slices.Clone(in.tags),
s.jobOutRequestCh,
s.runJobRequestCh,
}
}
func (s *scheduler) Jobs() []Job {
outChan := make(chan []Job)
select {
case <-s.shutdownCtx.Done():
case s.allJobsOutRequest <- allJobsOutRequest{outChan: outChan}:
}
var jobs []Job
select {
case <-s.shutdownCtx.Done():
case jobs = <-outChan:
}
return jobs
}
func (s *scheduler) NewJob(jobDefinition JobDefinition, task Task, options ...JobOption) (Job, error) {
return s.addOrUpdateJob(uuid.Nil, jobDefinition, task, options)
}
func (s *scheduler) verifyInterfaceVariadic(taskFunc reflect.Value, tsk task, variadicStart int) error {
ifaceType := taskFunc.Type().In(variadicStart).Elem()
for i := variadicStart; i < len(tsk.parameters); i++ {
if !reflect.TypeOf(tsk.parameters[i]).Implements(ifaceType) {
return ErrNewJobWrongTypeOfParameters
}
}
return nil
}
func (s *scheduler) verifyVariadic(taskFunc reflect.Value, tsk task, variadicStart int) error {
if err := s.verifyNonVariadic(taskFunc, tsk, variadicStart); err != nil {
return err
}
parameterType := taskFunc.Type().In(variadicStart)
parameterTypeKind := parameterType.Elem().Kind()
if parameterTypeKind == reflect.Interface {
return s.verifyInterfaceVariadic(taskFunc, tsk, variadicStart)
}
if parameterTypeKind == reflect.Pointer {
parameterTypeKind = reflect.Indirect(reflect.ValueOf(parameterType)).Kind()
}
for i := variadicStart; i < len(tsk.parameters); i++ {
argumentType := reflect.TypeOf(tsk.parameters[i])
argumentTypeKind := argumentType.Kind()
if argumentTypeKind == reflect.Interface || argumentTypeKind == reflect.Pointer {
argumentTypeKind = argumentType.Elem().Kind()
}
if argumentTypeKind != parameterTypeKind {
return ErrNewJobWrongTypeOfParameters
}
}
return nil
}
func (s *scheduler) verifyNonVariadic(taskFunc reflect.Value, tsk task, length int) error {
for i := 0; i < length; i++ {
argumentType := reflect.TypeOf(tsk.parameters[i])
t1 := argumentType.Kind()
if t1 == reflect.Interface || t1 == reflect.Pointer {
t1 = argumentType.Elem().Kind()
}
parameterType := taskFunc.Type().In(i)
t2 := reflect.New(parameterType).Elem().Kind()
if t2 == reflect.Interface || t2 == reflect.Pointer {
t2 = reflect.Indirect(reflect.ValueOf(parameterType)).Kind()
}
if t1 != t2 {
return ErrNewJobWrongTypeOfParameters
}
}
return nil
}
func (s *scheduler) verifyParameterType(taskFunc reflect.Value, tsk task) error {
taskFuncType := taskFunc.Type()
isVariadic := taskFuncType.IsVariadic()
if isVariadic {
variadicStart := taskFuncType.NumIn() - 1
return s.verifyVariadic(taskFunc, tsk, variadicStart)
}
expectedParameterLength := taskFuncType.NumIn()
if len(tsk.parameters) != expectedParameterLength {
return ErrNewJobWrongNumberOfParameters
}
return s.verifyNonVariadic(taskFunc, tsk, expectedParameterLength)
}
var contextType = reflect.TypeOf((*context.Context)(nil)).Elem()
func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition, taskWrapper Task, options []JobOption) (Job, error) {
j := internalJob{}
if id == uuid.Nil {
j.id = uuid.New()
} else {
currentJob := requestJobCtx(s.shutdownCtx, id, s.jobOutRequestCh)
if currentJob != nil && currentJob.id != uuid.Nil {
select {
case <-s.shutdownCtx.Done():
return nil, nil
case s.removeJobCh <- id:
<-currentJob.ctx.Done()
}
}
j.id = id
}
if taskWrapper == nil {
return nil, ErrNewJobTaskNil
}
tsk := taskWrapper()
taskFunc := reflect.ValueOf(tsk.function)
for taskFunc.Kind() == reflect.Ptr {
taskFunc = taskFunc.Elem()
}
if taskFunc.Kind() != reflect.Func {
return nil, ErrNewJobTaskNotFunc
}
j.name = runtime.FuncForPC(taskFunc.Pointer()).Name()
j.function = tsk.function
j.parameters = tsk.parameters
// apply global job options
for _, option := range s.globalJobOptions {
if err := option(&j, s.now()); err != nil {
return nil, err
}
}
// apply job specific options, which take precedence
for _, option := range options {
if err := option(&j, s.now()); err != nil {
return nil, err
}
}
if j.parentCtx == nil {
j.parentCtx = s.shutdownCtx
}
j.ctx, j.cancel = context.WithCancel(j.parentCtx)
if !taskFunc.IsZero() && taskFunc.Type().NumIn() > 0 {
// if the first parameter is a context.Context and params have no context.Context, add current ctx to the params
if taskFunc.Type().In(0) == contextType {
if len(tsk.parameters) == 0 {
tsk.parameters = []any{j.ctx}
j.parameters = []any{j.ctx}
} else if _, ok := tsk.parameters[0].(context.Context); !ok {
tsk.parameters = append([]any{j.ctx}, tsk.parameters...)
j.parameters = append([]any{j.ctx}, j.parameters...)
}
}
}
if err := s.verifyParameterType(taskFunc, tsk); err != nil {
j.cancel()
return nil, err
}
if err := definition.setup(&j, s.location, s.exec.clock.Now()); err != nil {
j.cancel()
return nil, err
}
newJobCtx, newJobCancel := context.WithCancel(context.Background())
select {
case <-s.shutdownCtx.Done():
newJobCancel()
case s.newJobCh <- newJobIn{
ctx: newJobCtx,
cancel: newJobCancel,
job: j,
}:
}
select {
case <-newJobCtx.Done():
case <-s.shutdownCtx.Done():
newJobCancel()
}
out := s.jobFromInternalJob(j)
if s.schedulerMonitor != nil {
s.notifyJobRegistered(out)
}
return &out, nil
}
func (s *scheduler) RemoveByTags(tags ...string) {
select {
case <-s.shutdownCtx.Done():
case s.removeJobsByTagsCh <- tags:
}
}
func (s *scheduler) RemoveJob(id uuid.UUID) error {
j := requestJobCtx(s.shutdownCtx, id, s.jobOutRequestCh)
if j == nil || j.id == uuid.Nil {
return ErrJobNotFound
}
select {
case <-s.shutdownCtx.Done():
case s.removeJobCh <- id:
}
return nil
}
func (s *scheduler) Start() {
if s.started.Load() {
s.logger.Warn("gocron: scheduler already started")
return
}
select {
case <-s.shutdownCtx.Done():
// Scheduler already shut down, don't notify
return
case s.startCh <- struct{}{}:
<-s.startedCh // Wait for scheduler to actually start
// Scheduler has started
s.notifySchedulerStarted()
}
}
func (s *scheduler) StopJobs() error {
select {
case <-s.shutdownCtx.Done():
return nil
case s.stopCh <- struct{}{}:
}
t := time.NewTimer(s.exec.stopTimeout + 2*time.Second)
select {
case err := <-s.stopErrCh:
t.Stop()
return err
case <-t.C:
return ErrStopSchedulerTimedOut
}
}
func (s *scheduler) Shutdown() error {
s.logger.Debug("scheduler shutting down")
s.shutdownCancel()
if !s.started.Load() {
return nil
}
t := time.NewTimer(s.exec.stopTimeout + 2*time.Second)
select {
case err := <-s.stopErrCh:
t.Stop()
// notify monitor that scheduler stopped
s.notifySchedulerShutdown()
return err
case <-t.C:
return ErrStopSchedulerTimedOut
}
}
func (s *scheduler) Update(id uuid.UUID, jobDefinition JobDefinition, task Task, options ...JobOption) (Job, error) {
return s.addOrUpdateJob(id, jobDefinition, task, options)
}
func (s *scheduler) JobsWaitingInQueue() int {
if s.exec.limitMode != nil && s.exec.limitMode.mode == LimitModeWait {
return len(s.exec.limitMode.in)
}
return 0
}
// -----------------------------------------------
// -----------------------------------------------
// ------------- Scheduler Options ---------------
// -----------------------------------------------
// -----------------------------------------------
// SchedulerOption defines the function for setting
// options on the Scheduler.
type SchedulerOption func(*scheduler) error
// WithClock sets the clock used by the Scheduler
// to the clock provided. See https://github.com/jonboulle/clockwork
func WithClock(clock clockwork.Clock) SchedulerOption {
return func(s *scheduler) error {
if clock == nil {
return ErrWithClockNil
}
s.exec.clock = clock
return nil
}
}
// WithDistributedElector sets the elector to be used by multiple
// Scheduler instances to determine who should be the leader.
// Only the leader runs jobs, while non-leaders wait and continue
// to check if a new leader has been elected.
func WithDistributedElector(elector Elector) SchedulerOption {
return func(s *scheduler) error {
if elector == nil {
return ErrWithDistributedElectorNil
}
s.exec.elector = elector
return nil
}
}
// WithDistributedLocker sets the locker to be used by multiple
// Scheduler instances to ensure that only one instance of each
// job is run.
// To disable this global locker for specific jobs, see
// WithDisabledDistributedJobLocker.
func WithDistributedLocker(locker Locker) SchedulerOption {
return func(s *scheduler) error {
if locker == nil {
return ErrWithDistributedLockerNil
}
s.exec.locker = locker
return nil
}
}
// WithGlobalJobOptions sets JobOption's that will be applied to
// all jobs added to the scheduler. JobOption's set on the job
// itself will override if the same JobOption is set globally.
func WithGlobalJobOptions(jobOptions ...JobOption) SchedulerOption {
return func(s *scheduler) error {
s.globalJobOptions = jobOptions
return nil
}
}
// LimitMode defines the modes used for handling jobs that reach
// the limit provided in WithLimitConcurrentJobs
type LimitMode int
const (
// LimitModeReschedule causes jobs reaching the limit set in
// WithLimitConcurrentJobs or WithSingletonMode to be skipped
// and rescheduled for the next run time rather than being
// queued up to wait.
LimitModeReschedule = 1
// LimitModeWait causes jobs reaching the limit set in
// WithLimitConcurrentJobs or WithSingletonMode to wait
// in a queue until a slot becomes available to run.
//
// Note: this mode can produce unpredictable results as
// job execution order isn't guaranteed. For example, a job that
// executes frequently may pile up in the wait queue and be executed
// many times back to back when the queue opens.
//
// Warning: do not use this mode if your jobs will continue to stack
// up beyond the ability of the limit workers to keep up. An example of
// what NOT to do:
//
// s, _ := gocron.NewScheduler(gocron.WithLimitConcurrentJobs)
// s.NewJob(
// gocron.DurationJob(
// time.Second,
// Task{
// Function: func() {
// time.Sleep(10 * time.Second)
// },
// },
// ),
// )
LimitModeWait = 2
)
// WithLimitConcurrentJobs sets the limit and mode to be used by the
// Scheduler for limiting the number of jobs that may be running at
// a given time.
//
// Note: the limit mode selected for WithLimitConcurrentJobs takes initial
// precedence in the event you are also running a limit mode at the job level
// using WithSingletonMode.
//
// Warning: a single time consuming job can dominate your limit in the event
// you are running both the scheduler limit WithLimitConcurrentJobs(1, LimitModeWait)
// and a job limit WithSingletonMode(LimitModeReschedule).
func WithLimitConcurrentJobs(limit uint, mode LimitMode) SchedulerOption {
return func(s *scheduler) error {
if limit == 0 {
return ErrWithLimitConcurrentJobsZero
}
s.exec.limitMode = &limitModeConfig{
mode: mode,
limit: limit,
in: make(chan jobIn, 1000),
singletonJobs: make(map[uuid.UUID]struct{}),
}
if mode == LimitModeReschedule {
s.exec.limitMode.rescheduleLimiter = make(chan struct{}, limit)
}
return nil
}
}
// WithLocation sets the location (i.e. timezone) that the scheduler
// should operate within. In many systems time.Local is UTC.
// Default: time.Local
func WithLocation(location *time.Location) SchedulerOption {
return func(s *scheduler) error {
if location == nil {
return ErrWithLocationNil
}
s.location = location
return nil
}
}
// WithLogger sets the logger to be used by the Scheduler.
func WithLogger(logger Logger) SchedulerOption {
return func(s *scheduler) error {
if logger == nil {
return ErrWithLoggerNil
}
s.logger = logger
s.exec.logger = logger
return nil
}
}
// WithStopTimeout sets the amount of time the Scheduler should
// wait gracefully for jobs to complete before returning when
// StopJobs() or Shutdown() are called.
// Default: 10 * time.Second
func WithStopTimeout(timeout time.Duration) SchedulerOption {
return func(s *scheduler) error {
if timeout <= 0 {
return ErrWithStopTimeoutZeroOrNegative
}
s.exec.stopTimeout = timeout
return nil
}
}
// WithMonitor sets the metrics provider to be used by the Scheduler.
func WithMonitor(monitor Monitor) SchedulerOption {
return func(s *scheduler) error {
if monitor == nil {
return ErrWithMonitorNil
}
s.exec.monitor = monitor
return nil
}
}
// WithMonitorStatus sets the metrics provider to be used by the Scheduler.
func WithMonitorStatus(monitor MonitorStatus) SchedulerOption {
return func(s *scheduler) error {
if monitor == nil {
return ErrWithMonitorNil
}
s.exec.monitorStatus = monitor
return nil
}
}
// WithSchedulerMonitor sets a monitor that will be called with scheduler-level events.
func WithSchedulerMonitor(monitor SchedulerMonitor) SchedulerOption {
return func(s *scheduler) error {
if monitor == nil {
return ErrSchedulerMonitorNil
}
s.schedulerMonitor = monitor
return nil
}
}
// notifySchedulerStarted notifies the monitor that scheduler has started
func (s *scheduler) notifySchedulerStarted() {
if s.schedulerMonitor != nil {
s.schedulerMonitor.SchedulerStarted()
}
}
// notifySchedulerShutdown notifies the monitor that scheduler has stopped
func (s *scheduler) notifySchedulerShutdown() {
if s.schedulerMonitor != nil {
s.schedulerMonitor.SchedulerShutdown()
}
}
// notifyJobRegistered notifies the monitor that a job has been registered
func (s *scheduler) notifyJobRegistered(job Job) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobRegistered(job)
}
}
// notifyJobUnregistered notifies the monitor that a job has been unregistered
func (s *scheduler) notifyJobUnregistered(job Job) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobUnregistered(job)
}
}
// notifyJobStarted notifies the monitor that a job has started
func (s *scheduler) notifyJobStarted(job Job) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobStarted(job)
}
}
// notifyJobRunning notifies the monitor that a job is running.
func (s *scheduler) notifyJobRunning(job Job) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobRunning(job)
}
}
// notifyJobCompleted notifies the monitor that a job has completed.
func (s *scheduler) notifyJobCompleted(job Job) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobCompleted(job)
}
}
// notifyJobFailed notifies the monitor that a job has failed.
func (s *scheduler) notifyJobFailed(job Job, err error) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobFailed(job, err)
}
}
// notifySchedulerStopped notifies the monitor that the scheduler has stopped
func (s *scheduler) notifySchedulerStopped() {
if s.schedulerMonitor != nil {
s.schedulerMonitor.SchedulerStopped()
}
}
// notifyJobExecutionTime notifies the monitor of a job's execution time
func (s *scheduler) notifyJobExecutionTime(job Job, duration time.Duration) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobExecutionTime(job, duration)
}
}
// notifyJobSchedulingDelay notifies the monitor of scheduling delay
func (s *scheduler) notifyJobSchedulingDelay(job Job, scheduledTime time.Time, actualStartTime time.Time) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.JobSchedulingDelay(job, scheduledTime, actualStartTime)
}
}
// notifyConcurrencyLimitReached notifies the monitor that a concurrency limit was reached
func (s *scheduler) notifyConcurrencyLimitReached(limitType string, job Job) {
if s.schedulerMonitor != nil {
s.schedulerMonitor.ConcurrencyLimitReached(limitType, job)
}
}
================================================
FILE: scheduler_monitor.go
================================================
package gocron
import "time"
// SchedulerMonitor is called by the Scheduler to provide scheduler-level
// metrics and events.
type SchedulerMonitor interface {
// SchedulerStarted is called when Start() is invoked on the scheduler.
SchedulerStarted()
// SchedulerStopped is called when the scheduler's main loop stops,
// but before final cleanup in Shutdown().
SchedulerStopped()
// SchedulerShutdown is called when Shutdown() completes successfully.
SchedulerShutdown()
// JobRegistered is called when a job is registered with the scheduler.
JobRegistered(job Job)
// JobUnregistered is called when a job is unregistered from the scheduler.
JobUnregistered(job Job)
// JobStarted is called when a job starts running.
JobStarted(job Job)
// JobRunning is called when a job is running.
JobRunning(job Job)
// JobFailed is called when a job fails to complete successfully.
JobFailed(job Job, err error)
// JobCompleted is called when a job has completed running.
JobCompleted(job Job)
// JobExecutionTime is called after a job completes (success or failure)
// with the time it took to execute. This enables calculation of metrics
// like AverageExecutionTime.
JobExecutionTime(job Job, duration time.Duration)
// JobSchedulingDelay is called when a job starts running, providing both
// the scheduled time and actual start time. This enables calculation of
// SchedulingLag metrics to detect when jobs are running behind schedule.
JobSchedulingDelay(job Job, scheduledTime time.Time, actualStartTime time.Time)
// ConcurrencyLimitReached is called when a job cannot start immediately
// due to concurrency limits (singleton or limit mode).
// limitType will be "singleton" or "limit".
ConcurrencyLimitReached(limitType string, job Job)
}
================================================
FILE: scheduler_monitor_test.go
================================================
package gocron
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testSchedulerMonitor is a test implementation of SchedulerMonitor
// that tracks scheduler lifecycle events
type testSchedulerMonitor struct {
mu sync.RWMutex
startedCount int64
stoppedCount int64
shutdownCount int64
jobRegCount int64
jobUnregCount int64
jobStartCount int64
jobRunningCount int64
jobCompletedCount int64
jobFailedCount int64
concurrencyLimitCount int64
startedCalls []time.Time
stoppedCalls []time.Time
shutdownCalls []time.Time
jobRegCalls []Job
jobUnregCalls []Job
jobStartCalls []Job
jobRunningCalls []Job
jobCompletedCalls []Job
jobExecutionTimes []time.Duration
jobSchedulingDelays []time.Duration
concurrencyLimitCalls []string
jobFailedCalls struct {
jobs []Job
errs []error
}
}
func newTestSchedulerMonitor() *testSchedulerMonitor {
return &testSchedulerMonitor{
startedCalls: make([]time.Time, 0),
stoppedCalls: make([]time.Time, 0),
shutdownCalls: make([]time.Time, 0),
jobRegCalls: make([]Job, 0),
jobUnregCalls: make([]Job, 0),
jobStartCalls: make([]Job, 0),
jobRunningCalls: make([]Job, 0),
jobCompletedCalls: make([]Job, 0),
jobExecutionTimes: make([]time.Duration, 0),
jobSchedulingDelays: make([]time.Duration, 0),
concurrencyLimitCalls: make([]string, 0),
jobFailedCalls: struct {
jobs []Job
errs []error
}{
jobs: make([]Job, 0),
errs: make([]error, 0),
},
}
}
func (t *testSchedulerMonitor) SchedulerStarted() {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.startedCount, 1)
t.startedCalls = append(t.startedCalls, time.Now())
}
func (t *testSchedulerMonitor) SchedulerStopped() {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.stoppedCount, 1)
t.stoppedCalls = append(t.stoppedCalls, time.Now())
}
func (t *testSchedulerMonitor) SchedulerShutdown() {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.shutdownCount, 1)
t.shutdownCalls = append(t.shutdownCalls, time.Now())
}
func (t *testSchedulerMonitor) getStartedCount() int64 {
return atomic.LoadInt64(&t.startedCount)
}
func (t *testSchedulerMonitor) getShutdownCount() int64 {
return atomic.LoadInt64(&t.shutdownCount)
}
func (t *testSchedulerMonitor) getStartedCalls() []time.Time {
t.mu.RLock()
defer t.mu.RUnlock()
return append([]time.Time{}, t.startedCalls...)
}
func (t *testSchedulerMonitor) getShutdownCalls() []time.Time {
t.mu.RLock()
defer t.mu.RUnlock()
return append([]time.Time{}, t.shutdownCalls...)
}
func (t *testSchedulerMonitor) JobRegistered(job Job) {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.jobRegCount, 1)
t.jobRegCalls = append(t.jobRegCalls, job)
}
func (t *testSchedulerMonitor) JobUnregistered(job Job) {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.jobUnregCount, 1)
t.jobUnregCalls = append(t.jobUnregCalls, job)
}
func (t *testSchedulerMonitor) JobStarted(job Job) {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.jobStartCount, 1)
t.jobStartCalls = append(t.jobStartCalls, job)
}
func (t *testSchedulerMonitor) JobRunning(job Job) {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.jobRunningCount, 1)
t.jobRunningCalls = append(t.jobRunningCalls, job)
}
func (t *testSchedulerMonitor) JobCompleted(job Job) {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.jobCompletedCount, 1)
t.jobCompletedCalls = append(t.jobCompletedCalls, job)
}
func (t *testSchedulerMonitor) JobFailed(job Job, err error) {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.jobFailedCount, 1)
t.jobFailedCalls.jobs = append(t.jobFailedCalls.jobs, job)
t.jobFailedCalls.errs = append(t.jobFailedCalls.errs, err)
}
func (t *testSchedulerMonitor) JobExecutionTime(_ Job, duration time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
t.jobExecutionTimes = append(t.jobExecutionTimes, duration)
}
func (t *testSchedulerMonitor) JobSchedulingDelay(_ Job, scheduledTime time.Time, actualStartTime time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
delay := actualStartTime.Sub(scheduledTime)
if delay > 0 {
t.jobSchedulingDelays = append(t.jobSchedulingDelays, delay)
}
}
func (t *testSchedulerMonitor) ConcurrencyLimitReached(limitType string, _ Job) {
t.mu.Lock()
defer t.mu.Unlock()
atomic.AddInt64(&t.concurrencyLimitCount, 1)
t.concurrencyLimitCalls = append(t.concurrencyLimitCalls, limitType)
}
func (t *testSchedulerMonitor) getJobRegCount() int64 {
return atomic.LoadInt64(&t.jobRegCount)
}
func (t *testSchedulerMonitor) getJobUnregCount() int64 {
return atomic.LoadInt64(&t.jobUnregCount)
}
func (t *testSchedulerMonitor) getJobStartCount() int64 {
return atomic.LoadInt64(&t.jobStartCount)
}
func (t *testSchedulerMonitor) getJobRunningCount() int64 {
return atomic.LoadInt64(&t.jobRunningCount)
}
func (t *testSchedulerMonitor) getJobCompletedCount() int64 {
return atomic.LoadInt64(&t.jobCompletedCount)
}
func (t *testSchedulerMonitor) getJobFailedCount() int64 {
return atomic.LoadInt64(&t.jobFailedCount)
}
// func (t *testSchedulerMonitor) getJobRegCalls() []Job {
// t.mu.RLock()
// defer t.mu.RUnlock()
// return append([]Job{}, t.jobRegCalls...)
// }
// func (t *testSchedulerMonitor) getJobUnregCalls() []Job {
// t.mu.RLock()
// defer t.mu.RUnlock()
// return append([]Job{}, t.jobUnregCalls...)
// }
// func (t *testSchedulerMonitor) getJobStartCalls() []Job {
// t.mu.RLock()
// defer t.mu.RUnlock()
// return append([]Job{}, t.jobStartCalls...)
// }
// func (t *testSchedulerMonitor) getJobRunningCalls() []Job {
// t.mu.RLock()
// defer t.mu.RUnlock()
// return append([]Job{}, t.jobRunningCalls...)
// }
// func (t *testSchedulerMonitor) getJobCompletedCalls() []Job {
// t.mu.RLock()
// defer t.mu.RUnlock()
// return append([]Job{}, t.jobCompletedCalls...)
// }
func (t *testSchedulerMonitor) getJobFailedCalls() ([]Job, []error) {
t.mu.RLock()
defer t.mu.RUnlock()
jobs := append([]Job{}, t.jobFailedCalls.jobs...)
errs := append([]error{}, t.jobFailedCalls.errs...)
return jobs, errs
}
func TestSchedulerMonitor_Basic(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
monitor := newTestSchedulerMonitor()
s := newTestScheduler(t, WithSchedulerMonitor(monitor))
// Before starting, monitor should not have been called
assert.Equal(t, int64(0), monitor.getStartedCount())
assert.Equal(t, int64(0), monitor.getShutdownCount())
// Add a simple job
_, err := s.NewJob(
DurationJob(time.Second),
NewTask(func() {}),
)
require.NoError(t, err)
// Start the scheduler
s.Start()
// Wait a bit for the start to complete
time.Sleep(50 * time.Millisecond)
// SchedulerStarted should have been called once
assert.Equal(t, int64(1), monitor.getStartedCount())
assert.Equal(t, int64(0), monitor.getShutdownCount())
// Shutdown the scheduler
err = s.Shutdown()
require.NoError(t, err)
// SchedulerShutdown should have been called once
assert.Equal(t, int64(1), monitor.getStartedCount())
assert.Equal(t, int64(1), monitor.getShutdownCount())
// Verify the order of calls
startedCalls := monitor.getStartedCalls()
shutdownCalls := monitor.getShutdownCalls()
require.Len(t, startedCalls, 1)
require.Len(t, shutdownCalls, 1)
assert.True(t, startedCalls[0].Before(shutdownCalls[0]),
"SchedulerStarted should be called before SchedulerShutdown")
}
func TestSchedulerMonitor_MultipleStartStop(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
monitor := newTestSchedulerMonitor()
s := newTestScheduler(t, WithSchedulerMonitor(monitor))
_, err := s.NewJob(
DurationJob(time.Second),
NewTask(func() {}),
)
require.NoError(t, err)
// Start and stop multiple times
s.Start()
time.Sleep(50 * time.Millisecond)
assert.Equal(t, int64(1), monitor.getStartedCount())
err = s.StopJobs()
require.NoError(t, err)
// StopJobs shouldn't call SchedulerShutdown
assert.Equal(t, int64(0), monitor.getShutdownCount())
// Start again
s.Start()
time.Sleep(50 * time.Millisecond)
assert.Equal(t, int64(2), monitor.getStartedCount())
// Final shutdown
err = s.Shutdown()
require.NoError(t, err)
assert.Equal(t, int64(1), monitor.getShutdownCount())
}
func TestSchedulerMonitor_WithoutMonitor(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
// Create scheduler without monitor - should not panic
s := newTestScheduler(t)
_, err := s.NewJob(
DurationJob(time.Second),
NewTask(func() {}),
)
require.NoError(t, err)
s.Start()
time.Sleep(50 * time.Millisecond)
err = s.Shutdown()
require.NoError(t, err)
}
func TestSchedulerMonitor_NilMonitor(t *testing.T) {
// Attempting to create a scheduler with nil monitor should error
_, err := NewScheduler(WithSchedulerMonitor(nil))
assert.Error(t, err)
assert.Equal(t, ErrSchedulerMonitorNil, err)
}
func TestSchedulerMonitor_ConcurrentAccess(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
monitor := newTestSchedulerMonitor()
s := newTestScheduler(t, WithSchedulerMonitor(monitor))
// Add multiple jobs
for i := 0; i < 10; i++ {
_, err := s.NewJob(
DurationJob(100*time.Millisecond),
NewTask(func() {}),
)
require.NoError(t, err)
}
// Start scheduler once (normal use case)
s.Start()
time.Sleep(150 * time.Millisecond)
// Verify monitor was called
assert.Equal(t, int64(1), monitor.getStartedCount())
err := s.Shutdown()
require.NoError(t, err)
// Monitor should be called for shutdown
assert.Equal(t, int64(1), monitor.getShutdownCount())
}
func TestSchedulerMonitor_StartWithoutJobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
monitor := newTestSchedulerMonitor()
s := newTestScheduler(t, WithSchedulerMonitor(monitor))
// Start scheduler without any jobs
s.Start()
time.Sleep(50 * time.Millisecond)
// Monitor should still be called
assert.Equal(t, int64(1), monitor.getStartedCount())
err := s.Shutdown()
require.NoError(t, err)
assert.Equal(t, int64(1), monitor.getShutdownCount())
}
func TestSchedulerMonitor_ShutdownWithoutStart(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
monitor := newTestSchedulerMonitor()
s := newTestScheduler(t, WithSchedulerMonitor(monitor))
_, err := s.NewJob(
DurationJob(time.Second),
NewTask(func() {}),
)
require.NoError(t, err)
// Shutdown without starting
err = s.Shutdown()
require.NoError(t, err)
// SchedulerStarted should not be called
assert.Equal(t, int64(0), monitor.getStartedCount())
// SchedulerShutdown should not be called if scheduler was never started
assert.Equal(t, int64(0), monitor.getShutdownCount())
}
func TestSchedulerMonitor_ThreadSafety(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
monitor := newTestSchedulerMonitor()
// Simulate concurrent calls to the monitor from multiple goroutines
var wg sync.WaitGroup
iterations := 100
for i := 0; i < iterations; i++ {
wg.Add(2)
go func() {
defer wg.Done()
monitor.SchedulerStarted()
}()
go func() {
defer wg.Done()
monitor.SchedulerShutdown()
}()
}
wg.Wait()
// Verify all calls were recorded
assert.Equal(t, int64(iterations), monitor.getStartedCount())
assert.Equal(t, int64(iterations), monitor.getShutdownCount())
assert.Len(t, monitor.getStartedCalls(), iterations)
assert.Len(t, monitor.getShutdownCalls(), iterations)
}
func TestSchedulerMonitor_IntegrationWithJobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
monitor := newTestSchedulerMonitor()
s := newTestScheduler(t, WithSchedulerMonitor(monitor))
// Test successful job
jobRunCount := atomic.Int32{}
j, err := s.NewJob(
DurationJob(50*time.Millisecond),
NewTask(func() {
jobRunCount.Add(1)
}),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
// Test failing job
_, err = s.NewJob(
DurationJob(50*time.Millisecond),
NewTask(func() error {
return fmt.Errorf("test error")
}),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
// Start scheduler
s.Start()
time.Sleep(150 * time.Millisecond) // Wait for jobs to execute
// Verify scheduler lifecycle events
assert.Equal(t, int64(1), monitor.getStartedCount())
assert.GreaterOrEqual(t, jobRunCount.Load(), int32(1))
// Verify job registration
assert.Equal(t, int64(2), monitor.getJobRegCount(), "Should have registered 2 jobs")
// Verify job execution events
assert.GreaterOrEqual(t, monitor.getJobStartCount(), int64(1), "Jobs should have started")
assert.GreaterOrEqual(t, monitor.getJobRunningCount(), int64(1), "Jobs should be running")
assert.GreaterOrEqual(t, monitor.getJobCompletedCount(), int64(1), "Successful job should complete")
assert.GreaterOrEqual(t, monitor.getJobFailedCount(), int64(1), "Failing job should fail")
// Get failed job details
failedJobs, errors := monitor.getJobFailedCalls()
assert.NotEmpty(t, failedJobs, "Should have recorded failed jobs")
assert.NotEmpty(t, errors, "Should have recorded job errors")
assert.Contains(t, errors[0].Error(), "test error", "Should record the correct error")
// Test unregistration
err = s.RemoveJob(j.ID())
require.NoError(t, err)
time.Sleep(50 * time.Millisecond) // Wait for async removal
assert.Equal(t, int64(1), monitor.getJobUnregCount(), "Should have unregistered 1 job")
// Shutdown
err = s.Shutdown()
require.NoError(t, err)
assert.Equal(t, int64(1), monitor.getShutdownCount())
}
================================================
FILE: scheduler_test.go
================================================
package gocron
import (
"context"
"errors"
"io"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
// ci/cd produces a lot of false positive goroutine leaks for reasons
// I have not been able to pin down. All tests pass locally without leaks.
// Tests run in ci will use the TEST_ENV 'ci' to skip running leak detection.
const testEnvLocal = "local"
var testEnv = testEnvLocal
func init() {
tmp := os.Getenv("TEST_ENV")
if tmp != "" {
testEnv = tmp
}
}
var verifyNoGoroutineLeaks = func(t *testing.T) {
if testEnv != testEnvLocal {
return
}
goleak.VerifyNone(t)
}
func newTestScheduler(t *testing.T, options ...SchedulerOption) Scheduler {
// default test options
out := []SchedulerOption{
WithLogger(NewLogger(LogLevelDebug)),
WithStopTimeout(time.Second),
}
// append any additional options 2nd to override defaults if needed
out = append(out, options...)
s, err := NewScheduler(out...)
require.NoError(t, err)
return s
}
var _ Locker = new(errorLocker)
type errorLocker struct{}
func (e errorLocker) Lock(_ context.Context, _ string) (Lock, error) {
return nil, errors.New("locked")
}
func TestScheduler_OneSecond_NoOptions(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
cronNoOptionsCh := make(chan struct{}, 10)
durationNoOptionsCh := make(chan struct{}, 10)
tests := []struct {
name string
ch chan struct{}
jd JobDefinition
tsk Task
}{
{
"cron",
cronNoOptionsCh,
CronJob(
"* * * * * *",
true,
),
NewTask(
func() {
cronNoOptionsCh <- struct{}{}
},
),
},
{
"duration",
durationNoOptionsCh,
DurationJob(
time.Second,
),
NewTask(
func() {
durationNoOptionsCh <- struct{}{}
},
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(tt.jd, tt.tsk)
require.NoError(t, err)
s.Start()
startTime := time.Now()
var runCount int
for runCount < 1 {
<-tt.ch
runCount++
}
require.NoError(t, s.Shutdown())
stopTime := time.Now()
select {
case <-tt.ch:
t.Fatal("job ran after scheduler was stopped")
case <-time.After(time.Millisecond * 50):
}
runDuration := stopTime.Sub(startTime)
assert.GreaterOrEqual(t, runDuration, time.Millisecond)
assert.LessOrEqual(t, runDuration, 1500*time.Millisecond)
})
}
}
func TestScheduler_LongRunningJobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
if testEnv != testEnvLocal {
// this test is flaky in ci, but always passes locally
t.SkipNow()
}
durationCh := make(chan struct{}, 10)
durationSingletonCh := make(chan struct{}, 10)
tests := []struct {
name string
ch chan struct{}
jd JobDefinition
tsk Task
opts []JobOption
options []SchedulerOption
expectedRuns int
}{
{
"duration with stop time between executions",
durationCh,
DurationJob(
time.Millisecond * 500,
),
NewTask(
func() {
time.Sleep(1 * time.Second)
durationCh <- struct{}{}
}),
[]JobOption{WithStopAt(WithStopDateTime(time.Now().Add(time.Millisecond * 1100)))},
[]SchedulerOption{WithStopTimeout(time.Second * 2)},
2,
},
{
"duration",
durationCh,
DurationJob(
time.Millisecond * 500,
),
NewTask(
func() {
time.Sleep(1 * time.Second)
durationCh <- struct{}{}
},
),
nil,
[]SchedulerOption{WithStopTimeout(time.Second * 2)},
3,
},
{
"duration singleton",
durationSingletonCh,
DurationJob(
time.Millisecond * 500,
),
NewTask(
func() {
time.Sleep(1 * time.Second)
durationSingletonCh <- struct{}{}
},
),
[]JobOption{WithSingletonMode(LimitModeWait)},
[]SchedulerOption{WithStopTimeout(time.Second * 5)},
2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t, tt.options...)
_, err := s.NewJob(tt.jd, tt.tsk, tt.opts...)
require.NoError(t, err)
s.Start()
time.Sleep(1600 * time.Millisecond)
require.NoError(t, s.Shutdown())
var runCount int
timeout := make(chan struct{})
go func() {
time.Sleep(2 * time.Second)
close(timeout)
}()
Outer:
for {
select {
case <-tt.ch:
runCount++
case <-timeout:
break Outer
}
}
assert.Equal(t, tt.expectedRuns, runCount)
})
}
}
func TestScheduler_Update(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
durationJobCh := make(chan struct{})
tests := []struct {
name string
initialJob JobDefinition
updateJob JobDefinition
tsk Task
ch chan struct{}
runCount int
updateAfterCount int
expectedMinTime time.Duration
expectedMaxRunTime time.Duration
}{
{
"duration, updated to another duration",
DurationJob(
time.Millisecond * 500,
),
DurationJob(
time.Second,
),
NewTask(
func() {
durationJobCh <- struct{}{}
},
),
durationJobCh,
2,
1,
time.Second * 1,
time.Second * 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
j, err := s.NewJob(tt.initialJob, tt.tsk)
require.NoError(t, err)
startTime := time.Now()
s.Start()
var runCount int
for runCount < tt.runCount {
select {
case <-tt.ch:
runCount++
if runCount == tt.updateAfterCount {
_, err = s.Update(j.ID(), tt.updateJob, tt.tsk)
require.NoError(t, err)
}
default:
}
}
require.NoError(t, s.Shutdown())
stopTime := time.Now()
select {
case <-tt.ch:
t.Fatal("job ran after scheduler was stopped")
case <-time.After(time.Millisecond * 50):
}
runDuration := stopTime.Sub(startTime)
assert.GreaterOrEqual(t, runDuration, tt.expectedMinTime)
assert.LessOrEqual(t, runDuration, tt.expectedMaxRunTime)
})
}
}
func TestScheduler_StopTimeout(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
jd JobDefinition
f any
opts []JobOption
}{
{
"duration",
DurationJob(
time.Millisecond * 100,
),
func(testDoneCtx context.Context) {
select {
case <-time.After(1 * time.Second):
case <-testDoneCtx.Done():
}
},
nil,
},
{
"duration singleton",
DurationJob(
time.Millisecond * 100,
),
func(testDoneCtx context.Context) {
select {
case <-time.After(1 * time.Second):
case <-testDoneCtx.Done():
}
},
[]JobOption{WithSingletonMode(LimitModeWait)},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testDoneCtx, cancel := context.WithCancel(context.Background())
s := newTestScheduler(t,
WithStopTimeout(time.Millisecond*100),
)
_, err := s.NewJob(tt.jd, NewTask(tt.f, testDoneCtx), tt.opts...)
require.NoError(t, err)
s.Start()
assert.ErrorIs(t, err, s.Shutdown())
cancel()
time.Sleep(2 * time.Second)
})
}
}
func TestScheduler_StopLongRunningJobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
t.Run("start, run job, stop jobs before job is completed", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(50*time.Millisecond),
)
_, err := s.NewJob(
DurationJob(
50*time.Millisecond,
),
NewTask(
func(ctx context.Context) {
select {
case <-ctx.Done():
case <-time.After(100 * time.Millisecond):
t.Fatal("job can not been canceled")
}
},
),
WithStartAt(
WithStartImmediately(),
),
WithSingletonMode(LimitModeReschedule),
)
require.NoError(t, err)
s.Start()
time.Sleep(20 * time.Millisecond)
// the running job is canceled, no unexpected timeout error
require.NoError(t, s.StopJobs())
time.Sleep(100 * time.Millisecond)
require.NoError(t, s.Shutdown())
})
t.Run("start, run job, stop jobs before job is completed - manual context cancel", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(50*time.Millisecond),
)
ctx, cancel := context.WithCancel(context.Background())
_, err := s.NewJob(
DurationJob(
50*time.Millisecond,
),
NewTask(
func(ctx context.Context) {
select {
case <-ctx.Done():
case <-time.After(100 * time.Millisecond):
t.Fatal("job can not been canceled")
}
}, ctx,
),
WithStartAt(
WithStartImmediately(),
),
WithSingletonMode(LimitModeReschedule),
)
require.NoError(t, err)
s.Start()
time.Sleep(20 * time.Millisecond)
// the running job is canceled, no unexpected timeout error
cancel()
require.NoError(t, s.StopJobs())
time.Sleep(100 * time.Millisecond)
require.NoError(t, s.Shutdown())
})
t.Run("start, run job, stop jobs before job is completed - manual context cancel WithContext", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(50*time.Millisecond),
)
ctx, cancel := context.WithCancel(context.Background())
_, err := s.NewJob(
DurationJob(
50*time.Millisecond,
),
NewTask(
func(ctx context.Context) {
select {
case <-ctx.Done():
case <-time.After(100 * time.Millisecond):
t.Fatal("job can not been canceled")
}
},
),
WithStartAt(
WithStartImmediately(),
),
WithSingletonMode(LimitModeReschedule),
WithContext(ctx),
)
require.NoError(t, err)
s.Start()
time.Sleep(20 * time.Millisecond)
// the running job is canceled, no unexpected timeout error
cancel()
require.NoError(t, s.StopJobs())
time.Sleep(100 * time.Millisecond)
require.NoError(t, s.Shutdown())
})
}
func TestScheduler_StopAndStartLongRunningJobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
t.Run("start, run job, stop jobs before job is completed", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(50*time.Millisecond),
)
_, err := s.NewJob(
DurationJob(
50*time.Millisecond,
),
NewTask(
func(ctx context.Context) {
select {
case <-ctx.Done():
case <-time.After(100 * time.Millisecond):
}
},
),
WithStartAt(
WithStartImmediately(),
),
WithSingletonMode(LimitModeReschedule),
)
require.NoError(t, err)
s.Start()
time.Sleep(20 * time.Millisecond)
// the running job is canceled, no unexpected timeout error
require.NoError(t, s.StopJobs())
s.Start()
time.Sleep(200 * time.Millisecond)
require.NoError(t, s.Shutdown())
})
}
func TestScheduler_Shutdown(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
t.Run("start, stop, start, shutdown", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(time.Second),
)
_, err := s.NewJob(
DurationJob(
50*time.Millisecond,
),
NewTask(
func() {},
),
WithStartAt(
WithStartImmediately(),
),
)
require.NoError(t, err)
s.Start()
require.NoError(t, s.StopJobs())
s.Start()
require.NoError(t, s.Shutdown())
})
t.Run("calling Job methods after shutdown errors", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(time.Second),
)
j, err := s.NewJob(
DurationJob(
100*time.Millisecond,
),
NewTask(
func() {},
),
WithStartAt(
WithStartImmediately(),
),
)
require.NoError(t, err)
s.Start()
require.NoError(t, s.Shutdown())
_, err = j.LastRun()
assert.ErrorIs(t, err, ErrJobNotFound)
_, err = j.NextRun()
assert.ErrorIs(t, err, ErrJobNotFound)
})
t.Run("calling shutdown multiple times is a no-op", func(t *testing.T) {
s := newTestScheduler(t)
s.Start()
assert.NoError(t, s.Shutdown())
assert.NoError(t, s.Shutdown())
})
}
func TestScheduler_Start(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
t.Run("calling start multiple times is a no-op", func(t *testing.T) {
s := newTestScheduler(t)
var counter int
var mu sync.Mutex
_, err := s.NewJob(
DurationJob(
100*time.Millisecond,
),
NewTask(
func() {
mu.Lock()
counter++
mu.Unlock()
},
),
)
require.NoError(t, err)
s.Start()
s.Start()
s.Start()
time.Sleep(1000 * time.Millisecond)
require.NoError(t, s.Shutdown())
assert.Contains(t, []int{9, 10}, counter)
})
}
func TestScheduler_NewJob(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
jd JobDefinition
tsk Task
opts []JobOption
}{
{
"cron with timezone",
CronJob(
"CRON_TZ=America/Chicago * * * * * *",
true,
),
NewTask(
func() {},
),
nil,
},
{
"cron with timezone, no seconds",
CronJob(
"CRON_TZ=America/Chicago * * * * *",
false,
),
NewTask(
func() {},
),
nil,
},
{
"random duration",
DurationRandomJob(
time.Second,
time.Second*5,
),
NewTask(
func() {},
),
nil,
},
{
"daily",
DailyJob(
1,
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
NewTask(
func() {},
),
nil,
},
{
"weekly",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
NewTask(
func() {},
),
nil,
},
{
"monthly",
MonthlyJob(
1,
NewDaysOfTheMonth(1, -1),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
NewTask(
func() {},
),
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(tt.jd, tt.tsk, tt.opts...)
require.NoError(t, err)
s.Start()
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_NewJobErrors(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
jd JobDefinition
opts []JobOption
err error
}{
{
"cron with timezone",
CronJob(
"bad cron",
true,
),
nil,
ErrCronJobParse,
},
{
"cron invalid date",
CronJob(
"* * * 31 FEB *",
true,
),
nil,
ErrCronJobInvalid,
},
{
"context nil",
DurationJob(time.Second),
[]JobOption{WithContext(nil)}, //nolint:staticcheck
ErrWithContextNil,
},
{
"duration job time interval is zero",
DurationJob(0 * time.Second),
nil,
ErrDurationJobIntervalZero,
},
{
"duration job time interval is negative",
DurationJob(-1 * time.Second),
nil,
ErrDurationJobIntervalNegative,
},
{
"random with bad min/max",
DurationRandomJob(
time.Second*5,
time.Second,
),
nil,
ErrDurationRandomJobMinMax,
},
{
"random with negative min",
DurationRandomJob(
-time.Second,
time.Second,
),
nil,
ErrDurationRandomJobPositive,
},
{
"random with negative max",
DurationRandomJob(
-2*time.Second,
-time.Second,
),
nil,
ErrDurationRandomJobPositive,
},
{
"daily job at times nil",
DailyJob(
1,
nil,
),
nil,
ErrDailyJobAtTimesNil,
},
{
"daily job at time nil",
DailyJob(
1,
NewAtTimes(nil),
),
nil,
ErrDailyJobAtTimeNil,
},
{
"daily job hours out of range",
DailyJob(
1,
NewAtTimes(
NewAtTime(100, 0, 0),
),
),
nil,
ErrDailyJobHours,
},
{
"daily job minutes out of range",
DailyJob(
1,
NewAtTimes(
NewAtTime(1, 100, 0),
),
),
nil,
ErrDailyJobMinutesSeconds,
},
{
"daily job seconds out of range",
DailyJob(
1,
NewAtTimes(
NewAtTime(1, 0, 100),
),
),
nil,
ErrDailyJobMinutesSeconds,
},
{
"daily job interval 0",
DailyJob(
0,
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrDailyJobZeroInterval,
},
{
"weekly job at times nil",
WeeklyJob(
1,
NewWeekdays(time.Monday),
nil,
),
nil,
ErrWeeklyJobAtTimesNil,
},
{
"weekly job at time nil",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(nil),
),
nil,
ErrWeeklyJobAtTimeNil,
},
{
"weekly job weekdays nil",
WeeklyJob(
1,
nil,
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrWeeklyJobDaysOfTheWeekNil,
},
{
"weekly job hours out of range",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(100, 0, 0),
),
),
nil,
ErrWeeklyJobHours,
},
{
"weekly job minutes out of range",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(1, 100, 0),
),
),
nil,
ErrWeeklyJobMinutesSeconds,
},
{
"weekly job seconds out of range",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(1, 0, 100),
),
),
nil,
ErrWeeklyJobMinutesSeconds,
},
{
"weekly job interval zero",
WeeklyJob(
0,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrWeeklyJobZeroInterval,
},
{
"monthly job at times nil",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
nil,
),
nil,
ErrMonthlyJobAtTimesNil,
},
{
"monthly job at time nil",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(nil),
),
nil,
ErrMonthlyJobAtTimeNil,
},
{
"monthly job days out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(0),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrMonthlyJobDays,
},
{
"monthly job days out of range",
MonthlyJob(
1,
nil,
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrMonthlyJobDaysNil,
},
{
"monthly job hours out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(
NewAtTime(100, 0, 0),
),
),
nil,
ErrMonthlyJobHours,
},
{
"monthly job minutes out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(
NewAtTime(1, 100, 0),
),
),
nil,
ErrMonthlyJobMinutesSeconds,
},
{
"monthly job seconds out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(
NewAtTime(1, 0, 100),
),
),
nil,
ErrMonthlyJobMinutesSeconds,
},
{
"monthly job interval zero",
MonthlyJob(
0,
NewDaysOfTheMonth(1),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrMonthlyJobZeroInterval,
},
{
"WithName no name",
DurationJob(
time.Second,
),
[]JobOption{WithName("")},
ErrWithNameEmpty,
},
{
"WithStartDateTime is zero",
DurationJob(
time.Second,
),
[]JobOption{WithStartAt(WithStartDateTime(time.Time{}))},
ErrWithStartDateTimePast,
},
{
"WithStartDateTime is in the past",
DurationJob(
time.Second,
),
[]JobOption{WithStartAt(WithStartDateTime(time.Now().Add(-time.Second)))},
ErrWithStartDateTimePast,
},
{
"WithStartDateTimePast is zero",
DurationJob(
time.Second,
),
[]JobOption{WithStartAt(WithStartDateTimePast(time.Time{}))},
ErrWithStartDateTimePastZero,
},
{
"WithStartDateTime is later than the end",
DurationJob(
time.Second,
),
[]JobOption{WithStopAt(WithStopDateTime(time.Now().Add(time.Second))), WithStartAt(WithStartDateTime(time.Now().Add(time.Hour)))},
ErrStartTimeLaterThanEndTime,
},
{
"WithStopDateTime is earlier than the start",
DurationJob(
time.Second,
),
[]JobOption{WithStartAt(WithStartDateTime(time.Now().Add(time.Hour))), WithStopAt(WithStopDateTime(time.Now().Add(time.Second)))},
ErrStopTimeEarlierThanStartTime,
},
{
"oneTimeJob start at is zero",
OneTimeJob(OneTimeJobStartDateTime(time.Time{})),
nil,
ErrOneTimeJobStartDateTimePast,
},
{
"oneTimeJob start at is in past",
OneTimeJob(OneTimeJobStartDateTime(time.Now().Add(-time.Second))),
nil,
ErrOneTimeJobStartDateTimePast,
},
{
"WithDistributedJobLocker is nil",
DurationJob(
time.Second,
),
[]JobOption{WithDistributedJobLocker(nil)},
ErrWithDistributedJobLockerNil,
},
{
"WithIdentifier is nil",
DurationJob(
time.Second,
),
[]JobOption{WithIdentifier(uuid.Nil)},
ErrWithIdentifierNil,
},
{
"WithLimitedRuns is zero",
DurationJob(
time.Second,
),
[]JobOption{WithLimitedRuns(0)},
ErrWithLimitedRunsZero,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(time.Millisecond*50),
)
_, err := s.NewJob(tt.jd, NewTask(func() {}), tt.opts...)
assert.ErrorIs(t, err, tt.err)
require.NoError(t, s.Shutdown())
})
t.Run(tt.name+" global", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(time.Millisecond*50),
WithGlobalJobOptions(tt.opts...),
)
_, err := s.NewJob(tt.jd, NewTask(func() {}))
assert.ErrorIs(t, err, tt.err)
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_NewJobTask(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
testFuncPtr := func() {}
testFuncWithParams := func(_, _ string) {}
testStruct := struct{}{}
tests := []struct {
name string
tsk Task
err error
}{
{
"task nil",
nil,
ErrNewJobTaskNil,
},
{
"task not func - nil",
NewTask(nil),
ErrNewJobTaskNotFunc,
},
{
"task not func - string",
NewTask("not a func"),
ErrNewJobTaskNotFunc,
},
{
"task func is pointer",
NewTask(&testFuncPtr),
nil,
},
{
"parameter number does not match",
NewTask(testFuncWithParams, "one"),
ErrNewJobWrongNumberOfParameters,
},
{
"parameter type does not match",
NewTask(testFuncWithParams, "one", 2),
ErrNewJobWrongTypeOfParameters,
},
{
"parameter number does not match - ptr",
NewTask(&testFuncWithParams, "one"),
ErrNewJobWrongNumberOfParameters,
},
{
"parameter type does not match - ptr",
NewTask(&testFuncWithParams, "one", 2),
ErrNewJobWrongTypeOfParameters,
},
{
"all good struct",
NewTask(func(_ struct{}) {}, struct{}{}),
nil,
},
{
"all good interface",
NewTask(func(_ interface{}) {}, struct{}{}),
nil,
},
{
"all good any",
NewTask(func(_ any) {}, struct{}{}),
nil,
},
{
"all good slice",
NewTask(func(_ []struct{}) {}, []struct{}{}),
nil,
},
{
"all good chan",
NewTask(func(_ chan struct{}) {}, make(chan struct{})),
nil,
},
{
"all good pointer",
NewTask(func(_ *struct{}) {}, &testStruct),
nil,
},
{
"all good map",
NewTask(func(_ map[string]struct{}) {}, make(map[string]struct{})),
nil,
},
{
"all good",
NewTask(&testFuncWithParams, "one", "two"),
nil,
},
{
"parameter type does not match - different argument types against variadic parameters",
NewTask(func(_ ...string) {}, "one", 2),
ErrNewJobWrongTypeOfParameters,
},
{
"all good string - variadic",
NewTask(func(_ ...string) {}, "one", "two"),
nil,
},
{
"all good mixed variadic",
NewTask(func(_ int, _ ...string) {}, 1, "one", "two"),
nil,
},
{
"all good struct - variadic",
NewTask(func(_ ...interface{}) {}, struct{}{}),
nil,
},
{
"all good no arguments passed in - variadic",
NewTask(func(_ ...interface{}) {}),
nil,
},
{
"all good - interface variadic, int, string",
NewTask(func(_ ...interface{}) {}, 1, "2", 3.0),
nil,
},
{
"parameter type does not match - different argument types against interface variadic parameters",
NewTask(func(_ ...io.Reader) {}, os.Stdout, any(3.0)),
ErrNewJobWrongTypeOfParameters,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(DurationJob(time.Second), tt.tsk)
assert.ErrorIs(t, err, tt.err)
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_WithOptionsErrors(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
opt SchedulerOption
err error
}{
{
"WithClock nil",
WithClock(nil),
ErrWithClockNil,
},
{
"WithDistributedElector nil",
WithDistributedElector(nil),
ErrWithDistributedElectorNil,
},
{
"WithDistributedLocker nil",
WithDistributedLocker(nil),
ErrWithDistributedLockerNil,
},
{
"WithLimitConcurrentJobs limit 0",
WithLimitConcurrentJobs(0, LimitModeWait),
ErrWithLimitConcurrentJobsZero,
},
{
"WithLocation nil",
WithLocation(nil),
ErrWithLocationNil,
},
{
"WithLogger nil",
WithLogger(nil),
ErrWithLoggerNil,
},
{
"WithStopTimeout 0",
WithStopTimeout(0),
ErrWithStopTimeoutZeroOrNegative,
},
{
"WithStopTimeout -1",
WithStopTimeout(-1),
ErrWithStopTimeoutZeroOrNegative,
},
{
"WithMonitorer nil",
WithMonitor(nil),
ErrWithMonitorNil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewScheduler(tt.opt)
assert.ErrorIs(t, err, tt.err)
})
}
}
func TestScheduler_Singleton(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
duration time.Duration
limitMode LimitMode
runCount int
expectedMin time.Duration
expectedMax time.Duration
}{
{
"singleton mode reschedule",
time.Millisecond * 100,
LimitModeReschedule,
3,
time.Millisecond * 600,
time.Millisecond * 1100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobRanCh := make(chan struct{}, 10)
s := newTestScheduler(t,
WithStopTimeout(1*time.Second),
WithLocation(time.Local),
)
_, err := s.NewJob(
DurationJob(
tt.duration,
),
NewTask(func() {
time.Sleep(tt.duration * 2)
jobRanCh <- struct{}{}
}),
WithSingletonMode(tt.limitMode),
)
require.NoError(t, err)
start := time.Now()
s.Start()
var runCount int
for runCount < tt.runCount {
select {
case <-jobRanCh:
runCount++
case <-time.After(time.Second):
t.Fatalf("timed out waiting for jobs to run")
}
}
stop := time.Now()
require.NoError(t, s.Shutdown())
assert.GreaterOrEqual(t, stop.Sub(start), tt.expectedMin)
assert.LessOrEqual(t, stop.Sub(start), tt.expectedMax)
})
}
}
func TestScheduler_LimitMode(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
numJobs int
limit uint
limitMode LimitMode
duration time.Duration
expectedMin time.Duration
expectedMax time.Duration
}{
{
"limit mode reschedule",
10,
2,
LimitModeReschedule,
time.Millisecond * 100,
time.Millisecond * 400,
time.Millisecond * 700,
},
{
"limit mode wait",
10,
2,
LimitModeWait,
time.Millisecond * 100,
time.Millisecond * 200,
time.Millisecond * 500,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t,
WithLimitConcurrentJobs(tt.limit, tt.limitMode),
WithStopTimeout(2*time.Second),
)
jobRanCh := make(chan struct{}, 20)
for i := 0; i < tt.numJobs; i++ {
_, err := s.NewJob(
DurationJob(tt.duration),
NewTask(func() {
time.Sleep(tt.duration / 2)
jobRanCh <- struct{}{}
}),
)
require.NoError(t, err)
}
start := time.Now()
s.Start()
var runCount int
for runCount < tt.numJobs {
select {
case <-jobRanCh:
runCount++
case <-time.After(time.Second):
t.Fatalf("timed out waiting for jobs to run")
}
}
stop := time.Now()
require.NoError(t, s.Shutdown())
assert.GreaterOrEqual(t, stop.Sub(start), tt.expectedMin)
assert.LessOrEqual(t, stop.Sub(start), tt.expectedMax)
})
}
}
func TestScheduler_LimitModeAndSingleton(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
numJobs int
limit uint
limitMode LimitMode
singletonMode LimitMode
duration time.Duration
expectedMin time.Duration
expectedMax time.Duration
}{
{
"limit mode reschedule",
10,
2,
LimitModeReschedule,
LimitModeReschedule,
time.Millisecond * 100,
time.Millisecond * 400,
time.Millisecond * 700,
},
{
"limit mode wait",
10,
2,
LimitModeWait,
LimitModeWait,
time.Millisecond * 100,
time.Millisecond * 200,
time.Millisecond * 500,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t,
WithLimitConcurrentJobs(tt.limit, tt.limitMode),
WithStopTimeout(2*time.Second),
)
jobRanCh := make(chan int, 20)
for i := 0; i < tt.numJobs; i++ {
jobNum := i
_, err := s.NewJob(
DurationJob(tt.duration),
NewTask(func() {
time.Sleep(tt.duration / 2)
jobRanCh <- jobNum
}),
WithSingletonMode(tt.singletonMode),
)
require.NoError(t, err)
}
start := time.Now()
s.Start()
jobsRan := make(map[int]int)
var runCount int
for runCount < tt.numJobs {
select {
case jobNum := <-jobRanCh:
runCount++
jobsRan[jobNum]++
case <-time.After(time.Second):
t.Fatalf("timed out waiting for jobs to run")
}
}
stop := time.Now()
require.NoError(t, s.Shutdown())
assert.GreaterOrEqual(t, stop.Sub(start), tt.expectedMin)
assert.LessOrEqual(t, stop.Sub(start), tt.expectedMax)
for _, count := range jobsRan {
if tt.singletonMode == LimitModeWait {
assert.Equal(t, 1, count)
} else {
assert.LessOrEqual(t, count, 5)
}
}
})
}
}
func TestScheduler_OneTimeJob_DoesNotCleanupNext(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
schedulerStartTime := time.Date(2024, time.April, 3, 4, 5, 0, 0, time.UTC)
tests := []struct {
name string
runAt time.Time
fakeClock *clockwork.FakeClock
assertErr require.ErrorAssertionFunc
// asserts things about schedules, advance time and perform new assertions
advanceAndAsserts []func(
t *testing.T,
j Job,
clock *clockwork.FakeClock,
runs *atomic.Uint32,
)
}{
{
name: "exhausted run do does not cleanup next item",
runAt: time.Date(2024, time.April, 22, 4, 5, 0, 0, time.UTC),
fakeClock: clockwork.NewFakeClockAt(schedulerStartTime),
advanceAndAsserts: []func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32){
func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32) {
require.Equal(t, uint32(0), runs.Load())
// last not initialized
lastRunAt, err := j.LastRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, lastRunAt)
// next is now
expected := time.Date(2024, time.April, 22, 4, 5, 0, 0, time.UTC)
nextRunAt, err := j.NextRun()
require.NoError(t, err)
require.Equal(t, expected, nextRunAt.UTC())
// advance and eventually run
oneSecondAfterNextRun := expected.Add(1 * time.Second)
clock.Advance(oneSecondAfterNextRun.Sub(schedulerStartTime))
require.Eventually(t, func() bool {
return uint32(1) == runs.Load()
}, 3*time.Second, 100*time.Millisecond)
// last was run
lastRunAt, err = j.LastRun()
require.NoError(t, err)
require.WithinDuration(t, expected, lastRunAt, 1*time.Second)
nextRunAt, err = j.NextRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, nextRunAt)
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t, WithClock(tt.fakeClock), WithLocation(time.UTC))
t.Cleanup(func() {
require.NoError(t, s.Shutdown())
})
runs := atomic.Uint32{}
j, err := s.NewJob(
OneTimeJob(OneTimeJobStartDateTime(tt.runAt)),
NewTask(func() {
runs.Add(1)
}),
)
if tt.assertErr != nil {
tt.assertErr(t, err)
} else {
require.NoError(t, err)
s.Start()
for _, advanceAndAssert := range tt.advanceAndAsserts {
advanceAndAssert(t, j, tt.fakeClock, &runs)
}
}
})
}
}
var _ Elector = (*testElector)(nil)
type testElector struct {
mu sync.Mutex
leaderElected bool
notLeader chan struct{}
}
func (t *testElector) IsLeader(ctx context.Context) error {
select {
case <-ctx.Done():
return errors.New("done")
default:
}
t.mu.Lock()
defer t.mu.Unlock()
if t.leaderElected {
t.notLeader <- struct{}{}
return errors.New("already elected leader")
}
t.leaderElected = true
return nil
}
var _ Locker = (*testLocker)(nil)
type testLocker struct {
mu sync.Mutex
jobLocked bool
notLocked chan struct{}
}
func (t *testLocker) Lock(_ context.Context, _ string) (Lock, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.jobLocked {
t.notLocked <- struct{}{}
return nil, errors.New("job already locked")
}
t.jobLocked = true
return &testLock{}, nil
}
var _ Lock = (*testLock)(nil)
type testLock struct{}
func (t testLock) Unlock(_ context.Context) error {
return nil
}
func TestScheduler_WithDistributed(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
notLocked := make(chan struct{}, 10)
notLeader := make(chan struct{}, 10)
tests := []struct {
name string
count int
runCount int
schedulerOpts []SchedulerOption
jobOpts []JobOption
assertions func(*testing.T)
}{
{
"3 schedulers with elector",
3,
1,
[]SchedulerOption{
WithDistributedElector(&testElector{notLeader: notLeader}),
},
nil,
func(t *testing.T) {
timeout := time.Now().Add(1 * time.Second)
var notLeaderCount int
for !time.Now().After(timeout) {
select {
case <-notLeader:
notLeaderCount++
default:
}
}
assert.Equal(t, 2, notLeaderCount)
},
},
{
"3 schedulers with locker",
3,
1,
[]SchedulerOption{
WithDistributedLocker(&testLocker{notLocked: notLocked}),
},
nil,
func(_ *testing.T) {
timeout := time.Now().Add(1 * time.Second)
var notLockedCount int
for !time.Now().After(timeout) {
select {
case <-notLocked:
notLockedCount++
default:
}
}
assert.Equal(t, 2, notLockedCount)
},
},
{
"3 schedulers and job with Distributed locker",
3,
1,
nil,
[]JobOption{
WithDistributedJobLocker(&testLocker{notLocked: notLocked}),
},
func(_ *testing.T) {
timeout := time.Now().Add(1 * time.Second)
var notLockedCount int
for !time.Now().After(timeout) {
select {
case <-notLocked:
notLockedCount++
default:
}
}
assert.Equal(t, 2, notLockedCount)
},
},
{
"3 schedulers and job with disabled Distributed locker",
3,
3,
[]SchedulerOption{
WithDistributedLocker(&testLocker{notLocked: notLocked}),
},
[]JobOption{
WithDisabledDistributedJobLocker(true),
},
func(_ *testing.T) {
timeout := time.Now().Add(1 * time.Second)
var notLockedCount int
for !time.Now().After(timeout) {
select {
case <-notLocked:
notLockedCount++
default:
}
}
assert.Equal(t, 0, notLockedCount)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobsRan := make(chan struct{}, 20)
ctx, cancel := context.WithCancel(context.Background())
schedulersDone := make(chan struct{}, tt.count)
var (
runCount int
doneCount int
)
for i := tt.count; i > 0; i-- {
s := newTestScheduler(t,
tt.schedulerOpts...,
)
jobOpts := []JobOption{
WithStartAt(
WithStartImmediately(),
),
WithLimitedRuns(1),
}
jobOpts = append(jobOpts, tt.jobOpts...)
go func() {
s.Start()
_, err := s.NewJob(
DurationJob(
time.Second,
),
NewTask(
func() {
time.Sleep(100 * time.Millisecond)
jobsRan <- struct{}{}
},
),
jobOpts...,
)
require.NoError(t, err)
<-ctx.Done()
err = s.Shutdown()
require.NoError(t, err)
schedulersDone <- struct{}{}
}()
}
RunCountLoop:
for {
select {
case <-jobsRan:
runCount++
if runCount >= tt.runCount {
break RunCountLoop
}
case <-time.After(time.Second):
t.Error("timed out waiting for job to run")
break RunCountLoop
}
}
cancel()
assert.Equal(t, tt.runCount, runCount)
DoneCountLoop:
for {
select {
case <-schedulersDone:
doneCount++
if doneCount >= tt.count {
break DoneCountLoop
}
case <-time.After(3 * time.Second):
t.Error("timed out waiting for schedulers to shutdown")
break DoneCountLoop
}
}
assert.Equal(t, tt.count, doneCount)
time.Sleep(time.Second)
tt.assertions(t)
})
}
}
func TestScheduler_RemoveJob(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
addJob bool
err error
}{
{
"success",
true,
nil,
},
{
"job not found",
false,
ErrJobNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
var id uuid.UUID
if tt.addJob {
j, err := s.NewJob(DurationJob(time.Second), NewTask(func() {}))
require.NoError(t, err)
id = j.ID()
} else {
id = uuid.New()
}
err := s.RemoveJob(id)
assert.ErrorIs(t, err, tt.err)
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_JobsWaitingInQueue(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
limit uint
mode LimitMode
startAt func() OneTimeJobStartAtOption
expectedInQueue int
}{
{
"with mode wait limit 1",
1,
LimitModeWait,
func() OneTimeJobStartAtOption {
return OneTimeJobStartDateTime(time.Now().Add(10 * time.Millisecond))
},
4,
},
{
"with mode wait limit 10",
10,
LimitModeWait,
func() OneTimeJobStartAtOption {
return OneTimeJobStartDateTime(time.Now().Add(10 * time.Millisecond))
},
0,
},
{
"with mode Reschedule",
1,
LimitModeReschedule,
func() OneTimeJobStartAtOption {
return OneTimeJobStartDateTime(time.Now().Add(10 * time.Millisecond))
},
0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t, WithLimitConcurrentJobs(tt.limit, tt.mode))
for i := 0; i <= 4; i++ {
_, err := s.NewJob(OneTimeJob(tt.startAt()), NewTask(func() { time.Sleep(500 * time.Millisecond) }))
require.NoError(t, err)
}
s.Start()
time.Sleep(20 * time.Millisecond)
assert.Equal(t, tt.expectedInQueue, s.JobsWaitingInQueue())
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_RemoveLotsOfJobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
numJobs int
}{
{
"10 successes",
10,
},
{
"100 successes",
100,
},
{
"1000 successes",
1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
var ids []uuid.UUID
for i := 0; i < tt.numJobs; i++ {
j, err := s.NewJob(DurationJob(time.Second), NewTask(func() { time.Sleep(20 * time.Second) }))
require.NoError(t, err)
ids = append(ids, j.ID())
}
for _, id := range ids {
err := s.RemoveJob(id)
require.NoError(t, err)
}
assert.Len(t, s.Jobs(), 0)
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_RemoveJob_RemoveSelf(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
s := newTestScheduler(t)
s.Start()
_, err := s.NewJob(
DurationJob(100*time.Millisecond),
NewTask(func() {}),
WithEventListeners(
BeforeJobRuns(
func(_ uuid.UUID, _ string) {
s.RemoveByTags("tag1")
},
),
),
WithTags("tag1"),
)
require.NoError(t, err)
time.Sleep(time.Millisecond * 400)
assert.NoError(t, s.Shutdown())
}
func TestScheduler_WithEventListeners(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
listenerRunCh := make(chan error, 1)
testErr := errors.New("test error")
tests := []struct {
name string
tsk Task
el EventListener
expectRun bool
expectErr error
}{
{
"AfterJobRuns",
NewTask(func() {}),
AfterJobRuns(func(_ uuid.UUID, _ string) {
listenerRunCh <- nil
}),
true,
nil,
},
{
"AfterJobRunsWithError - error",
NewTask(func() error { return testErr }),
AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
listenerRunCh <- err
}),
true,
testErr,
},
{
"AfterJobRunsWithError - multiple return values, including error",
NewTask(func() (bool, error) { return false, testErr }),
AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
listenerRunCh <- err
}),
true,
testErr,
},
{
"AfterJobRunsWithError - no error",
NewTask(func() error { return nil }),
AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
listenerRunCh <- err
}),
false,
nil,
},
{
"BeforeJobRuns",
NewTask(func() {}),
BeforeJobRuns(func(_ uuid.UUID, _ string) {
listenerRunCh <- nil
}),
true,
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(
DurationJob(time.Minute*10),
tt.tsk,
WithStartAt(
WithStartImmediately(),
),
WithEventListeners(tt.el),
WithLimitedRuns(1),
)
require.NoError(t, err)
s.Start()
if tt.expectRun {
select {
case err = <-listenerRunCh:
assert.ErrorIs(t, err, tt.expectErr)
case <-time.After(time.Second):
t.Fatal("timed out waiting for listener to run")
}
} else {
select {
case <-listenerRunCh:
t.Fatal("listener ran when it shouldn't have")
case <-time.After(time.Millisecond * 100):
}
}
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_WithLocker_WithEventListeners(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
listenerRunCh := make(chan error, 1)
tests := []struct {
name string
locker Locker
tsk Task
el EventListener
expectRun bool
expectErr error
}{
{
"AfterLockError",
errorLocker{},
NewTask(func() {}),
AfterLockError(func(_ uuid.UUID, _ string, _ error) {
listenerRunCh <- nil
}),
true,
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(
DurationJob(time.Minute*10),
tt.tsk,
WithStartAt(
WithStartImmediately(),
),
WithDistributedJobLocker(tt.locker),
WithEventListeners(tt.el),
WithLimitedRuns(1),
)
require.NoError(t, err)
s.Start()
if tt.expectRun {
select {
case err = <-listenerRunCh:
assert.ErrorIs(t, err, tt.expectErr)
case <-time.After(time.Second):
t.Fatal("timed out waiting for listener to run")
}
} else {
select {
case <-listenerRunCh:
t.Fatal("listener ran when it shouldn't have")
case <-time.After(time.Millisecond * 100):
}
}
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_ManyJobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
s := newTestScheduler(t)
jobsRan := make(chan struct{}, 20000)
for i := 1; i <= 1000; i++ {
_, err := s.NewJob(
DurationJob(
time.Millisecond*100,
),
NewTask(
func() {
jobsRan <- struct{}{}
},
),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
}
s.Start()
time.Sleep(1 * time.Second)
require.NoError(t, s.Shutdown())
close(jobsRan)
var count int
for range jobsRan {
count++
}
assert.GreaterOrEqual(t, count, 9900)
assert.LessOrEqual(t, count, 11000)
}
func TestScheduler_RunJobNow(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
chDuration := make(chan struct{}, 10)
chMonthly := make(chan struct{}, 10)
chDurationImmediate := make(chan struct{}, 10)
chDurationSingleton := make(chan struct{}, 10)
chOneTime := make(chan struct{}, 10)
tests := []struct {
name string
ch chan struct{}
j JobDefinition
fun any
opts []JobOption
expectedDiff func() time.Duration
expectedRuns int
}{
{
"duration job",
chDuration,
DurationJob(time.Second * 10),
func() {
chDuration <- struct{}{}
},
nil,
func() time.Duration {
return 0
},
1,
},
{
"monthly job",
chMonthly,
MonthlyJob(1, NewDaysOfTheMonth(1), NewAtTimes(NewAtTime(0, 0, 0))),
func() {
chMonthly <- struct{}{}
},
nil,
func() time.Duration {
return 0
},
1,
},
{
"duration job - start immediately",
chDurationImmediate,
DurationJob(time.Second * 5),
func() {
chDurationImmediate <- struct{}{}
},
[]JobOption{
WithStartAt(
WithStartImmediately(),
),
},
func() time.Duration {
return 5 * time.Second
},
2,
},
{
"duration job - singleton",
chDurationSingleton,
DurationJob(time.Second * 10),
func() {
chDurationSingleton <- struct{}{}
time.Sleep(200 * time.Millisecond)
},
[]JobOption{
WithStartAt(
WithStartImmediately(),
),
WithSingletonMode(LimitModeReschedule),
},
func() time.Duration {
return 10 * time.Second
},
1,
},
{
"one time job",
chOneTime,
OneTimeJob(OneTimeJobStartImmediately()),
func() {
chOneTime <- struct{}{}
},
nil,
nil,
2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(tt.j, NewTask(tt.fun), tt.opts...)
require.NoError(t, err)
j := s.Jobs()[0]
s.Start()
var nextRunBefore time.Time
if tt.expectedDiff != nil {
for ; nextRunBefore.IsZero() || err != nil; nextRunBefore, err = j.NextRun() { //nolint:revive
}
}
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
require.NoError(t, j.RunNow())
var runCount int
select {
case <-tt.ch:
runCount++
case <-time.After(time.Second):
t.Fatal("timed out waiting for job to run")
}
timeout := time.Now().Add(time.Second)
for time.Now().Before(timeout) {
select {
case <-tt.ch:
runCount++
default:
}
}
assert.Equal(t, tt.expectedRuns, runCount)
nextRunAfter, err := j.NextRun()
if tt.expectedDiff != nil && tt.expectedDiff() > 0 {
for ; nextRunBefore.IsZero() || nextRunAfter.Equal(nextRunBefore); nextRunAfter, err = j.NextRun() { //nolint:revive
time.Sleep(100 * time.Millisecond)
}
}
assert.NoError(t, err)
assert.NoError(t, s.Shutdown())
if tt.expectedDiff != nil {
assert.Equal(t, tt.expectedDiff(), nextRunAfter.Sub(nextRunBefore))
}
})
}
}
func TestScheduler_LastRunSingleton(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
if testEnv != testEnvLocal {
// this test is flaky in ci, but always passes locally
t.SkipNow()
}
tests := []struct {
name string
f func(t *testing.T, j Job, jobRan chan struct{})
}{
{
"simple",
func(_ *testing.T, _ Job, _ chan struct{}) {},
},
{
"with runNow",
func(t *testing.T, j Job, jobRan chan struct{}) {
runTime := time.Now()
assert.NoError(t, j.RunNow())
// because we're using wait mode we need to wait here
// to make sure the job queued with RunNow has finished running
<-jobRan
lastRun, err := j.LastRun()
assert.NoError(t, err)
assert.LessOrEqual(t, lastRun.Sub(runTime), time.Millisecond*225)
assert.GreaterOrEqual(t, lastRun.Sub(runTime), time.Millisecond*175)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobRan := make(chan struct{}, 2)
s := newTestScheduler(t)
j, err := s.NewJob(
DurationJob(time.Millisecond*100),
NewTask(func() {
jobRan <- struct{}{}
time.Sleep(time.Millisecond * 200)
}),
WithSingletonMode(LimitModeWait),
)
require.NoError(t, err)
startTime := time.Now()
s.Start()
lastRun, err := j.LastRun()
assert.NoError(t, err)
assert.True(t, lastRun.IsZero())
<-jobRan
lastRun, err = j.LastRun()
assert.NoError(t, err)
assert.LessOrEqual(t, lastRun.Sub(startTime), time.Millisecond*125)
assert.GreaterOrEqual(t, lastRun.Sub(startTime), time.Millisecond*75)
tt.f(t, j, jobRan)
assert.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_OneTimeJob(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
startAt func() OneTimeJobStartAtOption
}{
{
"start now",
func() OneTimeJobStartAtOption {
return OneTimeJobStartImmediately()
},
},
{
"start in 100 ms",
func() OneTimeJobStartAtOption {
return OneTimeJobStartDateTime(time.Now().Add(100 * time.Millisecond))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobRan := make(chan struct{}, 2)
s := newTestScheduler(t)
_, err := s.NewJob(
OneTimeJob(tt.startAt()),
NewTask(func() {
jobRan <- struct{}{}
}),
)
require.NoError(t, err)
s.Start()
select {
case <-jobRan:
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for job to run")
}
assert.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_AtTimesJob(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
n := time.Now().UTC()
tests := []struct {
name string
atTimes []time.Time
fakeClock *clockwork.FakeClock
assertErr require.ErrorAssertionFunc
// asserts things about schedules, advance time and perform new assertions
advanceAndAsserts []func(
t *testing.T,
j Job,
clock *clockwork.FakeClock,
runs *atomic.Uint32,
)
}{
{
name: "no at times",
atTimes: []time.Time{},
fakeClock: clockwork.NewFakeClock(),
assertErr: func(t require.TestingT, err error, _ ...interface{}) {
require.ErrorIs(t, err, ErrOneTimeJobStartDateTimePast)
},
},
{
name: "all in the past",
atTimes: []time.Time{n.Add(-1 * time.Second)},
fakeClock: clockwork.NewFakeClockAt(n),
assertErr: func(t require.TestingT, err error, _ ...interface{}) {
require.ErrorIs(t, err, ErrOneTimeJobStartDateTimePast)
},
},
{
name: "one run 1 millisecond in the future",
atTimes: []time.Time{n.Add(1 * time.Millisecond)},
fakeClock: clockwork.NewFakeClockAt(n),
advanceAndAsserts: []func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32){
func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32) {
require.Equal(t, uint32(0), runs.Load())
// last not initialized
lastRunAt, err := j.LastRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, lastRunAt)
// next is now
nextRunAt, err := j.NextRun()
require.NoError(t, err)
require.Equal(t, n.Add(1*time.Millisecond), nextRunAt)
// advance and eventually run
clock.Advance(2 * time.Millisecond)
require.Eventually(t, func() bool {
return uint32(1) == runs.Load()
}, 3*time.Second, 100*time.Millisecond)
// last was run
lastRunAt, err = j.LastRun()
require.NoError(t, err)
require.WithinDuration(t, n.Add(1*time.Millisecond), lastRunAt, 1*time.Millisecond)
nextRunAt, err = j.NextRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, nextRunAt)
},
},
},
{
name: "one run in the past and one in the future",
atTimes: []time.Time{n.Add(-1 * time.Millisecond), n.Add(1 * time.Millisecond)},
fakeClock: clockwork.NewFakeClockAt(n),
advanceAndAsserts: []func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32){
func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32) {
require.Equal(t, uint32(0), runs.Load())
// last not initialized
lastRunAt, err := j.LastRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, lastRunAt)
// next is now
nextRunAt, err := j.NextRun()
require.NoError(t, err)
require.Equal(t, n.Add(1*time.Millisecond), nextRunAt)
// advance and eventually run
clock.Advance(2 * time.Millisecond)
require.Eventually(t, func() bool {
return uint32(1) == runs.Load()
}, 3*time.Second, 100*time.Millisecond)
// last was run
lastRunAt, err = j.LastRun()
require.NoError(t, err)
require.WithinDuration(t, n.Add(1*time.Millisecond), lastRunAt, 1*time.Millisecond)
},
},
},
{
name: "two runs in the future - order is maintained even if times are provided out of order",
atTimes: []time.Time{n.Add(3 * time.Millisecond), n.Add(1 * time.Millisecond)},
fakeClock: clockwork.NewFakeClockAt(n),
advanceAndAsserts: []func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32){
func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32) {
require.Equal(t, uint32(0), runs.Load())
// last not initialized
lastRunAt, err := j.LastRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, lastRunAt)
// next is now
nextRunAt, err := j.NextRun()
require.NoError(t, err)
require.Equal(t, n.Add(1*time.Millisecond), nextRunAt)
// advance and eventually run
clock.Advance(2 * time.Millisecond)
require.Eventually(t, func() bool {
return uint32(1) == runs.Load()
}, 3*time.Second, 100*time.Millisecond)
// last was run
lastRunAt, err = j.LastRun()
require.NoError(t, err)
require.WithinDuration(t, n.Add(1*time.Millisecond), lastRunAt, 1*time.Millisecond)
nextRunAt, err = j.NextRun()
require.NoError(t, err)
require.Equal(t, n.Add(3*time.Millisecond), nextRunAt)
},
func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32) {
// advance and eventually run
clock.Advance(2 * time.Millisecond)
require.Eventually(t, func() bool {
return uint32(2) == runs.Load()
}, 3*time.Second, 100*time.Millisecond)
// last was run
lastRunAt, err := j.LastRun()
require.NoError(t, err)
require.WithinDuration(t, n.Add(3*time.Millisecond), lastRunAt, 1*time.Millisecond)
nextRunAt, err := j.NextRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, nextRunAt)
},
},
},
{
name: "two runs in the future - order is maintained even if times are provided out of order - deduplication",
atTimes: []time.Time{n.Add(3 * time.Millisecond), n.Add(1 * time.Millisecond), n.Add(1 * time.Millisecond), n.Add(3 * time.Millisecond)},
fakeClock: clockwork.NewFakeClockAt(n),
advanceAndAsserts: []func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32){
func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32) {
require.Equal(t, uint32(0), runs.Load())
// last not initialized
lastRunAt, err := j.LastRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, lastRunAt)
// next is now
nextRunAt, err := j.NextRun()
require.NoError(t, err)
require.Equal(t, n.Add(1*time.Millisecond), nextRunAt)
// advance and eventually run
clock.Advance(2 * time.Millisecond)
require.Eventually(t, func() bool {
return uint32(1) == runs.Load()
}, 3*time.Second, 100*time.Millisecond)
// last was run
lastRunAt, err = j.LastRun()
require.NoError(t, err)
require.WithinDuration(t, n.Add(1*time.Millisecond), lastRunAt, 1*time.Millisecond)
nextRunAt, err = j.NextRun()
require.NoError(t, err)
require.Equal(t, n.Add(3*time.Millisecond), nextRunAt)
},
func(t *testing.T, j Job, clock *clockwork.FakeClock, runs *atomic.Uint32) {
// advance and eventually run
clock.Advance(2 * time.Millisecond)
require.Eventually(t, func() bool {
return uint32(2) == runs.Load()
}, 3*time.Second, 100*time.Millisecond)
// last was run
lastRunAt, err := j.LastRun()
require.NoError(t, err)
require.WithinDuration(t, n.Add(3*time.Millisecond), lastRunAt, 1*time.Millisecond)
nextRunAt, err := j.NextRun()
require.NoError(t, err)
require.Equal(t, time.Time{}, nextRunAt)
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t, WithClock(tt.fakeClock))
t.Cleanup(func() {
require.NoError(t, s.Shutdown())
})
runs := atomic.Uint32{}
j, err := s.NewJob(
OneTimeJob(OneTimeJobStartDateTimes(tt.atTimes...)),
NewTask(func() {
runs.Add(1)
}),
)
if tt.assertErr != nil {
tt.assertErr(t, err)
} else {
require.NoError(t, err)
s.Start()
for _, advanceAndAssert := range tt.advanceAndAsserts {
advanceAndAssert(t, j, tt.fakeClock, &runs)
}
}
})
}
}
func TestScheduler_WithLimitedRuns(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
schedulerOpts []SchedulerOption
job JobDefinition
jobOpts []JobOption
runLimit uint
expectedRuns int
}{
{
"simple",
nil,
DurationJob(time.Millisecond * 100),
nil,
1,
1,
},
{
"OneTimeJob, WithLimitConcurrentJobs",
[]SchedulerOption{
WithLimitConcurrentJobs(1, LimitModeWait),
},
OneTimeJob(OneTimeJobStartImmediately()),
nil,
1,
1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t, tt.schedulerOpts...)
jobRan := make(chan struct{}, 10)
jobOpts := []JobOption{
WithLimitedRuns(tt.runLimit),
}
jobOpts = append(jobOpts, tt.jobOpts...)
_, err := s.NewJob(
tt.job,
NewTask(func() {
jobRan <- struct{}{}
}),
jobOpts...,
)
require.NoError(t, err)
s.Start()
time.Sleep(time.Millisecond * 150)
assert.NoError(t, s.Shutdown())
var runCount int
for runCount < tt.expectedRuns {
select {
case <-jobRan:
runCount++
case <-time.After(time.Second):
t.Fatal("timed out waiting for job to run")
}
}
select {
case <-jobRan:
t.Fatal("job ran more than expected")
default:
}
assert.Equal(t, tt.expectedRuns, runCount)
})
}
}
func TestScheduler_Jobs(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
}{
{
"order is equal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
for i := 0; i <= 20; i++ {
_, err := s.NewJob(
DurationJob(time.Second),
NewTask(func() {}),
)
require.NoError(t, err)
}
jobsFirst := s.Jobs()
jobsSecond := s.Jobs()
assert.Equal(t, jobsFirst, jobsSecond)
assert.NoError(t, s.Shutdown())
})
}
}
type testMonitor struct {
mu sync.Mutex
counter map[string]int
time map[string][]time.Duration
}
func newTestMonitor() *testMonitor {
return &testMonitor{
counter: make(map[string]int),
time: make(map[string][]time.Duration),
}
}
func (t *testMonitor) IncrementJob(_ uuid.UUID, name string, _ []string, _ JobStatus) {
t.mu.Lock()
defer t.mu.Unlock()
_, ok := t.counter[name]
if !ok {
t.counter[name] = 0
}
t.counter[name]++
}
func (t *testMonitor) RecordJobTiming(startTime, endTime time.Time, _ uuid.UUID, name string, _ []string) {
t.mu.Lock()
defer t.mu.Unlock()
_, ok := t.time[name]
if !ok {
t.time[name] = make([]time.Duration, 0)
}
t.time[name] = append(t.time[name], endTime.Sub(startTime))
}
func TestScheduler_WithMonitor(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
tests := []struct {
name string
jd JobDefinition
jobName string
}{
{
"scheduler with monitor",
DurationJob(time.Millisecond * 50),
"job",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := make(chan struct{}, 20)
monitor := newTestMonitor()
s := newTestScheduler(t, WithMonitor(monitor))
opt := []JobOption{
WithName(tt.jobName),
WithStartAt(
WithStartImmediately(),
),
}
_, err := s.NewJob(
tt.jd,
NewTask(func() {
ch <- struct{}{}
}),
opt...,
)
require.NoError(t, err)
s.Start()
time.Sleep(150 * time.Millisecond)
require.NoError(t, s.Shutdown())
close(ch)
expectedCount := 0
for range ch {
expectedCount++
}
got := monitor.counter[tt.jobName]
if got != expectedCount {
t.Fatalf("job %q counter expected %d, got %d", tt.jobName, expectedCount, got)
}
})
}
}
func TestScheduler_WithStartAtDateTimePast(t *testing.T) {
defer verifyNoGoroutineLeaks(t)
// Monday
testTime := time.Date(2024, time.January, 1, 9, 0, 0, 0, time.UTC)
fakeClock := clockwork.NewFakeClockAt(testTime)
s := newTestScheduler(t, WithClock(fakeClock))
j, err := s.NewJob(
WeeklyJob(2, NewWeekdays(time.Sunday), NewAtTimes(NewAtTime(10, 0, 0))),
NewTask(func() {}),
WithStartAt(
// The start time is in the past (Dec 30, 2023 9am) which is a Saturday
WithStartDateTimePast(testTime.Add(-time.Hour*24*2)),
),
)
require.NoError(t, err)
s.Start()
nextRun, err := j.NextRun()
require.NoError(t, err)
require.NoError(t, s.Shutdown())
// Because the start time was in the past - we expect it to schedule 2 intervals ahead, pasing the first available Sunday
// which was in the past Dec 31, 2023, so the next is Jan 7, 2024
assert.Equal(t, time.Date(2024, time.January, 7, 10, 0, 0, 0, time.UTC), nextRun)
}
================================================
FILE: util.go
================================================
package gocron
import (
"context"
"reflect"
"slices"
"sync"
"time"
"github.com/google/uuid"
)
func callJobFuncWithParams(jobFunc any, params ...any) error {
if jobFunc == nil {
return nil
}
f := reflect.ValueOf(jobFunc)
if f.IsZero() {
return nil
}
if len(params) != f.Type().NumIn() {
return nil
}
in := make([]reflect.Value, len(params))
for k, param := range params {
in[k] = reflect.ValueOf(param)
}
returnValues := f.Call(in)
for _, val := range returnValues {
i := val.Interface()
if err, ok := i.(error); ok {
return err
}
}
return nil
}
func requestJob(id uuid.UUID, ch chan *jobOutRequest) *internalJob {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
return requestJobCtx(ctx, id, ch)
}
func requestJobCtx(ctx context.Context, id uuid.UUID, ch chan *jobOutRequest) *internalJob {
resp := make(chan internalJob, 1)
select {
case ch <- &jobOutRequest{
id: id,
outChan: resp,
}:
case <-ctx.Done():
return nil
}
var j internalJob
select {
case <-ctx.Done():
return nil
case jobReceived := <-resp:
j = jobReceived
}
return &j
}
func removeSliceDuplicatesInt(in []int) []int {
slices.Sort(in)
return slices.Compact(in)
}
func convertAtTimesToDateTime(atTimes AtTimes, location *time.Location) ([]time.Time, error) {
if atTimes == nil {
return nil, errAtTimesNil
}
var atTimesDate []time.Time
for _, a := range atTimes() {
if a == nil {
return nil, errAtTimeNil
}
at := a()
if at.hours > 23 {
return nil, errAtTimeHours
} else if at.minutes > 59 || at.seconds > 59 {
return nil, errAtTimeMinSec
}
atTimesDate = append(atTimesDate, at.time(location))
}
slices.SortStableFunc(atTimesDate, ascendingTime)
return atTimesDate, nil
}
func ascendingTime(a, b time.Time) int {
return a.Compare(b)
}
type waitGroupWithMutex struct {
wg sync.WaitGroup
mu sync.Mutex
}
func (w *waitGroupWithMutex) Add(delta int) {
w.mu.Lock()
defer w.mu.Unlock()
w.wg.Add(delta)
}
func (w *waitGroupWithMutex) Done() {
w.wg.Done()
}
func (w *waitGroupWithMutex) Wait() {
w.mu.Lock()
defer w.mu.Unlock()
w.wg.Wait()
}
================================================
FILE: util_test.go
================================================
package gocron
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestRemoveSliceDuplicatesInt(t *testing.T) {
tests := []struct {
name string
input []int
expected []int
}{
{
"lots of duplicates",
[]int{
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
},
[]int{1, 2, 3, 4, 5},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := removeSliceDuplicatesInt(tt.input)
assert.ElementsMatch(t, tt.expected, result)
})
}
}
func TestCallJobFuncWithParams(t *testing.T) {
type f1 func()
tests := []struct {
name string
jobFunc any
params []any
expectedErr error
}{
{
"nil jobFunc",
nil,
nil,
nil,
},
{
"zero jobFunc",
f1(nil),
nil,
nil,
},
{
"wrong number of params",
func(_ string, _ int) {},
[]any{"one"},
nil,
},
{
"function that returns an error",
func() error {
return errors.New("test error")
},
nil,
errors.New("test error"),
},
{
"function that returns no error",
func() error {
return nil
},
nil,
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := callJobFuncWithParams(tt.jobFunc, tt.params...)
assert.Equal(t, tt.expectedErr, err)
})
}
}
func TestConvertAtTimesToDateTime(t *testing.T) {
tests := []struct {
name string
atTimes AtTimes
location *time.Location
expected []time.Time
err error
}{
{
"atTimes is nil",
nil,
time.UTC,
nil,
errAtTimesNil,
},
{
"atTime is nil",
NewAtTimes(nil),
time.UTC,
nil,
errAtTimeNil,
},
{
"atTimes hours is invalid",
NewAtTimes(
NewAtTime(24, 0, 0),
),
time.UTC,
nil,
errAtTimeHours,
},
{
"atTimes minutes are invalid",
NewAtTimes(
NewAtTime(0, 60, 0),
),
time.UTC,
nil,
errAtTimeMinSec,
},
{
"atTimes seconds are invalid",
NewAtTimes(
NewAtTime(0, 0, 60),
),
time.UTC,
nil,
errAtTimeMinSec,
},
{
"atTimes valid",
NewAtTimes(
NewAtTime(0, 0, 3),
NewAtTime(0, 0, 0),
NewAtTime(0, 0, 1),
NewAtTime(0, 0, 2),
),
time.UTC,
[]time.Time{
time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
time.Date(0, 0, 0, 0, 0, 1, 0, time.UTC),
time.Date(0, 0, 0, 0, 0, 2, 0, time.UTC),
time.Date(0, 0, 0, 0, 0, 3, 0, time.UTC),
},
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := convertAtTimesToDateTime(tt.atTimes, tt.location)
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.err, err)
})
}
}