A context-aware job scheduler for Go with explicit lifecycle control, missed-fire policies, typed workflows and optional distributed coordination. Requires Go 1.27.
c := cron.MustNew(cron.WithLocation(time.UTC))
_, err := c.Add("0 9 * * MON-FRI", cron.JobFunc(func(ctx context.Context) error {
return sendDigest(ctx)
}), cron.WithName("digest"))
if err != nil {
log.Fatal(err)
}
if err := c.Start(); err != nil {
log.Fatal(err)
}
defer func() {
if err := c.Stop(context.Background()); err != nil {
log.Printf("stop cron: %v", err)
}
}()go get github.com/libtnb/cronpackage main
import (
"context"
"fmt"
"log"
"os/signal"
"syscall"
"time"
"github.com/libtnb/cron"
)
func main() {
c := cron.MustNew(cron.WithLocation(time.UTC))
_, err := c.Add("@every 5s", cron.JobFunc(func(ctx context.Context) error {
fmt.Println("tick", time.Now().Format(time.RFC3339))
return nil
}), cron.WithName("heartbeat"))
if err != nil {
log.Fatal(err)
}
if err := c.Start(); err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.Stop(shutdownCtx); err != nil {
log.Printf("stop: %v", err)
}
}Runnable programs for seconds fields, Quartz tokens, structured logging and workflows live in _examples; executable Example functions are on pkg.go.dev.
New/MustNewbuild a scheduler from options; nothing fires untilStart.Stop(ctx)cancels in-flight jobs (context causeErrCronStopping) and waits for the loop, the jobs and the observer queue;Drain(ctx)stops scheduling and lets running jobs finish. Both are bounded byctxand returnctx.Err()on timeout.- A stopped scheduler cannot be restarted:
StartreturnsErrSchedulerStopped. WithBaseContextties the run context to a parent;WithMaxConcurrentcaps in-flight jobs (excess automatic fires are rejected, not queued);WithMaxEntriescaps registrations.- The loop goroutine never runs user code:
Schedule.Next, coordination calls and jobs run on their own goroutines, so a slow schedule or job delays only its own entry.
Add (cron spec) and AddSchedule (programmatic Schedule) return a stable EntryID. Entries can be inspected (Entry, Entries), paused, resumed, updated (Update, UpdateSchedule), removed and triggered (Trigger, TriggerAndWait, TriggerByName). Remove, Pause and Resume return ErrEntryNotFound for unknown IDs.
id, err := c.Add("*/15 * * * *", job,
cron.WithName("sync"),
cron.WithTimeout(time.Minute), // ErrJobTimeout as the cancel cause
cron.WithEntryRetry(cron.Retry(3)), // exponential backoff, 1s initial delay
cron.WithEntryChain(wrap.SkipIfRunning()), // drop overlapping runs
)Scheduler-wide defaults (WithChain, WithRetry, WithJitter, WithMissedFire, WithClaimer) can be overridden per entry (WithEntryChain, WithEntryRetry, WithEntryJitter, WithEntryMissedFire, WithEntryClaimer). Jobs read their identity with EntryInfoFromContext(ctx). Panics are recovered into ErrJobPanic unless WithoutRecover is set.
The built-in parser accepts five fields (minute hour dom month dow), names (JAN, MON-FRI), ranges and steps (1-5, */10), descriptors (@hourly, @daily, @weekly, @monthly, @yearly, @every 90s) and a TZ= / CRON_TZ= prefix. WithSecondsField adds an optional leading seconds field. NewStandardParser exposes the same grammar standalone with WithOptionalSeconds, WithRequiredSeconds, WithDefaultLocation and WithParserExt. Day-of-month and day-of-week follow the classic rule: when either is *, both must match; otherwise either may.
Programmatic schedules: ConstantDelay (process-anchored interval), AlignedDelay (epoch-aligned, identical across replicas), OnceAt, TriggeredSchedule (manual only), Union and Filter. Any type implementing Schedule works; Next must return a time strictly after its argument and be safe for concurrent use.
Quartz day tokens (L, L-n, LW, nW, N#M, NL) live in parserext; numeric day-of-week stays cron-style 0-6, Sunday first:
c := cron.MustNew(cron.WithParser(parserext.NewQuartzParser(time.UTC)))
_, err := c.Add("0 0 22 ? * 5L", job) // 22:00 on the last Friday of each monthIntrospection: ValidateSpec, AnalyzeSpec (descriptor, interval, location, next run), NextN and Between.
s, _ := cron.NewStandardParser(cron.WithDefaultLocation(time.UTC)).Parse("0 9 * * MON-FRI")
for _, t := range cron.NextN(s, time.Now(), 3) {
fmt.Println(t)
}A fire that runs later than WithMissedTolerance (default: one minute) invokes the entry's missed-fire policy and publishes a MissedFireEvent:
| Policy | Behaviour |
|---|---|
MissedRunOnce (default) |
runs the most recent missed instant once |
MissedRunAll |
replays every missed instant, newest 1000 at most |
MissedSkip |
drops the backlog and resumes from the next instant |
Catch-up bisects over Schedule.Next: MissedRunOnce costs about 60 Next calls however long the outage, and MissedRunAll walks only the newest window. Seed WithLastRun from persisted state to catch up across restarts:
_, err := c.Add("0 * * * *", job,
cron.WithLastRun(lastRunFromDB),
cron.WithEntryMissedFire(cron.MissedRunAll),
)c := cron.MustNew(cron.WithClaimer(claimer), cron.WithElector(elector))
_, err := c.Add("0 * * * *", job, cron.WithKey("hourly-report"))Claimer.Claimelects one replica per keyed fire. The key combinesWithKeywith the UTC scheduled instant, so replicas in different time zones agree.Elector.IsLeaderrestricts automatic fires to the current leader.false, nilis the normal contention or follower state; backend errors fail closed. Every suppressed fire publishes aSkippedFireEventwith aSkipReason.WithKeyis the stable cross-replica identity and is required when a claimer applies;WithNameis only a display label. Manual triggers bypass coordination.ConstantDelayis process-local; useAlignedDelayor a cron expression with a claimer.
Redis and PostgreSQL implementations are separate modules:
go get github.com/libtnb/cron/coordination/redis
go get github.com/libtnb/cron/coordination/postgresEvery scheduler activity publishes a typed Event: ScheduleEvent, JobStartEvent, JobCompleteEvent, MissedFireEvent, RejectedFireEvent, CanceledFireEvent, SkippedFireEvent, QueueDepthEvent (after every heap change, including each committed fire) and ObserverDropEvent.
WithObserversdelivers events asynchronously, in order, through one bounded queue (WithObserverBuffer, default 1024). A full queue drops events instead of blocking the scheduler.WithRecorderreceives every event inline on the publishing goroutine; it must be concurrency-safe and fast. Panics in observers and recorders are recovered and logged.
c := cron.MustNew(cron.WithObservers(cron.ObserverFunc(func(ev cron.Event) {
if done, ok := ev.(cron.JobCompleteEvent); ok && done.Err != nil {
log.Printf("%s failed after %s: %v", done.Entry.Name, done.Duration, done.Err)
}
})))workflow builds typed DAGs that run as a single cron.Job. Go 1.27 generic methods keep the data flow between steps type-safe:
b := workflow.New(workflow.WithMaxParallelism(8))
download := b.Step[[]byte]("download", func(ctx context.Context, _ workflow.Inputs) ([]byte, error) {
return fetch(ctx)
})
b.Step[int]("store", func(ctx context.Context, in workflow.Inputs) (int, error) {
data, ok := in.Get(download)
if !ok {
return 0, errors.New("download output unavailable")
}
return save(ctx, data)
}, workflow.After(download, workflow.OnSuccess))
wf, err := b.Build() // validates names, dependencies and cycles; freezes the builderDependencies use OnSuccess, OnFailure, OnSkipped or OnComplete; a step whose conditions are not met is skipped. Steps accept WithTimeout and WithRetry. Execute returns an Execution with per-step results, typed outputs (Get) and a joined error; at most 32 steps run concurrently by default.
wrap supplies cron.Wrapper decorators: Recover, Timeout, Retry, SkipIfRunning and DelayIfRunning. Install them globally with WithChain or per entry with WithEntryChain; Chain composes wrappers with the first outermost. Retry policies (Retry(n, RetryInitial(...), RetryMaxDelay(...), RetryMultiplier(...), RetryJitterFrac(...))) join every attempt's error and stop on context cancellation.
| Module | Purpose |
|---|---|
github.com/libtnb/cron/contrib/prometheus |
cron.Recorder exposing job counters, duration and lateness histograms, queue depth and dropped events |
github.com/libtnb/cron/contrib/otel |
cron.Wrapper tracing each invocation as an OpenTelemetry span |
| Package | Purpose |
|---|---|
cron |
Scheduler, parser, lifecycle, events |
cron/workflow |
Typed bounded DAG executor |
cron/wrap |
Job wrappers |
cron/parserext |
Quartz day tokens |
cron/coordination/redis |
Redis claimer and elector (separate module) |
cron/coordination/postgres |
PostgreSQL claimer and elector (separate module) |
cron/contrib/prometheus |
Prometheus recorder (separate module) |
cron/contrib/otel |
OpenTelemetry wrapper (separate module) |
Please read the contributing guide before submitting a PR.
This project is licensed under the MIT License - see the LICENSE file for details.