cron
July 7, 2026 · View on GitHub
A modern, focused Go cron scheduler with no third-party dependencies.
Features
- Standard 5-field cron expressions plus
@hourly/@daily/@every 10sdescriptors, a per-specTZ=prefix, and POSIX7as Sunday. - Optional seconds field via
WithSecondsField()(or a custom parser). - Quartz tokens (
L,L-n,LW,nW,N#M,NL, names likeFRI#3) via theparserextsubpackage. - Schedule combinators:
OnceAt,Union, andFilter(calendar exclusions). - DAG jobs with conditional dependencies, data flow between steps, and
per-step timeout/retry via the
workflowsubpackage. - Job wrappers in
wrap:Recover,Timeout,Retry,SkipIfRunning,DelayIfRunning. Job panics are recovered by default (ErrJobPanic). - Per-event hooks and recorders so you can plug in metrics and tracing.
- Missed-fire policies (
MissedSkip,MissedRunOnce,MissedRunAll) with a configurable tolerance window, overridable per entry;WithLastRunseeds cross-restart catch-up. - Lifecycle control:
Pause/Resume, in-placeUpdate, gracefulDrainor cancellingStop. - Distributed coordination:
Locker(exactly-once per fire, clock-skew-proof fire-scoped keys) andElector(leader-only scheduling), with Redis and Postgres backends as separate zero-impact modules. - Manual
Trigger,TriggerAndWait, andTriggerByName, with concurrency and entry limits. - DST-correct scheduling (differentially fuzzed against a wall-clock scan). Per-entry timeout, jitter, retry, missed-fire policy, name, and chain.
Install
go get github.com/libtnb/cron
Requires Go 1.25+ (uses iter.Seq, sync.WaitGroup.Go, and slog).
Quick start
package main
import (
"context"
"fmt"
"log/slog"
"os/signal"
"syscall"
"time"
"github.com/libtnb/cron"
"github.com/libtnb/cron/wrap"
)
func main() {
// Cancel on SIGINT / SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Build a scheduler. Wrappers in WithChain apply to every entry.
c := cron.New(
cron.WithLogger(slog.Default()),
cron.WithChain(wrap.Recover(), wrap.Timeout(30*time.Second)),
)
// Add a job. Add returns an EntryID and a parse error (if any).
_, err := c.Add("@every 5s", cron.JobFunc(func(ctx context.Context) error {
fmt.Println("tick", time.Now())
return nil
}), cron.WithName("heartbeat"))
if err != nil {
panic(err)
}
// Start the loop. Idempotent while running.
if err := c.Start(); err != nil {
panic(err)
}
<-ctx.Done()
// Drain in-flight jobs and hooks. The deadline caps the wait.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = c.Stop(shutdownCtx)
}
Subpackages
| Path | Purpose |
|---|---|
github.com/libtnb/cron | Scheduler, parser, schedules, hooks, recorders, retry policy. |
github.com/libtnb/cron/wrap | Job wrappers: Recover, Timeout, SkipIfRunning, DelayIfRunning, Retry. |
github.com/libtnb/cron/workflow | DAG jobs with OnSuccess, OnFailure, OnSkipped, OnComplete. |
github.com/libtnb/cron/parserext | Quartz tokens (L, N#M, NL). |
github.com/libtnb/cron/lockers/redis | Redis Locker/Elector (separate module). |
github.com/libtnb/cron/lockers/postgres | Postgres Locker/Elector (separate module). |
github.com/libtnb/cron/contrib/prometheus | Prometheus metrics recorder (separate module). |
github.com/libtnb/cron/contrib/otel | OpenTelemetry tracing wrapper (separate module). |
Cron expressions
The default parser takes five fields:
minute hour day-of-month month day-of-week
Names are accepted (mon, MON, mon-fri, jan). Step (*/5), range
(1-5), list (15,45), and combinations are supported. A spec may carry a
TZ=Europe/Berlin or CRON_TZ=... prefix to override the scheduler's
timezone for that entry.
The descriptors @yearly, @monthly, @weekly, @daily, @midnight,
@hourly, and @every <duration> are also accepted. @every 90s is the
canonical fixed-interval form; the interval has a 1s floor.
To use seconds, enable it on the built-in parser (keeps WithLocation working):
cron.WithSecondsField()
Or install a custom parser, which then owns the timezone:
// Optional seconds: 5- and 6-field specs both parse.
cron.WithParser(cron.NewStandardParser(cron.WithSeconds()))
// Strict: 6 fields required.
cron.WithParser(cron.NewStandardParser(cron.WithSeconds(true)))
Building a scheduler
c := cron.New(
cron.WithLocation(time.UTC),
cron.WithSecondsField(), // the spec below has a seconds field
cron.WithMissedFire(cron.MissedRunOnce),
cron.WithMaxConcurrent(32),
cron.WithRetry(cron.Retry(3, cron.RetryInitial(time.Second))),
)
id, err := c.Add(
"0 0 9 * * *",
emailJob,
cron.WithName("daily-digest"),
cron.WithTimeout(time.Minute),
)
AddSchedule registers a programmatic Schedule instead of a string:
id, err := c.AddSchedule(cron.ConstantDelay(time.Hour), job)
Built-in schedules compose:
c.AddSchedule(cron.OnceAt(deployTime), job) // fire exactly once
c.AddSchedule(cron.Union(weekdayNine, weekendTen), job)
c.AddSchedule(cron.Filter(daily, notHoliday), job) // skip filtered firings
Entries can be paused, resumed, and updated in place — the ID and Prev
survive; Update re-parses the spec, UpdateSchedule swaps a programmatic
schedule:
c.Pause(id) // manual Trigger still works while paused
c.Resume(id) // reschedules from now
err = c.Update(id, "*/10 * * * *")
Missed fires
When a firing runs more than WithMissedTolerance (default 1s) late,
WithMissedFire decides what to do:
MissedSkip(default) drops the missed firing and waits for the next scheduled time.MissedRunOnceruns the job once at the most recent missed time, then resumes the regular schedule.MissedRunAllruns the job once per missed firing (the newest 1000 are kept), then resumes.
The policy can be overridden per entry with WithEntryMissedFire, and jitter
with WithEntryJitter. By themselves the policies cover in-process stalls
(VM suspend, clock jumps, a backlog while the loop was blocked). To catch up
across restarts, persist the last run time and seed it back:
c.Add("0 2 * * *", job,
cron.WithLastRun(lastRunFromDB), // first fire computes from here
cron.WithEntryMissedFire(cron.MissedRunOnce)) // -> the 02:00 you missed runs once
Distributed coordination (multi-instance)
Running the same scheduler on N instances runs every job N times. Two first-class primitives fix that; the core stays dependency-free and backends live in separate modules:
go get github.com/libtnb/cron/lockers/redis # package redislock
go get github.com/libtnb/cron/lockers/postgres # package pglock
Locker — exactly once per fire. Every automatic fire claims the key
<name>@<scheduledAt-unixnano>; one instance in the fleet wins, the rest skip
and emit EventSkipped. Because the key identifies the fire (not the job),
dedup depends on neither lock hold time nor clock agreement, and catch-up
fires (MissedRunOnce/MissedRunAll) each claim their own key. Claims expire
via TTL (default 10m) — set it to exceed your jitter plus clock skew.
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
c := cron.New(cron.WithLocker(redislock.NewLocker(client)))
// Locked entries must be named (ErrLockerRequiresName otherwise): the name
// is the cross-instance key component, so keep it unique per job.
c.Add("0 * * * *", job, cron.WithName("hourly-report"))
Elector — one active instance. IsLeader gates every fire; on any error
(including backend outage) the fire is skipped — fail-closed, nobody runs.
c := cron.New(cron.WithElector(redislock.NewElector(client)))
Postgres works through database/sql with any driver; create the tables once
with pglock.Migrate(ctx, db). Table names are customizable via
pglock.MigrateTables plus WithLocksTable/WithLeaderTable.
- Use schedules whose instants match across instances: cron expressions,
OnceAt, orAlignedDelay.@every's phase is per-process (the scheduler warns). - Override per entry with
WithEntryLocker(l);WithEntryLocker(nil)opts an entry out of the global locker. - Manual
Trigger/TriggerAndWaitbypass coordination: manual means "run it here, now". - Observe skips with
SkipHook(EventSkipped:not-leader,lock-held, orlock-error) andJobSkippedRecorder. cron.MemoryLocker/cron.MemoryElector(in the core, dependency-free) serve tests and single-process composition.
Triggering and removal
Trigger runs the job immediately. The returned error tells the caller why
dispatch was rejected:
if err := c.Trigger(id); err != nil {
switch {
case errors.Is(err, cron.ErrEntryNotFound):
case errors.Is(err, cron.ErrSchedulerNotRunning):
case errors.Is(err, cron.ErrConcurrencyLimit):
}
}
err = c.TriggerAndWait(ctx, id) // blocks and returns the job's error
count, err := c.TriggerByName("daily-digest") // err joins per-entry failures
c.Remove(id) // false if id is unknown
Remove blocks future automatic fires and future Trigger calls for that
entry. Jobs already dispatched keep running. Two shutdown modes:
Stop(ctx)cancels in-flight jobs (ErrCronStoppingas the cause) and waits for the loop, jobs, and hook dispatcher, capped by ctx.Drain(ctx)stops scheduling but lets in-flight jobs finish naturally.
Job panics are recovered into ErrJobPanic-wrapped errors by default, so one
bad job cannot crash the process; opt out with WithoutRecover().
Reading entries
Entry and Entries return copies and never block on the scheduler's
internal lock, so they are safe to call from a hot path (HTTP handler,
debug endpoint).
if entry, ok := c.Entry(id); ok {
fmt.Println(entry.Name, entry.Next)
}
for e := range c.Entries() {
fmt.Println(e.Name, e.Prev, e.Next)
}
NextN and Between operate on a Schedule directly, without a running
scheduler:
next := cron.NextN(schedule, time.Now(), 10)
window := cron.Between(schedule, start, end)
Hooks and recorders
Hooks and recorders are split per event so a subscriber implements only the methods it cares about:
- Hooks:
ScheduleHook,JobStartHook,JobCompleteHook,MissedHook. - Recorders:
JobScheduledRecorder,JobStartedRecorder,JobCompletedRecorder,JobMissedRecorder,QueueDepthRecorder,HookDroppedRecorder.
type metrics struct{}
// Implements JobCompleteHook only; the other 3 events are skipped automatically.
func (*metrics) OnJobComplete(e cron.EventJobComplete) {
// record duration, error, etc.
}
c := cron.New(cron.WithHooks(&metrics{}))
Hooks are delivered on a buffered channel and dropped when the buffer is
full. The size is configurable via WithHookBuffer and the drop count is
exposed through HookDroppedRecorder.
Recorders, unlike hooks, are not serialized: their methods are called inline and concurrently from job goroutines, the scheduler loop, and Add/Remove/Trigger callers. Implementations must be concurrency-safe and non-blocking.
Ready-made observability lives in separate contrib modules, so the core
stays dependency-free:
// go get github.com/libtnb/cron/contrib/prometheus
rec, _ := cronprom.New() // cron_jobs_{started,completed,missed,skipped}_total,
c := cron.New(cron.WithRecorder(rec)) // durations, lateness, queue depth...
// go get github.com/libtnb/cron/contrib/otel
c := cron.New(cron.WithChain(otelcron.Wrapper())) // one span per run, named
// after the entry via cron.EntryInfoFromContext, error status recorded
Jobs and wrappers can identify their own invocation through
cron.EntryInfoFromContext(ctx) (ID, Name, ScheduledAt).
Workflow DAGs
workflow.Workflow is a cron.Job, so a DAG can be scheduled with Add
or AddSchedule like any other job. workflow.New validates the graph
and returns an error (ErrDuplicateStep, ErrUnknownDep, ErrCycle);
workflow.MustNew panics on misconfiguration and is convenient for
static graphs.
w := workflow.MustNew(
workflow.NewStep("download", downloadJob).
WithTimeout(2*time.Minute).
WithRetry(cron.Retry(3, cron.RetryInitial(time.Second))),
workflow.NewStep("transform", transformJob,
workflow.After("download", workflow.OnSuccess)),
workflow.NewStep("notify_failure", notifyJob,
workflow.After("transform", workflow.OnFailure)),
)
_, _ = c.Add("@hourly", w, cron.WithName("etl"))
Conditions: OnSuccess, OnFailure, OnSkipped, OnComplete (any
terminal state). A step is skipped when one of its dependencies didn't
match the requested condition.
NewStepFunc steps pass data through the DAG — each receives its
dependencies' outputs and Execution.Steps reports outputs and timings:
extract := workflow.NewStepFunc("extract", func(ctx context.Context, _ workflow.Inputs) (any, error) {
return fetchRows(ctx)
})
load := workflow.NewStepFunc("load", func(ctx context.Context, in workflow.Inputs) (any, error) {
return nil, store(ctx, in["extract"].([]Row))
}, workflow.After("extract", workflow.OnSuccess))
w := workflow.MustNew(extract, load).WithOnComplete(func(e *workflow.Execution) {
log.Printf("run %s took %v", e.ID, e.Duration)
})
Quartz tokens
parserext.NewQuartzParser accepts standard 5/6-field specs and adds the
Quartz day tokens:
| Token | Field | Meaning |
|---|---|---|
L | dom | last day of the month |
L-3 | dom | 3 days before the last day |
LW | dom | last weekday of the month |
15W | dom | weekday nearest the 15th (never crosses month) |
5#3 | dow | third Friday (FRI#3 also works) |
5L | dow | last Friday (FRIL also works) |
c := cron.New(cron.WithParser(parserext.NewQuartzParser(time.UTC)))
_, _ = c.Add("0 0 18 L * ?", reportJob) // last day of every month
_, _ = c.Add("0 0 9 ? * FRI#3", standupJob) // third Friday
_, _ = c.Add("0 30 22 ? * FRIL", payrollJob) // last Friday
_, _ = c.Add("0 0 9 15W * ?", invoiceJob) // weekday nearest the 15th
? is accepted in the day-of-month and day-of-week fields per the Quartz
convention. Numeric day-of-week stays cron-style 0-6 Sunday-first (not
Quartz's 1-7); the name forms are unambiguous.
Migrating from robfig/cron
| robfig/cron | libtnb/cron |
|---|---|
cron.New(cron.WithSeconds()) | cron.New(cron.WithSecondsField()) |
Job.Run() | Job.Run(context.Context) error |
c.AddFunc(spec, func()) | c.Add(spec, cron.JobFunc(func(ctx) error { ... })) |
cron.WithLogger(custom) | cron.WithLogger(*slog.Logger) |
cron.Recover(logger) | wrap.Recover(wrap.WithLogger(logger)) |
cron.SkipIfStillRunning(logger) | wrap.SkipIfRunning() |
cron.DelayIfStillRunning(logger) | wrap.DelayIfRunning() |
c.Start() | c.Start() error |
c.Stop() | c.Stop(ctx) error |
c.Entries() | c.Entries() iter.Seq[Entry] |
Credits
License
See LICENSE.