scheduler

package
v1.33.0 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: MIT Imports: 9 Imported by: 2

Documentation

Overview

Package scheduler runs recurring jobs on wall-clock minute boundaries and one-shot jobs on demand, keyed by id, in one process.

Every instance of an app runs every recurring job at every boundary unless the Scheduler has a Claimer (WithClaimer), which lets one instance run each boundary of a recurring job. The claims subpackage has one on PostgreSQL.

Index

Constants

View Source
const (
	// DefaultShutdownTimeout is how long Start waits for running jobs after its
	// ctx is done, unless WithShutdownTimeout sets another value.
	DefaultShutdownTimeout = 60 * time.Second
	// DefaultJobTimeout is the per-job timeout applied when the job's ctx has no
	// deadline, unless WithJobTimeout sets another value.
	DefaultJobTimeout = time.Hour
)

Variables

View Source
var ErrSuperseded error = ez.New(ez.ECANCELED, "scheduler: superseded", context.Canceled)

ErrSuperseded is the context cause seen by a job that was cancelled because Supersede queued a replacement for the same id. errors.Is(context.Cause(ctx), ErrSuperseded) and errors.Is(context.Cause(ctx), context.Canceled) both hold.

Functions

func ShouldRunLocalHour added in v1.6.0

func ShouldRunLocalHour(tz string, hour int) bool

ShouldRunLocalHour returns true if the local time in tz is exactly hour:00.

func ShouldRunLocalTime added in v1.6.4

func ShouldRunLocalTime(tz string, hour, minute int) bool

ShouldRunLocalTime returns true if the local time in tzName matches hour:minute exactly.

Types

type Claimer added in v1.33.0

type Claimer interface {
	// Claim reports whether this instance may run job id for the minute
	// boundary tick. It returns false when tick or a later boundary of id was
	// already claimed by any instance, this one included (after its clock
	// stepped back, or a restart on a host whose clock is behind), or when a
	// claimed run of id is still going: not released and its lease not
	// expired. lease is how long a claim blocks later boundaries when its
	// instance never releases it; the Scheduler passes its job timeout
	// (WithJobTimeout) plus one minute, capped at the largest Duration. An
	// error means the store could not answer or refused tick.
	Claim(ctx context.Context, id string, tick time.Time, lease time.Duration) (bool, error)

	// Release ends the claim on tick for id, so it no longer blocks later
	// boundaries. It must not end a claim on any other tick, so a late
	// release cannot free a newer claim.
	Release(ctx context.Context, id string, tick time.Time) error
}

Claimer decides which instance of an app runs a recurring job at a minute boundary, so that a job registered with Add on every instance runs once per boundary instead of once per instance. Set it with WithClaimer. The claims subpackage implements it on PostgreSQL.

The Scheduler claims only recurring runs. At a due boundary it first applies its own rule, skipping the boundary while the id is busy in this process without claiming. Otherwise it admits the run, which makes the id busy, and the run calls Claim before the job body. While the claim is in flight the id is busy as for a running job: RunOnce returns false, RunNext and Supersede queue behind it, and Active counts the id. Supersede and shutdown cancel the context passed to Claim. A store can still answer that the claim is won, and the claims subpackage's always answers a claim it has started (bounded by the 10 second claim timeout); the job is then not run and the claim is released. A won claim runs the job, and Release is called when the job returns, after a panic too, and before a pending job of the id runs. A refused claim skips the job, and so does a claim error, which is logged at error level unless the run was cancelled: missing a boundary is safer than every instance running it. After a late wake the Scheduler claims, once, the latest boundary of the caught-up range that the job's schedule matches. RunOnce, Supersede and RunNext run on the instance they are called on and are never claimed.

During shutdown the claim is released when the job returns and its release finishes within the shutdown timeout, and the process waits for Start to return.

type Job

type Job func(ctx context.Context)

Job is a unit of work run by the Scheduler. It should return promptly once ctx is done.

type Option added in v1.6.0

type Option func(*Scheduler)

Option configures the Scheduler. Pass options to New.

func WithClaimer added in v1.33.0

func WithClaimer(c Claimer) Option

WithClaimer makes recurring jobs run on one instance per minute boundary: before a due recurring job runs, the Scheduler claims the boundary from c and skips the run unless the claim is won (see Claimer). Run every instance in the same time zone. Every and Hourly compare only the minute of the hour in local time, so zones whose UTC offsets differ by whole hours agree, but zones whose offsets differ by a fraction of an hour (+5:30, +5:45, -3:30) select different instants for a schedule that fraction does not fit, such as Hourly or Every(time.Hour), and each instance then wins its own boundaries. Daily compares in its own location, the same on every instance unless it is time.Local.

The claim's lease is the job timeout plus one minute (capped at the largest Duration), counted from the claim, while the job timeout starts when the job body is about to run. A run outlives its lease, and can overlap a run of a later boundary on another instance, when the claim's answer, the wait for a WithLimit permit and the time the job takes to return once its job timeout cancels it add up to more than that minute, when the job ignores its context, or when it runs under a Start ctx with a deadline, which replaces the job timeout. Nil => no claims, every instance runs every due boundary.

func WithJobTimeout added in v1.6.0

func WithJobTimeout(d time.Duration) Option

WithJobTimeout sets the per-job timeout applied when the job's ctx has no deadline. The timer starts when the job body is about to run, after any permit from WithLimit was acquired, so time spent waiting for a permit does not count. d <= 0 is ignored.

func WithLimit added in v1.12.0

func WithLimit(prefix string, n int) Option

WithLimit caps at n the number of jobs whose id starts with prefix that run at the same time, across RunOnce, Supersede, RunNext and recurring jobs. A job over the cap waits for a permit, still counts in Active, and gives up without running if its context is cancelled while waiting. New rejects an empty prefix, n < 1, a prefix given twice, and a prefix that is a prefix of another limit's prefix.

func WithLogger added in v1.6.0

func WithLogger(l logger.Logger) Option

WithLogger sets the logger. Nil => Noop logger.

func WithShutdownTimeout added in v1.6.0

func WithShutdownTimeout(d time.Duration) Option

WithShutdownTimeout sets how long Start waits for jobs to drain after its ctx is done. d <= 0 is ignored.

type Schedule added in v1.12.0

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

Schedule is an immutable description of the wall-clock minutes at which a recurring job is due. Build it with Every, Hourly or Daily. The zero value is invalid. A value can be reused for several ids.

func Daily added in v1.12.0

func Daily(hour, minute int, loc *time.Location) Schedule

Daily returns a Schedule that is due once a day at hour:minute wall-clock time in loc. hour must be in 0..23, minute in 0..59 and loc non-nil. On a day where that local time does not exist (DST spring-forward) the schedule is not due; on a day where it occurs twice (DST fall-back) it is due at both instants. Invalid input yields a Schedule that Add rejects with an ez.EINVALID error.

func Every added in v1.12.0

func Every(d time.Duration) Schedule

Every returns a Schedule that is due at minute 0 of every hour and every d after it, in server-local time. d must be a whole number of minutes that divides one hour evenly (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 or 60 minutes). Every(time.Hour) is equivalent to Hourly(0). An invalid d yields a Schedule that Add rejects with an ez.EINVALID error.

func Hourly added in v1.12.0

func Hourly(minutes ...int) Schedule

Hourly returns a Schedule that is due at each of the given minutes of every hour, in server-local time. At least one minute is required, each must be in 0..59 and none may repeat. Order does not matter. Invalid input yields a Schedule that Add rejects with an ez.EINVALID error.

type Scheduler

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

Scheduler runs recurring jobs on wall-clock minute boundaries and one-shot jobs on demand. Work is keyed by id: at most one invocation per id runs at a time, and each busy id holds at most one pending job that runs after the current one returns. Create it with New.

func New

func New(opts ...Option) (*Scheduler, error)

New creates a Scheduler with the given options. It returns an ez.EINVALID error when a WithLimit option has an empty prefix, a size below 1, a prefix given twice, or a prefix that is a prefix of another limit's prefix.

func (*Scheduler) Active added in v1.6.2

func (s *Scheduler) Active() int64

Active returns the number of ids with admitted work: running, waiting for a permit from a WithLimit option, or claiming their boundary from a Claimer. A pending job adds nothing to the count.

func (*Scheduler) Add

func (s *Scheduler) Add(id string, when Schedule, job Job) error

Add registers job to run at the minute boundaries selected by when. It returns an ez.EINVALID error for an empty id, a nil job or an invalid Schedule, and an ez.ECONFLICT error when a recurring job with the same id is already registered; on error nothing is registered. Add may be called before or after Start. The id is shared with RunOnce, Supersede and RunNext: a due boundary is skipped while the id is busy.

Daily schedules follow wall time in their location: on a DST spring-forward day a nonexistent local time does not fire, and on a fall-back day a repeated local time fires at both instants.

func (*Scheduler) Idled added in v1.6.2

func (s *Scheduler) Idled() <-chan struct{}

Idled returns a channel that receives a value when the last busy id finishes and Active drops to zero. It has a buffer of one and sends never block, so a receiver may see one value for several idle transitions. It does not fire between a job and the pending job that follows it. Check Active after receiving, since new work may already have been admitted.

func (*Scheduler) RunNext added in v1.12.0

func (s *Scheduler) RunNext(ctx context.Context, id string, job Job) bool

RunNext starts job under id now when id is idle. When id is busy it replaces the id's pending job with job and returns true without cancelling the current invocation; job starts after the current invocation returns. It returns false when id is empty, job is nil, ctx is already done, or the Scheduler is stopping. A pending job whose ctx is done by then is dropped.

func (*Scheduler) RunOnce

func (s *Scheduler) RunOnce(ctx context.Context, id string, job Job) bool

RunOnce starts job under id now. It returns false without running anything when id is empty, job is nil, ctx is already done, the Scheduler is stopping, or id is busy; a busy id's pending job is left untouched.

func (*Scheduler) Start

func (s *Scheduler) Start(ctx context.Context)

Start runs the minute engine and blocks until ctx is done. It sleeps until the next wall-clock minute boundary, runs the recurring jobs due at it, and repeats. It never fires immediately: the first boundary evaluated is the first one after Start is called.

A late wake, because the process was frozen or the clock jumped forward, catches up: every minute boundary between the last one handled and the clock is evaluated, and a registration due at any of them is admitted once. A due job whose id is still busy is skipped for that wake. Nothing is remembered across restarts, so minutes that pass while the process is not running are never replayed. If the clock is stepped back by a minute or more the engine follows it and evaluates those minutes again as they pass.

When ctx is done Start stops the Scheduler: nothing new is admitted, pending jobs are discarded, running jobs are cancelled with context.Canceled, and Start waits for them up to the shutdown timeout before returning. Jobs that ignore their context may outlive that wait. Call Start once; once it has returned, a later call returns immediately.

func (*Scheduler) Supersede added in v1.12.0

func (s *Scheduler) Supersede(ctx context.Context, id string, job Job) bool

Supersede starts job under id now when id is idle. When id is busy it replaces the id's pending job with job, cancels the current invocation with cause ErrSuperseded and returns true without waiting; job starts after the current invocation returns. It returns false when id is empty, job is nil, ctx is already done, or the Scheduler is stopping.

Directories

Path Synopsis
Package claims keeps the claims of a scheduler.Scheduler in a PostgreSQL table, so that an app running several instances runs each minute boundary of a recurring job on one of them.
Package claims keeps the claims of a scheduler.Scheduler in a PostgreSQL table, so that an app running several instances runs each minute boundary of a recurring job on one of them.

Jump to

Keyboard shortcuts

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