Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
# These are supported funding model platforms

github: [hyp3rd]
65 changes: 0 additions & 65 deletions .github/workflows/codacy.yml

This file was deleted.

8 changes: 8 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
103 changes: 103 additions & 0 deletions chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,106 @@ 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 {
limit = max(limit, 1)

return func(job Job) Job {
sem := make(chan struct{}, limit)

for range limit {
sem <- struct{}{}
}
Comment on lines +126 to +134
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxConcurrent panics if limit is negative because make(chan struct{}, limit) requires a non-negative capacity. Consider guarding/clamping limit (e.g., treat limit < 1 as 1, or return a wrapper that always skips) so callers can't crash the scheduler by passing a bad limit.

Copilot uses AI. Check for mistakes.

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 {
maxRetries = max(maxRetries, 0)

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
}
Comment on lines +184 to +191
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RetryOnError with a negative maxRetries currently results in the wrapped job never running (the for attempt := range maxRetries + 1 loop does zero iterations) and returns nil. It would be safer to clamp maxRetries to 0 (meaning “run once, no retries”) so invalid input can’t silently skip execution.

Copilot uses AI. Check for mistakes.

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
}
}
Loading
Loading