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 [![CI State](https://github.com/go-co-op/gocron/actions/workflows/go_test.yml/badge.svg?branch=v2&event=push)](https://github.com/go-co-op/gocron/actions) ![Go Report Card](https://goreportcard.com/badge/github.com/go-co-op/gocron) [![Go Doc](https://godoc.org/github.com/go-co-op/gocron/v2?status.svg)](https://pkg.go.dev/github.com/go-co-op/gocron/v2) 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) JetBrains logo [Sentry](https://sentry.io/welcome/) Sentry logo ## Star History Star History Chart ================================================ 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) }) } }