Documentation
¶
Index ¶
- Constants
- Variables
- func ParseCron(spec string) (cronExpr, error)
- type CronJob
- type LeaderElection
- type PostgresAdvisoryLease
- type Schedule
- func (s Schedule) FiresAt(prev, t time.Time) bool
- func (s Schedule) FixedTime() bool
- func (s Schedule) Matches(t time.Time) bool
- func (s Schedule) MatchesDay(t time.Time) bool
- func (s Schedule) Next(after time.Time) time.Time
- func (s Schedule) Satisfiable() bool
- func (s Schedule) SkipDay(t time.Time) time.Time
- type Scheduler
- func (s *Scheduler) Register(job CronJob) error
- func (s *Scheduler) RunOnce(ctx context.Context, now time.Time)
- func (s *Scheduler) SetGate(gate func(jobName string) bool)
- func (s *Scheduler) Start(ctx context.Context)
- func (s *Scheduler) Stop()
- func (s *Scheduler) StopContext(ctx context.Context) error
- func (s *Scheduler) WithLeaderElection(le LeaderElection)
- type TickLeaderElection
Constants ¶
const HorizonYears = 401
HorizonYears bounds every search for a firing. The Gregorian calendar repeats every 400 years, weekdays included, so a satisfiable spec fires within one cycle of any instant.
const MaxJobNameBytes = 256
MaxJobNameBytes caps the length of a job name. The cap exists to keep names cheap to log; it is not a security boundary on the name itself (names are opaque labels, sanitise them at any rendering call site).
Variables ¶
var ErrInvalidJobName = errors.New("cron: invalid job name")
ErrInvalidJobName is returned by Register when Name is empty or longer than MaxJobNameBytes.
var ErrNilJobRun = errors.New("cron: job Run is nil")
ErrNilJobRun is returned by Register when Run is nil. A nil Run would nil-pointer at execution time; rejecting at registration surfaces the bug at startup instead of inside a tick callback.
Functions ¶
Types ¶
type CronJob ¶
CronJob is the unit of scheduled work.
Run is invoked on every firing, its context is derived from the scheduler's parent context, so cancelling the scheduler cancels in-flight runs at the next yield point.
If Run returns an error it is forwarded to the scheduler's OnError callback (if set); otherwise it is logged via slog.Default(), jobs should not crash the process.
type LeaderElection ¶ added in v0.56.0
type LeaderElection interface {
// Acquire attempts to take the lease for this tick. held is true when the
// caller is the leader and should fire jobs; in that case release (non-nil)
// relinquishes it. A non-leader returns (false, nil, nil). Acquire must not
// block indefinitely, a non-leader should return promptly so the replica
// can skip to the next tick.
Acquire(ctx context.Context) (held bool, release func(), err error)
}
LeaderElection gates a Scheduler so only one replica fires jobs each tick, making cron safe to run across multiple replicas. Install one via Scheduler.WithLeaderElection; when set, the tick loop acquires the lease before firing and releases it after that tick's jobs finish, so a non-leader replica skips the tick. A lease that also implements TickLeaderElection is released as soon as its claim is made instead. The built-in PostgresAdvisoryLease uses a Postgres advisory lock; a custom implementation can back it with Redis, etcd, etc.
Acquire alone is mutual exclusion while the lease is held: a replica whose timer fires for the same minute after the leader released it can fire that minute again. A lease that also implements TickLeaderElection (PostgresAdvisoryLease does) records the claimed minute, so each tick fires at most once across replicas. Neither is exactly-once execution; for durable, exactly-once background work use the DB-backed queue (battery/queue.DurableScheduler).
type PostgresAdvisoryLease ¶ added in v0.56.0
type PostgresAdvisoryLease struct {
// contains filtered or unexported fields
}
PostgresAdvisoryLease is a LeaderElection backed by a Postgres advisory lock (pg_try_advisory_lock / pg_advisory_unlock). Every replica pointing at the same Postgres and using the same Key coordinates on the lock: the holder fires the tick, the others skip. Advisory locks are session-scoped, so each Acquire pins a dedicated connection for the lock's lifetime and unlock reuses that same connection before returning it to the pool.
The lease also implements TickLeaderElection: AcquireTick records the tick minute it fired in the gofastr_cron_ticks table (created on first use), so a replica whose timer fires for the same minute after the leader released the lock skips it instead of running the jobs a second time. The scheduler uses AcquireTick and releases the lock as soon as the claim is made, so the pinned connection is held for the lock and the claim only, never while the jobs run.
Pool sizing: through plain Acquire, the lock's connection stays pinned for as long as the caller holds the lease, so the DB pool should allow at least 2 open connections when the work done under it uses the database, otherwise the holder has the only connection and that work deadlocks.
func NewPostgresAdvisoryLease ¶ added in v0.56.0
func NewPostgresAdvisoryLease(db *sql.DB, key int64) *PostgresAdvisoryLease
NewPostgresAdvisoryLease creates a leader-election lease over a Postgres advisory lock identified by key. Every coordinating replica MUST use the same key; pick a fixed value to avoid colliding with the framework's own advisory locks (see core/migrate's pinned AdvisoryLockKey).
func (*PostgresAdvisoryLease) Acquire ¶ added in v0.56.0
func (p *PostgresAdvisoryLease) Acquire(ctx context.Context) (bool, func(), error)
Acquire tries to take the advisory lock. On success it returns the pinned connection's release function (which unlocks and returns the conn). A non-leader returns (false, nil, nil) and closes its speculative connection.
func (*PostgresAdvisoryLease) AcquireTick ¶ added in v0.96.0
func (p *PostgresAdvisoryLease) AcquireTick(ctx context.Context, tick time.Time) (bool, func(), error)
AcquireTick takes the advisory lock and then claims tick (the minute the scheduler is firing) for this lease key. It holds only when both succeed: a tick minute at or before the last one any replica claimed is refused, the lock released at once, and (false, nil, nil) returned. The claim is a single conditional upsert (INSERT ... ON CONFLICT DO UPDATE ... WHERE last_tick < $2) run on the pinned lock connection, so a one-connection pool cannot deadlock on it, and atomic on its own, so the at-most-once record does not depend on the lock. A claim error releases the lock and is returned; the scheduler reports it and skips the tick.
type Schedule ¶ added in v0.3.3
type Schedule struct {
// contains filtered or unexported fields
}
Schedule is a parsed cron expression with a public surface. It wraps the internal bitmask form so callers outside this package (notably the queue battery's Scheduler) can compute firing times without re-parsing or re-implementing a cron parser.
Schedule is immutable and safe for concurrent use.
func Parse ¶ added in v0.3.3
Parse parses a cron spec into a Schedule. It accepts the same syntax as ParseCron (standard 5-field plus @shortcuts, ranges, lists and steps). Unlike ParseCron, which returns the unexported internal form used by the in-process Scheduler. Parse returns a value other packages can hold and query via Next and Matches.
func (Schedule) FiresAt ¶ added in v0.96.0
FiresAt reports whether the schedule fires at the minute holding t, for a walk that evaluated prev just before it. It is Matches with vixie cron's daylight-saving handling, in t's location:
- A fixed-time schedule (see FixedTime) whose time falls in a repeated hour (a fall-back) runs in the first pass only.
- A fixed-time schedule whose time a spring-forward skipped runs once, at the first minute after the jump. prev is what tells FiresAt a jump happened: the in-process Scheduler passes the minute before t.
- Every other schedule fires by the wall clock as it runs, so a wildcard minute or hour keeps real elapsed time across a change.
battery/queue's DurableScheduler and in-memory Scheduler, and this package's Scheduler, all decide a firing here.
func (Schedule) FixedTime ¶ added in v0.96.0
FixedTime reports whether the schedule names fixed wall-clock times: neither its minute nor its hour field begins with "*". Vixie cron gives such a job its once-only handling across a DST change and runs every other job by the new wall clock.
func (Schedule) Matches ¶ added in v0.3.3
Matches reports whether the schedule fires during the minute containing t.
func (Schedule) MatchesDay ¶ added in v0.96.0
MatchesDay reports whether t's date can hold a firing: its month and day fields match, whatever the minute and hour fields say.
func (Schedule) Next ¶ added in v0.3.3
Next returns the earliest firing time strictly after after. The result is minute-aligned (cron resolution is one minute) and carries after's location. It returns the zero Time for a spec that never fires (Feb 30).
func (Schedule) Satisfiable ¶ added in v0.96.0
Satisfiable reports whether the schedule ever fires. A spec whose month and day-of-month fields name no real date (Feb 30) never does.
func (Schedule) SkipDay ¶ added in v0.96.0
SkipDay returns the next minute a walk from t must test. Within a date MatchesDay refuses, it jumps to 21:00 local of the same date: no firing can fall before then, and the walk still crosses midnight a minute at a time, so a zone transition near midnight is seen as it happens.
type Scheduler ¶
type Scheduler struct {
OnError func(jobName string, err error)
// contains filtered or unexported fields
}
Scheduler is a tiny in-process cron driver. It is intentionally minimal: no persistence, and by default no overlap protection across replicas (every replica fires every tick, fine for a single instance). For horizontally scaled deployments, install a LeaderElection via WithLeaderElection so only one replica fires each tick, or use the DB-backed queue (battery/queue.DurableScheduler) for durable, exactly-once work.
func NewScheduler ¶
func NewScheduler() *Scheduler
NewScheduler returns a Scheduler that wakes once per minute. The tick interval is a deliberate choice: cron resolution is one minute, and waking more often would burn CPU for no gain.
func (*Scheduler) Register ¶
Register adds a job. Returns an error if the spec is invalid, the name is empty or oversize, or Run is nil, callers catch typos at registration time rather than silently failing forever or nil-pointering at firing.
func (*Scheduler) RunOnce ¶
RunOnce fires every job whose schedule fires at the given minute, in now's location, with the daylight-saving handling Schedule.FiresAt describes: a fixed-time job runs once across a fall-back and once after a spring-forward skipped its time. Exported for tests that drive the tick manually instead of waiting on the wall clock; production code lets the loop call this.
Iterates under the lock without copying the slice. Jobs that mutate state (Register during tick) are safe because the mutex is held only for the read, new jobs appear on the next tick.
func (*Scheduler) SetGate ¶ added in v0.17.0
SetGate installs a gate checked in RunOnce for every firing job. When gate returns false the job is skipped for this tick. Framework code uses it to skip jobs owned by a disabled module. Pass nil to clear.
func (*Scheduler) Start ¶
Start begins the tick loop in a goroutine. Returns immediately. Idempotent: repeated Start calls are no-ops once the loop is running.
func (*Scheduler) Stop ¶
func (s *Scheduler) Stop()
Stop signals the loop to exit and blocks until it has, then joins any in-flight job goroutines, a job mid-write must finish before graceful shutdown lets the process exit. Safe to call multiple times, and safe to call before Start, if the loop was never launched there is nothing to wait for, so Stop returns immediately instead of blocking forever on a channel the loop never closes (which would hang graceful shutdown when boot aborts before Start runs).
Stop waits for in-flight jobs without a deadline; use StopContext when the join must be bounded (App wires its cron drain through StopContext so a job that ignores its context cannot hang SIGTERM forever).
func (*Scheduler) StopContext ¶ added in v0.11.0
StopContext is Stop with a deadline on the in-flight join: it signals the loop to exit, waits for it, then waits for running job goroutines until ctx expires. Jobs receive the scheduler's parent context, which App.Shutdown cancels before draining, so well-behaved jobs exit promptly; a job that ignores its context is abandoned at the deadline and ctx.Err() is returned.
func (*Scheduler) WithLeaderElection ¶ added in v0.56.0
func (s *Scheduler) WithLeaderElection(le LeaderElection)
WithLeaderElection installs a leader-election gate so the scheduler fires jobs only when it holds the lease, making cron safe across replicas. With none set (the default) every replica fires every tick. Build a lease with NewPostgresAdvisoryLease or supply a custom LeaderElection.
type TickLeaderElection ¶ added in v0.96.0
type TickLeaderElection interface {
LeaderElection
// AcquireTick attempts to take the lease and claim tick. held, release
// and err follow Acquire's contract; a lease taken for a minute that is
// already claimed is released before returning (false, nil, nil).
AcquireTick(ctx context.Context, tick time.Time) (held bool, release func(), err error)
}
TickLeaderElection is an optional LeaderElection capability that makes each tick fire at most once across replicas. Plain Acquire gives mutual exclusion only while the lease is held, and the scheduler releases it as soon as the tick's jobs finish: a replica whose timer fires for the same minute a moment later finds the lease free and runs the jobs again. AcquireTick closes that gap by recording which minute was consumed.
Because the claim is the guarantee, the scheduler releases a tick lease as soon as AcquireTick holds, before the tick's jobs start: a long job does not hold the lease, and the next minute fires on schedule (on any replica, this one included) while it runs.
When the installed lease implements it, the scheduler calls AcquireTick instead of Acquire, passing the tick minute (the firing time truncated to the minute, in UTC). An implementation must hold only when it took the lease AND no replica sharing the lease has claimed this minute or a later one, and it must record the claim atomically (a conditional write, not a read followed by a write). PostgresAdvisoryLease implements it with a small table and a conditional upsert.
Limits: the guarantee is at most once, not exactly once. A tick whose claim fails or loses is skipped, never retried, and a replica that claims and then crashes leaves the minute unrun. Claims are monotonic, so a replica whose clock runs behind cannot fire a minute older than one already claimed, and a replica whose clock runs ahead fires its minute early by its skew; skew of a minute or more skips minutes. Every replica sharing the lease must register the same jobs, since one claim covers the whole tick.