scheduler

package
v0.18.0 Latest Latest
Warning

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

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package scheduler runs the tasks modules declare.

A scheduler built on a system cron exists because the runtime has no resident process: cron calls a command every minute and the command decides what to run. That is two artifacts and a dependency on the operating system.

Go has a resident process. The scheduler is a goroutine in the same binary, which is also what keeps the deploy story of doc 17 true: one image, no crontab to configure, nothing to forget when a machine is replaced.

What it does not do is retry. A task that fails is logged and diagnosed, and the next window runs it again; work that needs its own retry budget enqueues a job, and the queue owns the retry. Scheduler fires, queue persists.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Module

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

Module runs the scheduler in the application process.

It is registered like any other module and collects the tasks from the ones registered before it -- which is why it goes last in the Register call. A module never starts its own goroutine; it declares work, and this is what runs it.

func NewModule

func NewModule(tasks []kernel.Task, opts Options) *Module

NewModule returns the module for a set of tasks.

Use kernel.Tasks() to collect them from the registered modules:

k := kernel.New(cfg).Register(billing.New(...), reports.New(...))
k.Register(scheduler.NewModule(k.Tasks(), scheduler.Options{
    Locker:  kv.NewLocker(client),
    Tenants: tenants.Active,
}))

func (*Module) Boot

func (m *Module) Boot(_ context.Context) error

Boot parses the tasks. It does not start the loop.

An unparseable spec fails the boot. The application does not start with a task that would silently never run, which is what "config validated at boot, fail fast" means applied to schedules -- and it fails for every command, not just the one that serves, so `aru routes` catches a bad spec too.

func (*Module) Close

func (m *Module) Close(ctx context.Context) error

Close stops the loop and waits for the run in flight.

func (*Module) Diagnose

func (m *Module) Diagnose(ctx context.Context) []string

Diagnose reports overdue and failed tasks on the error page.

func (*Module) Name

func (*Module) Name() string

Name is the module identifier.

func (*Module) Routes

func (*Module) Routes(*httpx.Router)

Routes registers nothing. A scheduled task is not reachable over HTTP, and making it reachable would be a way to trigger billing by URL.

func (*Module) Scheduler

func (m *Module) Scheduler() *Scheduler

Scheduler returns the running scheduler, for `aru schedule:list` and `aru schedule:run` to reach through the application binary.

Nil before Boot.

func (*Module) Start added in v0.10.0

func (m *Module) Start(ctx context.Context) error

Start begins the loop, and only the process that serves calls it.

It used to happen in Boot, so every `aru work` replica ran a scheduler and `aru schedule:run` -- the command for running one task by hand -- started the loop that runs all of them. The lock made it harmless; it did not make it right. See kernel.Background.

type Options

type Options struct {
	// Locker makes a Singleton task run on exactly one replica. Nil means a
	// single replica, and with more than one it means every replica runs
	// everything.
	Locker kernel.Locker
	// Tenants expands PerTenant tasks. Nil means those tasks do not run, which
	// is reported rather than silent.
	Tenants Tenants
	// Now is the clock, for tests. Nil means time.Now.
	Now func() time.Time
	// Recorder receives each finished run, so the task shows on /_arandu/debug
	// with its queries and its timeline -- exactly like a request.
	//
	// Nil means no instrumentation, and that is what production looks like: no
	// Collector is built and every Record method is a no-op on a nil receiver.
	// It used to build one on every run and throw it away, so production paid
	// for recording and the console the doc promised never showed a task. Found
	// by audit. Pass kernel.Recorder() to turn it on.
	Recorder *observability.Recorder
}

Options configures the scheduler.

type Registered

type Registered struct {
	ID        string
	Spec      string
	Scope     string
	Singleton bool
	Timeout   time.Duration
	Next      time.Time
	LastRun   time.Time
	LastError string
}

Registered is one task, as `aru schedule:list` prints it.

type Schedule

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

Schedule is a parsed five-field cron expression.

Five fields, not six: no seconds. A framework that offers second-level cron offers a way to write a busy loop by accident, and work that has to happen every few seconds is a worker, not a schedule.

Parsed rather than pulled from a library, because the core has two dependencies (ADR 0004) and this is eighty lines. What it supports is the syntax people write: `*`, `5`, `1-5`, `*/15`, `1,15,30`, and the two names that read better than numbers (`@daily`, `@hourly`).

func MustParse

func MustParse(spec string) Schedule

MustParse is Parse for a constant. It panics, which is right for a schedule written in source: a module with an unparseable spec must not boot.

func Parse

func Parse(spec string) (Schedule, error)

Parse reads a cron expression.

func (Schedule) Matches

func (s Schedule) Matches(t time.Time) bool

Matches reports whether the schedule fires in the minute of t.

Day-of-month and day-of-week are OR when both are restricted, which is the behaviour of every cron since Vixie: "0 0 1 * 1" means the first of the month AND every Monday, not their intersection. It surprises people, and matching the surprise is better than being the one implementation that differs.

func (Schedule) Next

func (s Schedule) Next(t time.Time) time.Time

Next returns the first minute at or after t that matches.

It walks minute by minute, bounded to a year. A schedule that matches nothing in a year matches nothing at all -- February 30th, for instance -- and returning the zero time is what lets `aru schedule:list` say so instead of hanging.

func (Schedule) String

func (s Schedule) String() string

String returns the expression it was parsed from, for `aru schedule:list`.

type Scheduler

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

Scheduler fires tasks on their schedule.

func New

func New(tasks []kernel.Task, opts Options) (*Scheduler, error)

New parses the tasks and returns the scheduler.

An unparseable spec is an error at construction rather than a task that silently never runs -- which is the failure mode of every scheduler that validates lazily.

func (*Scheduler) Diagnose

func (s *Scheduler) Diagnose(ctx context.Context) []string

Diagnose reports tasks that are overdue or that failed.

It feeds the error page through kernel.Diagnostic. A task that stopped firing looks exactly like a task with nothing to do, and the gap between the last run and the schedule is what tells them apart.

func (*Scheduler) List

func (s *Scheduler) List() []Registered

List returns the registered tasks with their next run.

func (*Scheduler) RunNow

func (s *Scheduler) RunNow(ctx context.Context, id, tenant string) error

RunNow runs one task by id, outside its schedule.

Same lock, same Grant, same instrumentation -- which is what makes the manual run auditable rather than a back door.

func (*Scheduler) Start

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

Start runs the loop until Stop.

func (*Scheduler) Stop

func (s *Scheduler) Stop(ctx context.Context) error

Stop cancels the loop and waits for the run in flight.

Waiting matters: a task killed halfway is a task whose lock is still held and whose work is half done, and the next window will not know either.

func (*Scheduler) Tick

func (s *Scheduler) Tick(ctx context.Context, at time.Time)

Tick fires everything due in the minute of at.

Exported because `aru schedule:run` and the tests drive the same code path the loop drives. A second entry point that "runs a task manually" would be a second implementation, and the manual one always ends up subtly different.

type Tenants

type Tenants func(ctx context.Context) ([]string, error)

Tenants returns the tenants a PerTenant task expands to.

Injected, because the core does not know where the application keeps its tenants -- a table, a config file, a control plane. Returning an empty list is valid and means the task simply does not run.

Jump to

Keyboard shortcuts

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