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 for in-process stalls (VM suspend, clock jumps, backlog). - 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; 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)))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)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. This covers in-process stalls (VM suspend, clock jumps, a backlog while the loop was blocked) — not restarts: a fresh process has no record of firings missed while it was down.
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.
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.
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.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] |