A modern, focused Go cron scheduler with no third-party dependencies.
- Standard 5-field cron expressions plus
@hourly/@daily/@every 10sdescriptors and a per-specTZ=prefix. - Optional seconds field.
WithSeconds()accepts both 5 and 6 fields;WithSeconds(true)requires 6. - Quartz tokens (
L,N#M,NL) via theparserextsubpackage. - DAG jobs with conditional dependencies via the
workflowsubpackage. - Job wrappers in
wrap:Recover,Timeout,Retry,SkipIfRunning,DelayIfRunning. - Per-event hooks and recorders so you can plug in metrics and tracing.
- Missed-fire policies (
MissedSkip,MissedRunOnce) with a configurable tolerance window, so a restarted process can catch up exactly once. - Manual
TriggerandTriggerByName, with concurrency and entry limits. - DST-aware. Per-entry timeout, jitter, retry, name, and chain.
go get github.com/libtnb/cronRequires Go 1.25+ (uses iter.Seq, sync.WaitGroup.Go, and slog).
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)
}| 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). |
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.
To use seconds, configure the parser:
// 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)))c := cron.New(
cron.WithLocation(time.UTC),
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)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. Useful when the process was restarted and you want the job to catch up exactly once.
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):
}
}
count, err := c.TriggerByName("daily-digest") // err joins per-entry failures
c.Remove(id) // false if id is unknownRemove blocks future automatic fires and future Trigger calls for that
entry. Jobs already dispatched keep running. Stop halts the loop and
waits for in-flight jobs and the hook dispatcher, capped by the context.
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 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.
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),
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.
parserext.NewQuartzParser accepts standard 5/6-field specs and adds
L (last day of month), N#M (Nth weekday of month), and NL (last
weekday of month).
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 ? * 5#3", standupJob) // third Friday
_, _ = c.Add("0 30 22 ? * 5L", payrollJob) // last Friday? is accepted in the day-of-month and day-of-week fields per the Quartz
convention.
| robfig/cron | libtnb/cron |
|---|---|
cron.New(cron.WithSeconds()) |
cron.New(cron.WithParser(cron.NewStandardParser(cron.WithSeconds()))) |
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] |