From 1df0a366edf231fec1cda6f2a97b3869867913c3 Mon Sep 17 00:00:00 2001 From: "F." Date: Fri, 10 Apr 2026 10:09:17 +0200 Subject: [PATCH 1/3] feat(v4): add named entries, event hooks, new job wrappers, and schedule inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `Entry.Name` field with `AddNamedFunc`, `AddNamedJob`, and `ScheduleNamed` methods for attaching human-readable labels to entries (logs, hooks, debugging) - Add `Timeout(time.Duration)` job wrapper — cancels the job context after a deadline - Add `MaxConcurrent(int, *slog.Logger)` job wrapper — generalizes `SkipIfStillRunning` to allow up to N concurrent invocations - Add `RetryOnError(int, time.Duration)` job wrapper — retries on failure with configurable backoff, respecting context cancellation - Add `EventHooks` struct with `OnJobStart` / `OnJobComplete` callbacks via `WithEventHooks` option for lifecycle observability (tracing, metrics) - Add `ErrorFunc` type and `WithOnError` option for error-only alerting callbacks - Add `NextN(Schedule, time.Time, int) []time.Time` to preview future fire times - Add `SpecSchedule.String()` to round-trip a parsed schedule back to a cron expression - Refactor `Schedule` / `AddJob` into internal `scheduleEntry` / `parseAndSchedule` helpers to reduce duplication - Update `startJob` to fire lifecycle hooks and route errors to `onError` callback - Include entry name in all structured log messages for improved observability - Add `example_test.go` with runnable pkg.go.dev examples for all new public APIs - Add depguard linter rules to `.golangci.yaml` - Remove Codacy security scan workflow - Update CHANGELOG and README with full feature documentation --- .github/workflows/codacy.yml | 65 ----------- .golangci.yaml | 8 ++ CHANGELOG.md | 16 +++ README.md | 64 ++++++++++- chain.go | 99 ++++++++++++++++ chain_test.go | 196 ++++++++++++++++++++++++++++++++ cron.go | 138 +++++++++++++++-------- doc.go | 41 +++++++ example_test.go | 213 +++++++++++++++++++++++++++++++++++ option.go | 30 +++++ schedule.go | 23 ++++ schedule_test.go | 66 +++++++++++ spec.go | 45 +++++++- spec_test.go | 83 ++++++++++++++ 14 files changed, 976 insertions(+), 111 deletions(-) delete mode 100644 .github/workflows/codacy.yml create mode 100644 example_test.go create mode 100644 schedule.go create mode 100644 schedule_test.go diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml deleted file mode 100644 index 2790839..0000000 --- a/.github/workflows/codacy.yml +++ /dev/null @@ -1,65 +0,0 @@ ---- -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# This workflow checks out code, performs a Codacy security scan -# and integrates the results with the -# GitHub Advanced Security code scanning feature. For more information on -# the Codacy security scan action usage and parameters, see -# https://github.com/codacy/codacy-analysis-cli-action. -# For more information on Codacy Analysis CLI in general, see -# https://github.com/codacy/codacy-analysis-cli. - -name: Codacy Security Scan - -on: - push: - branches: ["main"] - pull_request: - # The branches below must be a subset of the branches above - branches: ["main"] - schedule: - - cron: "40 11 * * 5" - -permissions: - contents: read - -jobs: - codacy-security-scan: - permissions: - # for actions/checkout to fetch code - contents: read - # for github/codeql-action/upload-sarif to upload SARIF results - security-events: write - # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status - actions: read - name: Codacy Security Scan - runs-on: ubuntu-latest - steps: - # Checkout the repository to the GitHub Actions runner - - name: Checkout code - uses: actions/checkout@v6 - - # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis - - name: Run Codacy Analysis CLI - uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08 - with: - # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository - # You can also omit the token and run the tools that support default configurations - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - verbose: true - output: results.sarif - format: sarif - # Adjust severity of non-security issues - gh-code-scanning-compat: true - # Force 0 exit code to allow SARIF file generation - # This will handover control about PR rejection to the GitHub side - max-allowed-issues: 2147483647 - - # Upload the SARIF file generated in the previous step - - name: Upload SARIF results file - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: results.sarif diff --git a/.golangci.yaml b/.golangci.yaml index 1fa4fcb..376a0f1 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -64,6 +64,14 @@ linters: # - "admin-ui/*" settings: + depguard: + rules: + main: + list-mode: lax + allow: + - $gostd + - github.com/hyp3rd/cron + cyclop: # The maximal code complexity to report. # Default: 10 diff --git a/CHANGELOG.md b/CHANGELOG.md index e653cbe..3c96134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `Entry.Name` field with `AddNamedFunc`, `AddNamedJob`, and `ScheduleNamed` + methods for human-readable entry labels in logs and hooks. +- `NextN(Schedule, time.Time, int) []time.Time` function to preview future + activation times. +- `SpecSchedule.String()` method to reconstruct a 6-field cron expression from + parsed bit fields. +- `Timeout(time.Duration)` job wrapper — cancels the job's context after a + deadline. +- `MaxConcurrent(int, *slog.Logger)` job wrapper — allows up to N concurrent + invocations, generalizes `SkipIfStillRunning`. +- `RetryOnError(int, time.Duration)` job wrapper — retries failed jobs with + configurable backoff. +- `EventHooks` struct with `OnJobStart` and `OnJobComplete` callbacks, set via + `WithEventHooks` option. +- `ErrorFunc` type and `WithOnError` option for error-only callbacks. +- `example_test.go` with runnable examples for pkg.go.dev. - `context.Context` threading throughout the public API: - `Job.Run(ctx context.Context) error` — jobs receive a cancellable context and return errors. diff --git a/README.md b/README.md index ed5c305..8d56ce4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,14 @@ func main() { - **`Clock` interface** — inject a fake clock via `WithClock` for deterministic, zero-`time.Sleep` tests. - **Job wrappers** — `Recover`, `SkipIfStillRunning`, `DelayIfStillRunning`, - and custom `JobWrapper` chains. + `Timeout`, `MaxConcurrent`, `RetryOnError`, and custom `JobWrapper` chains. +- **Named entries** — `AddNamedFunc` / `AddNamedJob` attach human-readable + labels for logging and observability. +- **Event hooks** — `WithEventHooks` for `OnJobStart` / `OnJobComplete` + callbacks; `WithOnError` for error-only alerting. +- **Schedule inspection** — `NextN` previews future fire times; + `SpecSchedule.String()` round-trips a parsed schedule back to a cron + expression. - **Thread-safe** — add, remove, and inspect entries while the scheduler is running. @@ -114,6 +121,61 @@ Or per-job: wrapped := cron.NewChain(cron.Recover(logger)).Then(myJob) ``` +### Timeout, concurrency, and retries + +```go +c := cron.New(cron.WithChain( + cron.Timeout(30 * time.Second), // cancel after 30s + cron.MaxConcurrent(3, logger), // allow up to 3 in parallel + cron.RetryOnError(2, 5 * time.Second), // retry twice with 5s backoff + cron.Recover(logger), +)) +``` + +## Named entries + +```go +c.AddNamedFunc("daily-report", "0 9 * * *", generateReport) + +for _, e := range c.Entries() { + fmt.Println(e.ID, e.Name, e.Next) +} +``` + +## Event hooks + +```go +c := cron.New(cron.WithEventHooks(cron.EventHooks{ + OnJobStart: func(id cron.EntryID, name string) { + span := tracer.Start(name) // start a trace span + }, + OnJobComplete: func(id cron.EntryID, name string, elapsed time.Duration, err error) { + metrics.Observe(name, elapsed, err) // record metrics + }, +})) +``` + +For error-only callbacks (alerting, retries): + +```go +c := cron.New(cron.WithOnError(func(id cron.EntryID, name string, err error) { + alerting.Notify(name, err) +})) +``` + +## Schedule inspection + +```go +// Preview the next 5 fire times +sched, _ := cron.ParseStandard("0 */6 * * *") +for _, t := range cron.NextN(sched, time.Now(), 5) { + fmt.Println(t) +} + +// Round-trip a parsed schedule back to a string +fmt.Println(sched) // "0 0,6,12,18 * * * *" +``` + ## Testing with a fake clock The `Clock` interface lets you drive the scheduler deterministically: diff --git a/chain.go b/chain.go index b697ebb..7f93b9d 100644 --- a/chain.go +++ b/chain.go @@ -118,3 +118,102 @@ func SkipIfStillRunning(logger *slog.Logger) JobWrapper { }) } } + +// MaxConcurrent allows up to limit concurrent invocations of the wrapped job. +// Additional invocations beyond the limit are skipped and logged at Info level. +// It generalizes [SkipIfStillRunning], which is equivalent to MaxConcurrent +// with a limit of 1. +func MaxConcurrent(limit int, logger *slog.Logger) JobWrapper { + return func(job Job) Job { + sem := make(chan struct{}, limit) + + for range limit { + sem <- struct{}{} + } + + return FuncJob(func(ctx context.Context) error { + select { + case token := <-sem: + defer func() { sem <- token }() + + return job.Run(ctx) + default: + logger.Info("skip", "reason", "max_concurrent", "limit", limit) + + return nil + } + }) + } +} + +// Timeout cancels the job's context after the given duration. If the job does +// not return before the deadline, the context passed to it is cancelled. The +// wrapper waits for the job to return and reports any error (including +// [context.DeadlineExceeded]) to the caller. +// +// Note: the wrapper does not forcefully kill the job goroutine. The job must +// honor ctx.Done() for cancellation to take effect. +func Timeout(duration time.Duration) JobWrapper { + return func(job Job) Job { + return FuncJob(func(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, duration) + defer cancel() + + return job.Run(ctx) + }) + } +} + +// RetryOnError retries the wrapped job up to maxRetries times when it returns a +// non-nil error. Between attempts it waits for the given backoff duration (or +// until the context is cancelled). A zero backoff retries immediately. +// +// The backoff uses real wall-clock time, not the scheduler's [Clock] interface. +func RetryOnError(maxRetries int, backoff time.Duration) JobWrapper { + return func(job Job) Job { + return FuncJob(func(ctx context.Context) error { + return executeWithRetry(ctx, job, maxRetries, backoff) + }) + } +} + +func executeWithRetry(ctx context.Context, job Job, maxRetries int, backoff time.Duration) error { + var err error + + for attempt := range maxRetries + 1 { + err = job.Run(ctx) + if err == nil { + return nil + } + + if attempt == maxRetries { + break + } + + waitErr := retryBackoff(ctx, backoff) + if waitErr != nil { + return waitErr + } + } + + return err +} + +// retryBackoff waits for the given duration or until the context is cancelled. +// A zero duration returns immediately. +func retryBackoff(ctx context.Context, backoff time.Duration) error { + if backoff <= 0 { + return nil + } + + timer := time.NewTimer(backoff) + + select { + case <-ctx.Done(): + timer.Stop() + + return fmt.Errorf("retry backoff: %w", ctx.Err()) + case <-timer.C: + return nil + } +} diff --git a/chain_test.go b/chain_test.go index 1015813..9766097 100644 --- a/chain_test.go +++ b/chain_test.go @@ -18,6 +18,13 @@ const ( rapidFireJobRuns = 11 rapidFireCompletionWait = 200 * time.Millisecond independentJobsWait = 100 * time.Millisecond + expectedThreeCalls = 3 +) + +var ( + errTransient = errors.New("transient") + errPersistent = errors.New("persistent") + errFail = errors.New("fail") ) func appendingJob(slice *[]int, value int) Job { @@ -343,3 +350,192 @@ func testChainSkipDifferentJobs(t *testing.T) { t.Error("expected both jobs executed once, got", done1, "and", done2) } } + +func TestMaxConcurrent(t *testing.T) { + t.Parallel() + + t.Run("allows up to limit", func(t *testing.T) { + t.Parallel() + + var jobCounter countJob + + jobCounter.delay = delayedJobDuration + + wrappedJob := NewChain(MaxConcurrent(2, DiscardLogger())).Then(&jobCounter) + + // Fire 3 concurrently; only 2 should start. + for range 3 { + runAsync(wrappedJob) + } + + time.Sleep(waitForFirstJob) + + started := jobCounter.Started() + if started != 2 { + t.Errorf("expected 2 started, got %d", started) + } + + time.Sleep(waitForDelayedJobs) + + done := jobCounter.Done() + if done != 2 { + t.Errorf("expected 2 done (1 skipped), got %d", done) + } + }) + + t.Run("limit one behaves like skip", func(t *testing.T) { + t.Parallel() + + var jobCounter countJob + + jobCounter.delay = delayedJobDuration + + wrappedJob := NewChain(MaxConcurrent(1, DiscardLogger())).Then(&jobCounter) + + for range 3 { + runAsync(wrappedJob) + } + + time.Sleep(waitForDelayedJobs) + + done := jobCounter.Done() + if done != 1 { + t.Errorf("expected 1 done (2 skipped), got %d", done) + } + }) +} + +func TestTimeout(t *testing.T) { + t.Parallel() + + t.Run("job completes before timeout", func(t *testing.T) { + t.Parallel() + + wrappedJob := NewChain(Timeout(100 * time.Millisecond)).Then( + FuncJob(func(_ context.Context) error { return nil }), + ) + + err := wrappedJob.Run(context.Background()) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("job exceeds timeout", func(t *testing.T) { + t.Parallel() + + wrappedJob := NewChain(Timeout(10 * time.Millisecond)).Then( + FuncJob(func(ctx context.Context) error { + <-ctx.Done() + + return ctx.Err() + }), + ) + + err := wrappedJob.Run(context.Background()) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("expected DeadlineExceeded, got %v", err) + } + }) +} + +func TestRetryOnError(t *testing.T) { + t.Parallel() + + t.Run("succeeds on first try", testRetrySucceedsFirstTry) + t.Run("succeeds after retries", testRetrySucceedsAfterRetries) + t.Run("exhausts retries", testRetryExhaustsRetries) + t.Run("respects context cancellation", testRetryRespectsCancel) +} + +func testRetrySucceedsFirstTry(t *testing.T) { + t.Parallel() + + var calls int + + wrappedJob := NewChain(RetryOnError(3, 0)).Then( + FuncJob(func(_ context.Context) error { + calls++ + + return nil + }), + ) + + err := wrappedJob.Run(context.Background()) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if calls != 1 { + t.Errorf("expected 1 call, got %d", calls) + } +} + +func testRetrySucceedsAfterRetries(t *testing.T) { + t.Parallel() + + var calls int + + wrappedJob := NewChain(RetryOnError(3, 0)).Then( + FuncJob(func(_ context.Context) error { + calls++ + + if calls < 3 { + return errTransient + } + + return nil + }), + ) + + err := wrappedJob.Run(context.Background()) + if err != nil { + t.Errorf("expected nil after retries, got %v", err) + } + + if calls != expectedThreeCalls { + t.Errorf("expected 3 calls, got %d", calls) + } +} + +func testRetryExhaustsRetries(t *testing.T) { + t.Parallel() + + var calls int + + wrappedJob := NewChain(RetryOnError(2, 0)).Then( + FuncJob(func(_ context.Context) error { + calls++ + + return errPersistent + }), + ) + + err := wrappedJob.Run(context.Background()) + if err == nil { + t.Error("expected error after exhausting retries") + } + + if calls != expectedThreeCalls { + t.Errorf("expected 3 calls (1 + 2 retries), got %d", calls) + } +} + +func testRetryRespectsCancel(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + wrappedJob := NewChain(RetryOnError(5, time.Hour)).Then( + FuncJob(func(_ context.Context) error { + cancel() // cancel during first backoff + + return errFail + }), + ) + + err := wrappedJob.Run(ctx) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled, got %v", err) + } +} diff --git a/cron.go b/cron.go index 172e806..0a4aa4f 100644 --- a/cron.go +++ b/cron.go @@ -35,6 +35,8 @@ type Cron struct { rootCtx context.Context //nolint:containedctx // stored to propagate cancellation to in-flight jobs rootCancel context.CancelFunc loopDone chan struct{} + onError ErrorFunc + hooks EventHooks } // Parser turns a cron spec string into a [Schedule]. The default @@ -68,6 +70,11 @@ type Entry struct { // snapshot or remove it. ID EntryID + // Name is an optional human-readable label for this entry, useful for + // logging, metrics, and debugging. It is set via [Cron.AddNamedFunc], + // [Cron.AddNamedJob], or [Cron.ScheduleNamed]. + Name string + // Schedule on which this job should be run. Schedule Schedule @@ -149,49 +156,31 @@ func (c *Cron) AddFunc(spec string, cmd func(ctx context.Context) error) (EntryI // The spec is parsed using the time zone of this Cron instance as the default. // An opaque ID is returned that can be used to later remove it. func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) { - schedule, err := c.parser.Parse(spec) - if err != nil { - return 0, fmt.Errorf("parse schedule %q: %w", spec, err) - } + return c.parseAndSchedule("", spec, cmd) +} - return c.Schedule(schedule, cmd), nil +// AddNamedFunc is like [Cron.AddFunc] but assigns a human-readable name to the +// entry. The name appears in log messages, [Entry.Name], and event hooks. +func (c *Cron) AddNamedFunc(name, spec string, cmd func(ctx context.Context) error) (EntryID, error) { + return c.AddNamedJob(name, spec, FuncJob(cmd)) +} + +// AddNamedJob is like [Cron.AddJob] but assigns a human-readable name to the +// entry. The name appears in log messages, [Entry.Name], and event hooks. +func (c *Cron) AddNamedJob(name, spec string, cmd Job) (EntryID, error) { + return c.parseAndSchedule(name, spec, cmd) } // Schedule adds a Job to the Cron to be run on the given schedule. // The job is wrapped with the configured Chain. func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID { - c.runningMu.Lock() - - c.nextID++ - - entry := &Entry{ - ID: c.nextID, - Schedule: schedule, - Job: cmd, - wrappedJob: c.chain.Then(cmd), - } - - if !c.running.Load() { - c.entries = append(c.entries, entry) - c.runningMu.Unlock() - - return entry.ID - } - - addCh := c.add - loopDone := c.loopDone - c.runningMu.Unlock() - - select { - case addCh <- entry: - case <-loopDone: - // Scheduler loop has exited; append directly. - c.runningMu.Lock() - c.entries = append(c.entries, entry) - c.runningMu.Unlock() - } + return c.scheduleEntry("", schedule, cmd) +} - return entry.ID +// ScheduleNamed is like [Cron.Schedule] but assigns a human-readable name to +// the entry. The name appears in log messages, [Entry.Name], and event hooks. +func (c *Cron) ScheduleNamed(name string, schedule Schedule, cmd Job) EntryID { + return c.scheduleEntry(name, schedule, cmd) } // Entries returns a snapshot of the cron entries. @@ -340,6 +329,51 @@ func (c *Cron) Stop(ctx context.Context) error { } } +func (c *Cron) parseAndSchedule(name, spec string, cmd Job) (EntryID, error) { + schedule, err := c.parser.Parse(spec) + if err != nil { + return 0, fmt.Errorf("parse schedule %q: %w", spec, err) + } + + return c.scheduleEntry(name, schedule, cmd), nil +} + +func (c *Cron) scheduleEntry(name string, sched Schedule, cmd Job) EntryID { + c.runningMu.Lock() + + c.nextID++ + + entry := &Entry{ + ID: c.nextID, + Name: name, + Schedule: sched, + Job: cmd, + wrappedJob: c.chain.Then(cmd), + } + + if !c.running.Load() { + c.entries = append(c.entries, entry) + c.runningMu.Unlock() + + return entry.ID + } + + addCh := c.add + loopDone := c.loopDone + c.runningMu.Unlock() + + select { + case addCh <- entry: + case <-loopDone: + // Scheduler loop has exited; append directly. + c.runningMu.Lock() + c.entries = append(c.entries, entry) + c.runningMu.Unlock() + } + + return entry.ID +} + // enterRunning must be called with runningMu held. It sets up the root context // derived from the caller's ctx and marks the scheduler as running. func (c *Cron) enterRunning(ctx context.Context) context.Context { @@ -468,10 +502,10 @@ func (c *Cron) runDueEntries(ctx context.Context, now time.Time) { break } - c.startJob(ctx, entry.wrappedJob) + c.startJob(ctx, entry) entry.Prev = entry.Next entry.Next = entry.Schedule.Next(now) - c.logger.Info("run", "now", now, "entry", entry.ID, "next", entry.Next) + c.logger.Info("run", "now", now, "entry", entry.ID, "name", entry.Name, "next", entry.Next) } } @@ -479,7 +513,7 @@ func (c *Cron) handleEntryAdded(newEntry *Entry) time.Time { now := c.now() newEntry.Next = newEntry.Schedule.Next(now) c.entries = append(c.entries, newEntry) - c.logger.Info("added", "now", now, "entry", newEntry.ID, "next", newEntry.Next) + c.logger.Info("added", "now", now, "entry", newEntry.ID, "name", newEntry.Name, "next", newEntry.Next) return now } @@ -492,13 +526,29 @@ func (c *Cron) handleEntryRemoved(id EntryID) time.Time { return now } -// startJob runs the given job in a new goroutine. Non-nil errors returned by -// the job are logged at Warn level and do not affect future executions. -func (c *Cron) startJob(ctx context.Context, j Job) { +// startJob runs the entry's wrapped job in a new goroutine, firing any +// configured [EventHooks] and [ErrorFunc]. Non-nil errors are logged at Warn +// level and do not affect future executions. +func (c *Cron) startJob(ctx context.Context, entry *Entry) { c.jobWaiter.Go(func() { - err := j.Run(ctx) + if c.hooks.OnJobStart != nil { + c.hooks.OnJobStart(entry.ID, entry.Name) + } + + start := time.Now() + + err := entry.wrappedJob.Run(ctx) + + if c.hooks.OnJobComplete != nil { + c.hooks.OnJobComplete(entry.ID, entry.Name, time.Since(start), err) + } + if err != nil { - c.logger.Warn("job error", "err", err) + c.logger.Warn("job error", "err", err, "entry", entry.ID, "name", entry.Name) + + if c.onError != nil { + c.onError(entry.ID, entry.Name, err) + } } }) } diff --git a/doc.go b/doc.go index 08871b1..27dda91 100644 --- a/doc.go +++ b/doc.go @@ -195,6 +195,47 @@ Install wrappers for individual jobs by explicitly wrapping them: cron.SkipIfStillRunning(logger), ).Then(job) +Additional built-in wrappers: + + - [Timeout] — cancels the job's context after a deadline + - [MaxConcurrent] — allows up to N concurrent invocations (generalizes SkipIfStillRunning) + - [RetryOnError] — retries failed jobs with configurable backoff + +Wrapper ordering matters when composing. For example: + + cron.NewChain(cron.Timeout(5*time.Second), cron.RetryOnError(3, time.Second)).Then(job) + +gives each retry attempt its own 5-second timeout, while reversing the order +shares a single timeout across all retries. + +# Named entries + +Use [Cron.AddNamedFunc], [Cron.AddNamedJob], or [Cron.ScheduleNamed] to assign +a human-readable name to an entry. The name appears in log messages, +[Entry.Name], and event hook callbacks. + +# Event hooks + +Register lifecycle callbacks via [WithEventHooks]: + + cron.New(cron.WithEventHooks(cron.EventHooks{ + OnJobStart: func(id cron.EntryID, name string) { ... }, + OnJobComplete: func(id cron.EntryID, name string, elapsed time.Duration, err error) { ... }, + })) + +For error-only callbacks, use [WithOnError]: + + cron.New(cron.WithOnError(func(id cron.EntryID, name string, err error) { ... })) + +# Schedule inspection + +[NextN] previews the next N fire times for any [Schedule]: + + times := cron.NextN(sched, time.Now(), 5) + +[SpecSchedule.String] reconstructs a 6-field cron expression from a parsed +schedule, enabling serialization and debugging. + # Thread safety Since the Cron service runs concurrently with the calling code, some amount of diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..3f078db --- /dev/null +++ b/example_test.go @@ -0,0 +1,213 @@ +//nolint:errcheck,gosec // examples prioritize clarity over exhaustive error handling +package cron_test + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "sync/atomic" + "time" + + "github.com/hyp3rd/cron/v4" +) + +const ( + exampleEverySecond = "* * * * * *" + exampleEveryHour = "0 * * * *" + exampleNextNCount = 3 +) + +var errAttemptFailed = errors.New("attempt failed") + +func ExampleCron_AddFunc() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cronInstance := cron.New(cron.WithSeconds()) + cronInstance.AddFunc(exampleEverySecond, func(_ context.Context) error { + fmt.Println("tick") + cancel() + + return nil + }) + + cronInstance.Start(ctx) + <-ctx.Done() + + cronInstance.Stop(context.Background()) + + // Output: + // tick +} + +func ExampleCron_AddNamedFunc() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cronInstance := cron.New(cron.WithSeconds()) + cronInstance.AddNamedFunc("heartbeat", exampleEverySecond, func(_ context.Context) error { + fmt.Println("heartbeat") + cancel() + + return nil + }) + + entry := cronInstance.Entries()[0] + fmt.Println("name:", entry.Name) + + cronInstance.Start(ctx) + <-ctx.Done() + + cronInstance.Stop(context.Background()) + + // Output: + // name: heartbeat + // heartbeat +} + +func ExampleNextN() { + sched, _ := cron.ParseStandard(exampleEveryHour) + + //nolint:revive // example uses fixed date and count for deterministic output + anchor := time.Date(2024, 6, 3, 12, 0, 0, 0, time.UTC) + times := cron.NextN(sched, anchor, exampleNextNCount) + + for _, nextTime := range times { + fmt.Println(nextTime.Format(time.DateTime)) + } + + // Output: + // 2024-06-03 13:00:00 + // 2024-06-03 14:00:00 + // 2024-06-03 15:00:00 +} + +func ExampleSpecSchedule_String() { + parser := cron.NewSpecParser( + cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, + ) + + sched, _ := parser.Parse("*/5 * * * * *") + fmt.Println(sched) + + // Output: + // 0,5,10,15,20,25,30,35,40,45,50,55 * * * * * +} + +func ExampleTimeout() { + job := cron.NewChain(cron.Timeout(5 * time.Second)).Then( + cron.FuncJob(func(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Millisecond): + fmt.Println("done") + + return nil + } + }), + ) + + job.Run(context.Background()) + + // Output: + // done +} + +func ExampleMaxConcurrent() { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + job := cron.NewChain(cron.MaxConcurrent(2, logger)).Then( + cron.FuncJob(func(_ context.Context) error { + fmt.Println("running") + + return nil + }), + ) + + job.Run(context.Background()) + + // Output: + // running +} + +func ExampleRetryOnError() { + var attempts atomic.Int32 + + job := cron.NewChain(cron.RetryOnError(2, time.Millisecond)).Then( + cron.FuncJob(func(_ context.Context) error { + attempt := attempts.Add(1) + if attempt < 3 { + return fmt.Errorf("%w: %d", errAttemptFailed, attempt) + } + + fmt.Println("succeeded on attempt", attempt) + + return nil + }), + ) + + job.Run(context.Background()) + + // Output: + // succeeded on attempt 3 +} + +func ExampleWithEventHooks() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cronInstance := cron.New( + cron.WithSeconds(), + cron.WithEventHooks(cron.EventHooks{ + OnJobStart: func(id cron.EntryID, name string) { + fmt.Printf("start: %s (id=%d)\n", name, id) + }, + OnJobComplete: func(id cron.EntryID, name string, _ time.Duration, _ error) { + fmt.Printf("complete: %s (id=%d)\n", name, id) + cancel() + }, + }), + ) + + cronInstance.AddNamedFunc("hello", exampleEverySecond, func(_ context.Context) error { + return nil + }) + + cronInstance.Start(ctx) + <-ctx.Done() + + cronInstance.Stop(context.Background()) + + // Output: + // start: hello (id=1) + // complete: hello (id=1) +} + +func ExampleWithOnError() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cronInstance := cron.New( + cron.WithSeconds(), + cron.WithLogger(cron.DiscardLogger()), + cron.WithOnError(func(id cron.EntryID, name string, err error) { + fmt.Printf("error in %s (id=%d): %v\n", name, id, err) + cancel() + }), + ) + + cronInstance.AddNamedFunc("failing-job", exampleEverySecond, func(_ context.Context) error { + return errors.New("something went wrong") //nolint:err113 // example error + }) + + cronInstance.Start(ctx) + <-ctx.Done() + + cronInstance.Stop(context.Background()) + + // Output: + // error in failing-job (id=1): something went wrong +} diff --git a/option.go b/option.go index 0495053..874c861 100644 --- a/option.go +++ b/option.go @@ -52,3 +52,33 @@ func WithClock(clock Clock) Option { c.clock = clock } } + +// ErrorFunc is called after a job returns a non-nil error. It fires in the +// job's goroutine, so it must be safe for concurrent use. +type ErrorFunc func(id EntryID, name string, err error) + +// WithOnError registers a callback invoked whenever a job returns a non-nil +// error. The callback receives the entry's ID, name, and the error. +func WithOnError(fn ErrorFunc) Option { + return func(c *Cron) { + c.onError = fn + } +} + +// EventHooks contains optional callbacks for job lifecycle events. All +// callbacks fire in the job's goroutine and must be safe for concurrent use. +type EventHooks struct { + // OnJobStart is called just before a job begins execution. + OnJobStart func(id EntryID, name string) + + // OnJobComplete is called after a job finishes, with the duration and + // the error (or nil). + OnJobComplete func(id EntryID, name string, elapsed time.Duration, err error) +} + +// WithEventHooks registers lifecycle callbacks for job execution events. +func WithEventHooks(hooks EventHooks) Option { + return func(c *Cron) { + c.hooks = hooks + } +} diff --git a/schedule.go b/schedule.go new file mode 100644 index 0000000..0099336 --- /dev/null +++ b/schedule.go @@ -0,0 +1,23 @@ +package cron + +import "time" + +// NextN returns the next n activation times for the given schedule, starting +// after the given time. If the schedule cannot produce n times (e.g., the +// schedule is unsatisfiable), the returned slice will have fewer than n +// elements. +func NextN(sched Schedule, after time.Time, count int) []time.Time { + times := make([]time.Time, 0, max(count, 0)) + + for range count { + next := sched.Next(after) + if next.IsZero() { + break + } + + times = append(times, next) + after = next + } + + return times +} diff --git a/schedule_test.go b/schedule_test.go new file mode 100644 index 0000000..a6bb50e --- /dev/null +++ b/schedule_test.go @@ -0,0 +1,66 @@ +package cron + +import ( + "testing" + "time" +) + +const nextNCount = 5 + +func TestNextNExpectedCount(t *testing.T) { + t.Parallel() + + parser := NewSpecParser(Second | Minute | Hour | Dom | Month | Dow | Descriptor) + + sched, err := parser.Parse("0 * * * * *") + if err != nil { + t.Fatal(err) + } + + times := NextN(sched, baseTime, nextNCount) + if len(times) != nextNCount { + t.Fatalf("expected 5 times, got %d", len(times)) + } + + for idx := 1; idx < len(times); idx++ { + diff := times[idx].Sub(times[idx-1]) + if diff != time.Minute { + t.Errorf("expected 1m between fires %d and %d, got %v", idx-1, idx, diff) + } + } +} + +func TestNextNUnsatisfiable(t *testing.T) { + t.Parallel() + + parser := NewSpecParser(Second | Minute | Hour | Dom | Month | Dow | Descriptor) + + sched, err := parser.Parse("0 0 0 30 Feb *") + if err != nil { + t.Fatal(err) + } + + times := NextN(sched, baseTime, nextNCount) + if len(times) != 0 { + t.Errorf("expected 0 times for unsatisfiable schedule, got %d", len(times)) + } +} + +func TestNextNZeroAndNegativeCount(t *testing.T) { + t.Parallel() + + parser := NewSpecParser(Second | Minute | Hour | Dom | Month | Dow | Descriptor) + + sched, err := parser.Parse("0 * * * * *") + if err != nil { + t.Fatal(err) + } + + if times := NextN(sched, baseTime, 0); len(times) != 0 { + t.Errorf("expected 0 times for count=0, got %d", len(times)) + } + + if times := NextN(sched, baseTime, -1); len(times) != 0 { + t.Errorf("expected 0 times for count=-1, got %d", len(times)) + } +} diff --git a/spec.go b/spec.go index 1563cb7..10f213f 100644 --- a/spec.go +++ b/spec.go @@ -1,6 +1,10 @@ package cron -import "time" +import ( + "strconv" + "strings" + "time" +) // SpecSchedule specifies a duty cycle (to the second granularity), based on a // traditional crontab specification. It is computed initially and stored as bit sets. @@ -118,6 +122,21 @@ func (s *SpecSchedule) Next(candidate time.Time) time.Time { return nextActivation.In(origLocation) } +// String reconstructs a 6-field cron expression (second minute hour dom month +// dow) from the schedule's bit fields. If a field matches all values in its +// range the output is "*". The returned expression can be re-parsed by a +// [SpecParser] configured with [Second]. +func (s *SpecSchedule) String() string { + return strings.Join([]string{ + fieldString(s.Second, secondBounds()), + fieldString(s.Minute, minuteBounds()), + fieldString(s.Hour, hourBounds()), + fieldString(s.Dom, dayOfMonthBounds()), + fieldString(s.Month, monthBounds()), + fieldString(s.Dow, dayOfWeekBounds()), + }, " ") +} + type nextActivationState struct { schedule *SpecSchedule added bool @@ -355,3 +374,27 @@ func dayMatches(s *SpecSchedule, candidate time.Time) bool { func hasBit(bitset uint64, position int) bool { return uint64(1)< 0 } + +// fieldString renders a single cron field from its bitset representation. +func fieldString(bitset uint64, bnd bounds) string { + allBits := getBits(bnd.min, bnd.max, 1) + if bitset&^starBit == allBits { + return "*" + } + + return enumerateBits(bitset, bnd) +} + +// enumerateBits returns a comma-separated list of the set bit positions within +// the given bounds. +func enumerateBits(bitset uint64, bnd bounds) string { + var parts []string + + for idx := bnd.min; idx <= bnd.max; idx++ { + if hasBit(bitset, int(idx)) { //nolint:gosec // G115: idx is bounded by [0,63] + parts = append(parts, strconv.FormatUint(uint64(idx), 10)) + } + } + + return strings.Join(parts, ",") +} diff --git a/spec_test.go b/spec_test.go index 16a8894..f11b5cc 100644 --- a/spec_test.go +++ b/spec_test.go @@ -343,6 +343,89 @@ func nextRunFallBackInputCases() []nextRunTestCase { } } +func TestSpecScheduleString(t *testing.T) { + t.Parallel() + + tests := []struct { + expr string + expected string + }{ + {"* * * * * *", "* * * * * *"}, + {"5 * * * * *", "5 * * * * *"}, + {"0,30 * * * * *", "0,30 * * * * *"}, + {"0 0 1,15 * * *", "0 0 1,15 * * *"}, + {"0 0 0 1 1 *", "0 0 0 1 1 *"}, + } + + parser := NewSpecParser(Second | Minute | Hour | Dom | Month | Dow | Descriptor) + + for _, tt := range tests { + sched, err := parser.Parse(tt.expr) + if err != nil { + t.Fatalf("parse %q: %v", tt.expr, err) + } + + spec, ok := sched.(*SpecSchedule) + if !ok { + t.Fatalf("expected *SpecSchedule, got %T", sched) + } + + got := spec.String() + if got != tt.expected { + t.Errorf("String() for %q: got %q, want %q", tt.expr, got, tt.expected) + } + } +} + +func TestSpecScheduleStringRoundTrip(t *testing.T) { + t.Parallel() + + expressions := []string{ + "* * * * * *", + "5 * * * * *", + "0 0 * * * *", + "0 0 0 1 1 *", + "0 0,30 * * * *", + "0 0 9 * * 1,3,5", + } + + parser := NewSpecParser(Second | Minute | Hour | Dom | Month | Dow | Descriptor) + anchor := baseTime + + for _, expr := range expressions { + sched1, err := parser.Parse(expr) + if err != nil { + t.Fatalf("parse %q: %v", expr, err) + } + + spec1, ok := sched1.(*SpecSchedule) + if !ok { + t.Fatalf("expected *SpecSchedule, got %T", sched1) + } + + rendered := spec1.String() + + sched2, err := parser.Parse(rendered) + if err != nil { + t.Fatalf("re-parse %q (from %q): %v", rendered, expr, err) + } + + // Verify the next 10 fire times match. + cursor1, cursor2 := anchor, anchor + + for idx := range 10 { + cursor1 = sched1.Next(cursor1) + cursor2 = sched2.Next(cursor2) + + if !cursor1.Equal(cursor2) { + t.Errorf("%q round-trip mismatch at fire %d: %v != %v", expr, idx, cursor1, cursor2) + + break + } + } + } +} + func nextRunEdgeCases() []nextRunTestCase { return []nextRunTestCase{ {julyNinthLateNight, "0 0 0 30 Feb ?", ""}, From 73d02f5b9c35d0d51fe05dfa770d925c1ef1ddb0 Mon Sep 17 00:00:00 2001 From: "F." Date: Fri, 10 Apr 2026 10:24:58 +0200 Subject: [PATCH 2/3] fix: gosec integer overflow issue --- spec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec.go b/spec.go index 10f213f..8bf3538 100644 --- a/spec.go +++ b/spec.go @@ -391,7 +391,7 @@ func enumerateBits(bitset uint64, bnd bounds) string { var parts []string for idx := bnd.min; idx <= bnd.max; idx++ { - if hasBit(bitset, int(idx)) { //nolint:gosec // G115: idx is bounded by [0,63] + if bitset&(uint64(1)< Date: Fri, 10 Apr 2026 10:39:15 +0200 Subject: [PATCH 3/3] fix(scheduler): recover from hook panics and clamp invalid inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap event-hook invocations (OnJobStart, OnJobComplete, OnError) in a new safeCallHook helper that uses defer/recover, preventing panicking callbacks from crashing the scheduler. Extract executeJob from startJob for clearer separation of concerns. Clamp MaxConcurrent limit to ≥1 and RetryOnError maxRetries to ≥0 to guard against invalid arguments. Add comprehensive tests for event hooks, error callback behaviour, and hook-panic resilience. Introduce mustAddNamedFunc test helper. Add GitHub Sponsors funding configuration. --- .github/FUNDING.yml | 4 + chain.go | 4 + cron.go | 34 +++++++-- cron_test.go | 176 ++++++++++++++++++++++++++++++++++++++++++++ example_test.go | 2 +- helpers_test.go | 11 +++ 6 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e9b0c4e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +--- +# These are supported funding model platforms + +github: [hyp3rd] diff --git a/chain.go b/chain.go index 7f93b9d..b7459f7 100644 --- a/chain.go +++ b/chain.go @@ -124,6 +124,8 @@ func SkipIfStillRunning(logger *slog.Logger) JobWrapper { // It generalizes [SkipIfStillRunning], which is equivalent to MaxConcurrent // with a limit of 1. func MaxConcurrent(limit int, logger *slog.Logger) JobWrapper { + limit = max(limit, 1) + return func(job Job) Job { sem := make(chan struct{}, limit) @@ -170,6 +172,8 @@ func Timeout(duration time.Duration) JobWrapper { // // The backoff uses real wall-clock time, not the scheduler's [Clock] interface. func RetryOnError(maxRetries int, backoff time.Duration) JobWrapper { + maxRetries = max(maxRetries, 0) + return func(job Job) Job { return FuncJob(func(ctx context.Context) error { return executeWithRetry(ctx, job, maxRetries, backoff) diff --git a/cron.go b/cron.go index 0a4aa4f..251de37 100644 --- a/cron.go +++ b/cron.go @@ -528,29 +528,51 @@ func (c *Cron) handleEntryRemoved(id EntryID) time.Time { // startJob runs the entry's wrapped job in a new goroutine, firing any // configured [EventHooks] and [ErrorFunc]. Non-nil errors are logged at Warn -// level and do not affect future executions. +// level and do not affect future executions. Hook panics are recovered and +// logged so that observability callbacks cannot crash the scheduler. func (c *Cron) startJob(ctx context.Context, entry *Entry) { c.jobWaiter.Go(func() { + c.executeJob(ctx, entry) + }) +} + +func (c *Cron) executeJob(ctx context.Context, entry *Entry) { + c.safeCallHook(func() { if c.hooks.OnJobStart != nil { c.hooks.OnJobStart(entry.ID, entry.Name) } + }) - start := time.Now() + start := time.Now() - err := entry.wrappedJob.Run(ctx) + err := entry.wrappedJob.Run(ctx) + c.safeCallHook(func() { if c.hooks.OnJobComplete != nil { c.hooks.OnJobComplete(entry.ID, entry.Name, time.Since(start), err) } + }) - if err != nil { - c.logger.Warn("job error", "err", err, "entry", entry.ID, "name", entry.Name) + if err != nil { + c.logger.Warn("job error", "err", err, "entry", entry.ID, "name", entry.Name) + c.safeCallHook(func() { if c.onError != nil { c.onError(entry.ID, entry.Name, err) } + }) + } +} + +// safeCallHook calls fn and recovers from any panic, logging it as an error. +func (c *Cron) safeCallHook(fn func()) { + defer func() { + if recovered := recover(); recovered != nil { + c.logger.Error("hook panic", "recovered", recovered) } - }) + }() + + fn() } // now returns current time in c location. diff --git a/cron_test.go b/cron_test.go index c4b4d7d..6db24a3 100644 --- a/cron_test.go +++ b/cron_test.go @@ -953,3 +953,179 @@ func signalJobStarted(started chan<- struct{}) { default: } } + +func TestEventHooksOnSuccess(t *testing.T) { + t.Parallel() + + var ( + startID atomic.Int64 + startName atomic.Value + compID atomic.Int64 + compName atomic.Value + ) + + wg := &sync.WaitGroup{} + wg.Add(1) + + fc := newFakeClock(baseTime) + cron := New( + WithParser(testParserWithSeconds()), + WithChain(), + WithClock(fc), + WithEventHooks(EventHooks{ + OnJobStart: func(id EntryID, name string) { + startID.Store(int64(id)) + startName.Store(name) + }, + OnJobComplete: func(id EntryID, name string, _ time.Duration, _ error) { + compID.Store(int64(id)) + compName.Store(name) + }, + }), + ) + + mustAddNamedFunc(t, cron, "test-job", everySecondSpec, done(wg)) + + startCron(t, cron) + fc.BlockUntilTimers(1) + fc.Advance(1 * time.Second) + awaitWg(t, wg) + + if startID.Load() != 1 { + t.Errorf("OnJobStart: expected id=1, got %d", startID.Load()) + } + + if startName.Load() != "test-job" { + t.Errorf("OnJobStart: expected name=test-job, got %v", startName.Load()) + } + + if compID.Load() != 1 { + t.Errorf("OnJobComplete: expected id=1, got %d", compID.Load()) + } + + if compName.Load() != "test-job" { + t.Errorf("OnJobComplete: expected name=test-job, got %v", compName.Load()) + } +} + +func TestOnErrorCallback(t *testing.T) { + t.Parallel() + + var ( + cbID atomic.Int64 + cbName atomic.Value + cbErr atomic.Value + ) + + wg := &sync.WaitGroup{} + wg.Add(1) + + fc := newFakeClock(baseTime) + cron := New( + WithParser(testParserWithSeconds()), + WithChain(), + WithClock(fc), + WithLogger(DiscardLogger()), + WithOnError(func(id EntryID, name string, err error) { + cbID.Store(int64(id)) + cbName.Store(name) + cbErr.Store(err.Error()) + wg.Done() + }), + ) + + mustAddNamedFunc(t, cron, "failing", everySecondSpec, func(_ context.Context) error { + return errors.New("boom") //nolint:err113 // test error + }) + + startCron(t, cron) + fc.BlockUntilTimers(1) + fc.Advance(1 * time.Second) + awaitWg(t, wg) + + if cbID.Load() != 1 { + t.Errorf("OnError: expected id=1, got %d", cbID.Load()) + } + + if cbName.Load() != "failing" { + t.Errorf("OnError: expected name=failing, got %v", cbName.Load()) + } + + if cbErr.Load() != "boom" { + t.Errorf("OnError: expected err=boom, got %v", cbErr.Load()) + } +} + +func TestOnErrorNotCalledOnSuccess(t *testing.T) { + t.Parallel() + + var errorCalled atomic.Bool + + wg := &sync.WaitGroup{} + wg.Add(1) + + fc := newFakeClock(baseTime) + cron := New( + WithParser(testParserWithSeconds()), + WithChain(), + WithClock(fc), + WithOnError(func(_ EntryID, _ string, _ error) { + errorCalled.Store(true) + }), + ) + + mustAddFunc(t, cron, everySecondSpec, done(wg)) + + startCron(t, cron) + fc.BlockUntilTimers(1) + fc.Advance(1 * time.Second) + awaitWg(t, wg) + + // Small yield to ensure callback would have fired. + time.Sleep(5 * time.Millisecond) + + if errorCalled.Load() { + t.Error("OnError should not be called on successful job") + } +} + +func TestHookPanicDoesNotCrashScheduler(t *testing.T) { + t.Parallel() + + wg := &sync.WaitGroup{} + wg.Add(1) + + fc := newFakeClock(baseTime) + cron := New( + WithParser(testParserWithSeconds()), + WithChain(), + WithClock(fc), + WithLogger(DiscardLogger()), + WithEventHooks(EventHooks{ + OnJobStart: func(_ EntryID, _ string) { + panic("start hook panic") + }, + OnJobComplete: func(_ EntryID, _ string, _ time.Duration, _ error) { + panic("complete hook panic") + }, + }), + WithOnError(func(_ EntryID, _ string, _ error) { + panic("error hook panic") + }), + ) + + mustAddNamedFunc(t, cron, "panicky-hooks", everySecondSpec, func(_ context.Context) error { + wg.Done() + + return errors.New("trigger onError") //nolint:err113 // test error + }) + + startCron(t, cron) + fc.BlockUntilTimers(1) + fc.Advance(1 * time.Second) + awaitWg(t, wg) + + // If we reach here, the scheduler survived all hook panics. + // Small yield to let the hook goroutine complete. + time.Sleep(10 * time.Millisecond) +} diff --git a/example_test.go b/example_test.go index 3f078db..50f8826 100644 --- a/example_test.go +++ b/example_test.go @@ -70,7 +70,7 @@ func ExampleCron_AddNamedFunc() { func ExampleNextN() { sched, _ := cron.ParseStandard(exampleEveryHour) - //nolint:revive // example uses fixed date and count for deterministic output + //nolint:revive // example uses fixed date for deterministic output anchor := time.Date(2024, 6, 3, 12, 0, 0, 0, time.UTC) times := cron.NextN(sched, anchor, exampleNextNCount) diff --git a/helpers_test.go b/helpers_test.go index 96b9584..f608a49 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -21,6 +21,17 @@ func mustAddFunc(t *testing.T, cron *Cron, spec string, cmd func(context.Context return entryID } +func mustAddNamedFunc(t *testing.T, cron *Cron, name, spec string, cmd func(context.Context) error) EntryID { + t.Helper() + + entryID, err := cron.AddNamedFunc(name, spec, cmd) + if err != nil { + t.Fatalf("add named func %q (%s): %v", name, spec, err) + } + + return entryID +} + func mustAddJob(t *testing.T, cron *Cron, spec string, job Job) EntryID { t.Helper()