cron

package module
v0.5.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 7, 2026 License: MIT Imports: 16 Imported by: 0

README

cron

Doc Go Release Test Report Card Stars License

A modern, focused Go cron scheduler with no third-party dependencies.

Features

  • Standard 5-field cron expressions plus @hourly / @daily / @every 10s descriptors, a per-spec TZ= prefix, and POSIX 7 as Sunday.
  • Optional seconds field via WithSecondsField() (or a custom parser).
  • Quartz tokens (L, L-n, LW, nW, N#M, NL, names like FRI#3) via the parserext subpackage.
  • Schedule combinators: OnceAt, Union, and Filter (calendar exclusions).
  • DAG jobs with conditional dependencies, data flow between steps, and per-step timeout/retry via the workflow subpackage.
  • 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; WithLastRun seeds cross-restart catch-up.
  • Lifecycle control: Pause/Resume, in-place Update, graceful Drain or cancelling Stop.
  • Distributed coordination: Locker (exactly-once per fire, clock-skew-proof fire-scoped keys) and Elector (leader-only scheduling), with Redis and Postgres backends as separate zero-impact modules.
  • Manual Trigger, TriggerAndWait, and TriggerByName, 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.
  • MissedRunOnce runs the job once at the most recent missed time, then resumes the regular schedule.
  • MissedRunAll runs 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, or AlignedDelay. @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/TriggerAndWait bypass coordination: manual means "run it here, now".
  • Observe skips with SkipHook (EventSkipped: not-leader, lock-held, or lock-error) and JobSkippedRecorder.
  • 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 (ErrCronStopping as 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.

Documentation

Overview

Package cron is a cron scheduler.

Register jobs with Add or AddSchedule, then call Start. Stop cancels the loop and waits for in-flight jobs, capped by its context.

Specs use five fields. WithSeconds adds an optional leading seconds field; WithSeconds(true) requires six.

Subpackages

  • wrap: job wrappers (Recover, Timeout, SkipIfRunning, DelayIfRunning, Retry).
  • workflow: DAG jobs with conditional dependencies.
  • parserext: Quartz tokens (L, N#M, NL).

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrCapacityReached     = errors.New("cron: capacity reached")
	ErrAlreadyRunning      = errors.New("cron: job already running")
	ErrJobTimeout          = errors.New("cron: job timeout")
	ErrCronStopping        = errors.New("cron: scheduler stopping")
	ErrEntryNotFound       = errors.New("cron: entry not found")
	ErrSchedulerNotRunning = errors.New("cron: scheduler not running")
	ErrConcurrencyLimit    = errors.New("cron: max concurrent reached")
	ErrSchedulerStopped    = errors.New("cron: scheduler stopped")
	ErrNilJob              = errors.New("cron: nil job")
	ErrNilSchedule         = errors.New("cron: nil schedule")
	ErrJobPanic            = errors.New("cron: job panicked")
	ErrLockHeld            = errors.New("cron: fire lock held by another instance")
	ErrNotLeader           = errors.New("cron: not leader")
	ErrLockerRequiresName  = errors.New("cron: distributed locker requires WithName")
)

Functions

func Between

func Between(s Schedule, start, end time.Time) []time.Time

Between returns every firing in (start, end].

func FireKey added in v0.3.0

func FireKey(name string, id EntryID, scheduledAt time.Time) string

FireKey is the coordination key for one fire of one entry: "<name>@<unix-nanos>". Nanosecond precision matches the scheduler's heap, so custom sub-second Schedules never collapse two legitimate fires into one claim. Including the scheduled time makes every fire — including each MissedRunOnce/MissedRunAll catch-up instant — its own claim, so deduplication depends on neither lock hold duration nor clock agreement between instances.

The name is the cross-instance component and is required for locked entries (see ErrLockerRequiresName): it must uniquely identify a job, and entries sharing a name share claims for coinciding instants. The "#<id>" fallback exists only for direct callers; the scheduler never registers a locked entry without a name.

func IsTriggered

func IsTriggered(s Schedule) bool

IsTriggered reports whether s came from TriggeredSchedule.

func NextN

func NextN(s Schedule, from time.Time, n int) []time.Time

NextN returns the next n firings strictly after from.

func ValidateSpec

func ValidateSpec(spec string) error

ValidateSpec returns nil iff spec parses with the standard parser.

func ValidateSpecWith

func ValidateSpecWith(spec string, p Parser) error

ValidateSpecWith is ValidateSpec with a custom parser.

Types

type AlignedDelay added in v0.5.1

type AlignedDelay time.Duration

AlignedDelay is a fixed-interval Schedule aligned to the Unix epoch: every instance computes identical fire instants, which makes it the right interval schedule under a distributed Locker. ConstantDelay, by contrast, anchors its phase at Add time, so staggered instances never share fire keys. Non-positive intervals never fire.

func (AlignedDelay) Next added in v0.5.1

func (d AlignedDelay) Next(now time.Time) time.Time

func (AlignedDelay) String added in v0.5.1

func (d AlignedDelay) String() string

type ConstantDelay

type ConstantDelay time.Duration

ConstantDelay is a fixed-interval Schedule. The interval has a 1s floor: sub-second durations fire every 1s. Whole-second intervals are aligned to the second boundary; fractional intervals (e.g. 1500ms) keep their exact period.

func (ConstantDelay) Next

func (d ConstantDelay) Next(now time.Time) time.Time

func (ConstantDelay) String

func (d ConstantDelay) String() string

type Cron

type Cron struct {
	// contains filtered or unexported fields
}

Cron is a job scheduler. Construct one with New, register jobs, then Start.

func New

func New(opts ...Option) *Cron

New constructs a Cron. It does not start scheduling until Start is called.

func (*Cron) Add

func (c *Cron) Add(spec string, j Job, opts ...EntryOption) (EntryID, error)

Add parses spec and registers j. It returns a *ParseError for invalid specs or ErrCapacityReached when WithMaxEntries rejects the registration.

func (*Cron) AddSchedule

func (c *Cron) AddSchedule(s Schedule, j Job, opts ...EntryOption) (EntryID, error)

AddSchedule registers j against a programmatic Schedule.

func (*Cron) Drain added in v0.3.0

func (c *Cron) Drain(ctx context.Context) error

Drain is Stop without cancelling in-flight jobs: it stops scheduling new fires and waits for running jobs to finish naturally, capped by ctx.

func (*Cron) Entries

func (c *Cron) Entries() iter.Seq[Entry]

Entries returns registered entry snapshots ordered by Next.

func (*Cron) Entry

func (c *Cron) Entry(id EntryID) (Entry, bool)

Entry returns the current snapshot for id.

func (*Cron) Pause added in v0.3.0

func (c *Cron) Pause(id EntryID) bool

Pause suspends automatic fires for id, keeping the entry and its Prev. Manual Trigger still works while paused. Returns false if id is unknown.

func (*Cron) Remove

func (c *Cron) Remove(id EntryID) bool

Remove deregisters id. In-flight invocations continue; future automatic fires and future Trigger calls for id are rejected.

func (*Cron) Resume added in v0.3.0

func (c *Cron) Resume(id EntryID) bool

Resume re-enables automatic fires for id, scheduling from now. Returns false if id is unknown.

func (*Cron) Running

func (c *Cron) Running() bool

Running reports whether the scheduler is running. It is observational; use Trigger's returned error for race-free dispatch decisions.

func (*Cron) Start

func (c *Cron) Start() error

Start launches the scheduler. It is idempotent while running and returns ErrSchedulerStopped after Stop has been called.

func (*Cron) Stop

func (c *Cron) Stop(ctx context.Context) error

Stop halts the scheduler, cancels in-flight jobs (ErrCronStopping as the cause), and waits for the loop, jobs and hook dispatcher to drain, capped by ctx. Returns ctx.Err() on timeout. Do not call it from inside a Job.

func (*Cron) Trigger

func (c *Cron) Trigger(id EntryID) error

Trigger fires id immediately, bypassing jitter. It returns ErrSchedulerNotRunning, ErrEntryNotFound, or ErrConcurrencyLimit when dispatch is rejected. Paused entries can still be triggered.

func (*Cron) TriggerAndWait added in v0.3.0

func (c *Cron) TriggerAndWait(ctx context.Context, id EntryID) error

TriggerAndWait fires id like Trigger and blocks until the invocation returns, yielding the job's error. ctx bounds only the wait; on ctx cancellation the job keeps running.

func (*Cron) TriggerByName

func (c *Cron) TriggerByName(name string) (int, error)

TriggerByName fires every entry whose Name matches name. Returns the successful dispatch count and errors.Join of per-Trigger failures. No match returns (0, nil); not running returns (0, ErrSchedulerNotRunning).

func (*Cron) Update added in v0.3.0

func (c *Cron) Update(id EntryID, spec string) error

Update re-parses spec and swaps id's schedule in place, keeping the job, entry options, ID, and Prev. The next fire is recomputed from now.

func (*Cron) UpdateSchedule added in v0.3.0

func (c *Cron) UpdateSchedule(id EntryID, s Schedule) error

UpdateSchedule is Update for a programmatic Schedule.

type Elector added in v0.3.0

type Elector interface {
	IsLeader(ctx context.Context) error
}

Elector reports whether this instance should run scheduled jobs. nil means leader: run. Any error — including backend failures — means skip (fail-closed: during an outage nobody fires). Backends return an error wrapping ErrNotLeader when another instance holds leadership.

type Entry

type Entry struct {
	ID       EntryID
	Name     string
	Spec     string // empty for AddSchedule entries
	Schedule Schedule
	Prev     time.Time // zero if never fired
	Next     time.Time // zero if exhausted, paused, or TriggeredSchedule
	Paused   bool
}

Entry is the public read-only view of a scheduled item. Safe to copy.

func (Entry) LogValue

func (e Entry) LogValue() slog.Value

func (Entry) Valid

func (e Entry) Valid() bool

Valid reports whether e refers to a registered entry. Zero is invalid.

type EntryID

type EntryID uint64

EntryID is an opaque, process-local identifier.

func (EntryID) LogValue

func (id EntryID) LogValue() slog.Value

func (EntryID) String

func (id EntryID) String() string

type EntryInfo added in v0.4.0

type EntryInfo struct {
	ID          EntryID
	Name        string
	ScheduledAt time.Time
}

EntryInfo identifies the running invocation inside a job's context.

func EntryInfoFromContext added in v0.4.0

func EntryInfoFromContext(ctx context.Context) (EntryInfo, bool)

EntryInfoFromContext returns the identity of the entry whose job is running under ctx. The scheduler injects it for every dispatch, so wrappers and jobs can tell which entry — and which fire — they serve.

type EntryOption

type EntryOption func(*entryConfig)

EntryOption configures one entry.

func WithEntryChain

func WithEntryChain(wrappers ...Wrapper) EntryOption

WithEntryChain installs per-entry wrappers inside the global chain.

func WithEntryJitter added in v0.3.0

func WithEntryJitter(max time.Duration) EntryOption

WithEntryJitter overrides the scheduler's jitter for one entry. Zero disables jitter for the entry.

func WithEntryLocker added in v0.3.0

func WithEntryLocker(l Locker) EntryOption

WithEntryLocker overrides the scheduler's Locker for one entry. An explicit nil disables distributed locking for the entry.

func WithEntryMissedFire added in v0.3.0

func WithEntryMissedFire(p MissedFirePolicy) EntryOption

WithEntryMissedFire overrides the scheduler's missed-fire policy for one entry.

func WithEntryRetry

func WithEntryRetry(p RetryPolicy) EntryOption

WithEntryRetry overrides the global retry for one entry. A zero policy disables retry for that entry.

func WithLastRun added in v0.3.0

func WithLastRun(t time.Time) EntryOption

WithLastRun seeds the entry's schedule anchor, usually the persisted time of the last run before a restart. The first fire is computed from t instead of now, so a missed-fire policy can catch up work missed while the process was down. It also seeds Entry.Prev.

func WithName

func WithName(name string) EntryOption

WithName labels an entry.

func WithTimeout

func WithTimeout(d time.Duration) EntryOption

WithTimeout caps a Job's runtime with ErrJobTimeout as the cancel cause.

type EventJobComplete

type EventJobComplete struct {
	EntryID     EntryID
	Name        string
	ScheduledAt time.Time
	FireAt      time.Time
	Duration    time.Duration
	Err         error
}

EventJobComplete is emitted after the chain returns. Err is the chain result.

type EventJobStart

type EventJobStart struct {
	EntryID     EntryID
	Name        string
	ScheduledAt time.Time
	FireAt      time.Time
}

EventJobStart is emitted just before the chain runs. ScheduledAt is the schedule-selected time; FireAt is the actual start time after jitter/queueing.

type EventMissed

type EventMissed struct {
	EntryID     EntryID
	Name        string
	ScheduledAt time.Time
	Lateness    time.Duration
	Policy      MissedFirePolicy
}

EventMissed is emitted for missed fires and MaxConcurrent rejections.

type EventSchedule

type EventSchedule struct {
	EntryID  EntryID
	Name     string
	Schedule Schedule
	Next     time.Time
}

EventSchedule is emitted when an entry is added or its next firing is recomputed after a fire.

type EventSkipped added in v0.3.0

type EventSkipped struct {
	EntryID     EntryID
	Name        string
	ScheduledAt time.Time
	Reason      SkipReason
	Err         error // wraps ErrLockHeld / ErrNotLeader, or the backend error
}

EventSkipped is emitted when distributed coordination suppresses a fire.

type HookDroppedRecorder

type HookDroppedRecorder interface{ HookDropped() }

type Job

type Job interface {
	Run(ctx context.Context) error
}

Job is the unit of work executed by the scheduler.

type JobCompleteHook

type JobCompleteHook interface{ OnJobComplete(EventJobComplete) }

type JobCompletedRecorder

type JobCompletedRecorder interface {
	JobCompleted(name string, dur time.Duration, err error)
}

type JobFunc

type JobFunc func(ctx context.Context) error

JobFunc adapts a function to Job.

Example
package main

import (
	"context"
	"fmt"

	"github.com/libtnb/cron"
)

func main() {
	j := cron.JobFunc(func(ctx context.Context) error {
		fmt.Println("hello")
		return nil
	})
	_ = j.Run(context.Background())
}
Output:
hello

func (JobFunc) Run

func (f JobFunc) Run(ctx context.Context) error

type JobMissedRecorder

type JobMissedRecorder interface {
	JobMissed(name string, lateness time.Duration)
}

type JobScheduledRecorder

type JobScheduledRecorder interface{ JobScheduled(name string) }

type JobSkippedRecorder added in v0.3.0

type JobSkippedRecorder interface {
	JobSkipped(name string, reason SkipReason)
}

type JobStartHook

type JobStartHook interface{ OnJobStart(EventJobStart) }

type JobStartedRecorder

type JobStartedRecorder interface{ JobStarted(name string) }

type Locker added in v0.3.0

type Locker interface {
	Lock(ctx context.Context, key string) (ReleaseFunc, error)
}

Locker coordinates fires across scheduler instances. Lock claims key (see FireKey); exactly one instance in the fleet succeeds per key. On failure it returns an error wrapping ErrLockHeld when another instance holds the claim, or any other error for backend failures — either way the fire is skipped on this instance (fail-closed) and EventSkipped is emitted.

Implementations own TTL and acquisition-timeout policy; the ctx passed in is the scheduler's run context (cancelled on Stop), not a per-call deadline. Implementations should retain a claim until its TTL even after release: deleting the key on release re-opens the duplicate window that fire-scoped keys close (an instance with a larger jitter draw could still attempt the same fire). The TTL must exceed max jitter + clock skew + any catch-up horizon within which the same instant may be re-attempted.

Exactly-once requires schedules whose instants match across instances: cron expressions, OnceAt, and AlignedDelay qualify; ConstantDelay's phase is per-process.

type MemoryElector added in v0.3.0

type MemoryElector struct {
	// contains filtered or unexported fields
}

MemoryElector is a settable Elector for tests and single-process use. A new MemoryElector starts as leader.

func NewMemoryElector added in v0.3.0

func NewMemoryElector() *MemoryElector

func (*MemoryElector) IsLeader added in v0.3.0

func (e *MemoryElector) IsLeader(context.Context) error

func (*MemoryElector) SetLeader added in v0.3.0

func (e *MemoryElector) SetLeader(leader bool)

SetLeader flips this instance's leadership.

type MemoryLocker added in v0.3.0

type MemoryLocker struct {
	// contains filtered or unexported fields
}

MemoryLocker is a process-local Locker for tests and single-process composition. Unlike backend lockers it does not retain claims after release: a single scheduler never dispatches the same fire twice, so in-process retention is unnecessary. It is not a substitute for a backend locker across processes.

func NewMemoryLocker added in v0.3.0

func NewMemoryLocker() *MemoryLocker

func (*MemoryLocker) Lock added in v0.3.0

func (l *MemoryLocker) Lock(_ context.Context, key string) (ReleaseFunc, error)

type MissedFirePolicy

type MissedFirePolicy uint8

MissedFirePolicy controls behaviour when a fire is later than WithMissedTolerance. OnMissedFire fires regardless of policy.

const (
	// MissedSkip ignores missed firings and resumes from the next
	// scheduled time. This is the default.
	MissedSkip MissedFirePolicy = iota

	// MissedRunOnce runs the job once for the most recent missed firing
	// (latest schedule.Next <= now), then resumes normally.
	MissedRunOnce

	// MissedRunAll runs the job once per missed firing (newest
	// missedRunAllCap kept), then resumes normally.
	MissedRunAll
)

func (MissedFirePolicy) String

func (p MissedFirePolicy) String() string

type MissedHook

type MissedHook interface{ OnMissedFire(EventMissed) }

type Option

type Option func(*config)

Option configures a Cron.

func WithBaseContext added in v0.3.0

func WithBaseContext(ctx context.Context) Option

WithBaseContext sets the root context jobs inherit from. Cancelling it stops firing and cancels in-flight jobs, like Stop but without waiting.

func WithChain

func WithChain(wrappers ...Wrapper) Option

WithChain installs global wrappers. First wrapper is outermost.

func WithElector added in v0.3.0

func WithElector(e Elector) Option

WithElector gates automatic fires on leadership: any IsLeader error skips the fire on this instance. Manual Trigger bypasses it.

func WithHookBuffer

func WithHookBuffer(n int) Option

WithHookBuffer sets the hook event buffer size. Full buffers drop new events.

func WithHooks

func WithHooks(hooks ...any) Option

WithHooks installs async hook subscribers. Values may implement any subset of ScheduleHook, JobStartHook, JobCompleteHook, and MissedHook.

func WithJitter

func WithJitter(max time.Duration) Option

WithJitter adds a random delay in [0, max) to each firing.

func WithLocation

func WithLocation(loc *time.Location) Option

WithLocation sets the default schedule timezone. Default is time.Local. Ignored when WithParser is set: a custom parser owns its timezone.

func WithLocker added in v0.3.0

func WithLocker(l Locker) Option

WithLocker sets the distributed Locker every automatic fire must claim (see FireKey). On failure the fire is skipped on this instance and EventSkipped is emitted. Manual Trigger bypasses it.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the slog.Logger. Default slog.Default().

func WithMaxConcurrent

func WithMaxConcurrent(n int) Option

WithMaxConcurrent caps in-flight jobs. Zero means unlimited.

func WithMaxEntries

func WithMaxEntries(n int) Option

WithMaxEntries caps registered entries. Zero means unlimited.

func WithMissedFire

func WithMissedFire(p MissedFirePolicy) Option

WithMissedFire selects the missed-fire policy. Default MissedSkip.

func WithMissedTolerance

func WithMissedTolerance(d time.Duration) Option

WithMissedTolerance sets the lateness threshold for "missed". Default 1s.

func WithParser

func WithParser(p Parser) Option

WithParser installs a parser. It takes over timezone resolution, so WithLocation and WithSecondsField no longer apply.

func WithRecorder

func WithRecorder(r any) Option

WithRecorder installs a metrics subscriber. Values may implement any subset of the recorder sub-interfaces. Methods are called concurrently from job goroutines, the scheduler loop, and Add/Remove/Trigger callers; they must be concurrency-safe and non-blocking.

func WithRetry

func WithRetry(p RetryPolicy) Option

WithRetry sets the default RetryPolicy. Overridden by WithEntryRetry.

func WithSecondsField added in v0.2.2

func WithSecondsField() Option

WithSecondsField enables a leading seconds field in the built-in parser, so the common seconds + WithLocation case composes without WithParser.

func WithoutRecover added in v0.3.0

func WithoutRecover() Option

WithoutRecover disables the built-in job panic recovery. By default a panicking job is recovered into an ErrJobPanic-wrapped error; with this option the panic propagates and crashes the process.

type ParseError

type ParseError struct {
	Spec   string
	Field  string // e.g. "minute"; "" if not applicable
	Pos    int    // 0-based byte offset; -1 if unknown
	Reason string
	Err    error
}

ParseError describes a failure parsing a cron specification.

func (*ParseError) Error

func (e *ParseError) Error() string

func (*ParseError) Unwrap

func (e *ParseError) Unwrap() error

type Parser

type Parser interface {
	Parse(spec string) (Schedule, error)
}

Parser turns a textual spec into a Schedule. Cron caches parser results.

type ParserOption

type ParserOption func(*parserConfig)

ParserOption configures NewStandardParser.

func WithDefaultLocation

func WithDefaultLocation(loc *time.Location) ParserOption

WithDefaultLocation sets the default timezone for specs without TZ=/CRON_TZ=. nil means time.Local.

func WithParserExt

func WithParserExt(ext Parser) ParserOption

WithParserExt installs a pre-parse hook. Returning (nil, nil) falls through to the standard parser.

func WithSeconds

func WithSeconds(strict ...bool) ParserOption

WithSeconds enables a leading seconds field. By default the parser accepts both 5- and 6-field specs (a 5-field spec is parsed with second=0). Pass true to require exactly 6 fields.

type QueueDepthRecorder

type QueueDepthRecorder interface{ QueueDepth(n int) }

type ReleaseFunc added in v0.3.0

type ReleaseFunc func(ctx context.Context) error

ReleaseFunc releases a lock acquired by a Locker. Implementations must be idempotent. The scheduler calls it with a short-deadline context detached from the job's context, so job cancellation never prevents release.

type RetryOption

type RetryOption func(*RetryPolicy)

RetryOption configures a RetryPolicy built by Retry.

func RetryInitial

func RetryInitial(d time.Duration) RetryOption

RetryInitial sets the first retry delay (default 1s).

func RetryJitterFrac

func RetryJitterFrac(f float64) RetryOption

RetryJitterFrac is fractional uniform jitter (e.g. 0.1 = ±10%).

func RetryMaxDelay

func RetryMaxDelay(d time.Duration) RetryOption

RetryMaxDelay caps backoff (zero = uncapped).

func RetryMultiplier

func RetryMultiplier(m float64) RetryOption

RetryMultiplier is the per-attempt growth factor (<=1 stays constant).

type RetryPolicy

type RetryPolicy struct {
	MaxRetries int
	Initial    time.Duration
	MaxDelay   time.Duration
	Multiplier float64
	JitterFrac float64
}

RetryPolicy describes exponential backoff with optional jitter. MaxRetries == 0 disables retry; negative means unlimited until ctx cancellation. Fields are exported for config-driven assembly; use Retry(...) for programmatic construction.

func Retry

func Retry(maxRetries int, opts ...RetryOption) RetryPolicy

Retry builds a RetryPolicy. maxRetries is the number of retries after the initial attempt; negative retries until ctx cancellation.

func (RetryPolicy) IsZero

func (p RetryPolicy) IsZero() bool

IsZero is keyed only on MaxRetries so half-filled policies (e.g. only Initial set) don't produce a useless wrapper.

func (RetryPolicy) Wrapper

func (p RetryPolicy) Wrapper() Wrapper

Wrapper returns a Wrapper that retries on error per p. Attempt errors are joined via errors.Join; ctx cancellation aborts, recording context.Cause so ErrJobTimeout / ErrCronStopping survive into the joined error.

type Schedule

type Schedule interface {
	Next(now time.Time) time.Time
}

Schedule yields successive firing times. Next must return the first firing strictly after now, or zero when exhausted.

func Filter added in v0.3.0

func Filter(s Schedule, keep func(time.Time) bool) Schedule

Filter wraps s, skipping firings for which keep returns false — e.g. a holiday calendar. A nil keep passes everything through. The search gives up (returns zero) after filterScanCap consecutive rejections.

func OnceAt added in v0.3.0

func OnceAt(t time.Time) Schedule

OnceAt fires exactly once, at t. If t is already past, it never fires.

func TriggeredSchedule

func TriggeredSchedule() Schedule

TriggeredSchedule never fires automatically. Combine with Trigger.

func Union added in v0.3.0

func Union(schedules ...Schedule) Schedule

Union fires whenever any of the schedules fires. Nil members are ignored; an empty union never fires.

type ScheduleHook

type ScheduleHook interface{ OnSchedule(EventSchedule) }

Hook sub-interfaces. WithHooks subscribers may implement any subset.

type SkipHook added in v0.3.0

type SkipHook interface{ OnSkipped(EventSkipped) }

type SkipReason added in v0.3.0

type SkipReason uint8

SkipReason classifies why distributed coordination suppressed a fire.

const (
	SkipNotLeader SkipReason = iota // Elector returned an error
	SkipLockHeld                    // another instance claimed this fire
	SkipLockError                   // Locker backend failure (fail-closed)
)

func (SkipReason) String added in v0.3.0

func (r SkipReason) String() string

type SpecAnalysis

type SpecAnalysis struct {
	Spec        string
	Valid       bool
	Err         error
	IsTriggered bool
	Descriptor  string         // "@every", "@hourly", ... or "" for 5/6-field specs
	Interval    time.Duration  // set when Descriptor == "@every"
	Location    *time.Location // schedule timezone
	NextRun     time.Time      // upcoming firing relative to the now passed in
}

SpecAnalysis is the result of AnalyzeSpec. Most fields are populated only when Valid is true.

func AnalyzeSpec

func AnalyzeSpec(spec string, now time.Time) SpecAnalysis

AnalyzeSpec parses spec and returns a structured description.

func AnalyzeSpecWith

func AnalyzeSpecWith(spec string, p Parser, now time.Time) SpecAnalysis

AnalyzeSpecWith is AnalyzeSpec with a custom parser.

type SpecSchedule

type SpecSchedule struct {
	// contains filtered or unexported fields
}

SpecSchedule is a parsed cron expression.

func (*SpecSchedule) Location

func (s *SpecSchedule) Location() *time.Location

Location returns the evaluation timezone.

func (*SpecSchedule) LogValue

func (s *SpecSchedule) LogValue() slog.Value

func (*SpecSchedule) Next

func (s *SpecSchedule) Next(t time.Time) time.Time

Next returns the next firing after t, or zero if none is found.

The hour branch reconstructs the wall clock at the target hour instead of adding an absolute (h-hour)*time.Hour, which would overshoot a DST spring-forward gap and skip the day. The minute/second branches keep their absolute jumps: they never straddle a whole-hour DST boundary, and stepping through the repeated hour on fall-back keeps both firings observable.

func (*SpecSchedule) Upcoming

func (s *SpecSchedule) Upcoming(from time.Time) iter.Seq[time.Time]

Upcoming is a lazy iterator over future firings.

type StandardParser

type StandardParser struct {
	// contains filtered or unexported fields
}

StandardParser is stateless and concurrent-safe.

func NewStandardParser

func NewStandardParser(opts ...ParserOption) *StandardParser

NewStandardParser handles 5/6-field specs, descriptors, and TZ prefixes.

func (*StandardParser) Parse

func (p *StandardParser) Parse(spec string) (Schedule, error)

type Upcoming

type Upcoming interface {
	Upcoming(from time.Time) iter.Seq[time.Time]
}

Upcoming is an optional lazy iteration capability.

type Wrapper

type Wrapper func(Job) Job

Wrapper decorates a Job.

func Chain

func Chain(wrappers ...Wrapper) Wrapper

Chain composes wrappers so the first wraps outermost.

Example
package main

import (
	"context"
	"fmt"

	"github.com/libtnb/cron"
)

func main() {
	mk := func(name string) cron.Wrapper {
		return func(j cron.Job) cron.Job {
			return cron.JobFunc(func(ctx context.Context) error {
				fmt.Println("enter", name)
				err := j.Run(ctx)
				fmt.Println("leave", name)
				return err
			})
		}
	}
	core := cron.JobFunc(func(ctx context.Context) error {
		fmt.Println("run core")
		return nil
	})
	_ = cron.Chain(mk("outer"), mk("inner"))(core).Run(context.Background())
}
Output:
enter outer
enter inner
run core
leave inner
leave outer

Directories

Path Synopsis
_examples
hello command
quartz command
seconds command
slog command
workflow command
internal
bitmask
Package bitmask provides bit-set scans shared by the schedule implementations.
Package bitmask provides bit-set scans shared by the schedule implementations.
heap
Package heap provides a typed min-heap with addressable items.
Package heap provides a typed min-heap with addressable items.
parsecache
Package parsecache memoises parser results.
Package parsecache memoises parser results.
Package parserext provides optional cron parser extensions.
Package parserext provides optional cron parser extensions.
Package workflow runs DAGs of cron.Jobs.
Package workflow runs DAGs of cron.Jobs.
Package wrap supplies Job decorators.
Package wrap supplies Job decorators.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL