Cron job
Run a function on a schedule, once across the cluster
A cron job actor runs a function you supply on a schedule, across the cluster on a single node at a time. It is a cluster-wide singleton backed by one durable, repeating job : the schedule is registered exactly once, and each occurrence is leased so only one host runs it.
Registering#
Build a cron job with cronjob.New and register it on the host, before the host starts:
import "github.com/italypaleale/francis/builtin/cronjob"
cleanupJob, err := cronjob.New("nightly-cleanup",
cronjob.WithCron("0 2 * * *"),
cronjob.WithJob(func(ctx context.Context) error {
// ... runs once across the cluster, every night at 2am ...
return nil
}),
)
if err != nil {
return err
}
host, err := local.NewHost(/* ... options ... */)
if err != nil {
return err
}
// Register the built-in actor before calling host.Run
err = host.RegisterBuiltInActor(cleanupJob)RegisterBuiltInActor can be called more than once to register several built-in actors, and must be called before host.Run. Register the same cron job (same name and options) on every host that should be able to run it: at startup each host triggers the registration, but the schedule is set up only once for the cluster.
Options#
cronjob.New(name, opts...) takes a unique name (used to build the reserved actor type, and must not contain /) and these options:
| Option | Description |
|---|---|
WithJob(fn) | The function to run on each occurrence. Required. |
WithInterval(d) | Repeat every time.Duration d. |
WithPeriod(iso8601) | Repeat on an ISO 8601 duration string, e.g. "PT5M" or "P1D". |
WithCron(expr) | Repeat on a standard cron expression, e.g. "0 9 * * 1-5". |
WithImmediate() | Also run the job once right away, but only the first time it is registered. |
WithJitter(d) | Spread each occurrence by a random offset within ±d of its scheduled time. |
Exactly one of WithInterval, WithPeriod, or WithCron is required, and WithJob is required. Without WithImmediate, the first run happens after one interval (or at the next cron tick).
Jitter#
Cron jobs that share a schedule all fire at the same instant, so the work lands in one spike. For example, a fleet of jobs set to 0 2 * * * all start at exactly 2am. Use WithJitter to spread them apart:
cleanupJob, err := cronjob.New("nightly-cleanup",
cronjob.WithCron("0 2 * * *"),
// Each run lands somewhere between 01:55 and 02:05
cronjob.WithJitter(5*time.Minute),
cronjob.WithJob(func(ctx context.Context) error {
return nil
}),
)A fresh offset is drawn for every occurrence within ±d of the scheduled time. For example, with a cron job set for 0 2 * * * (every day at 2am) and a jitter of 5 minutes, jobs are executed at a random time between 1.55-2.05am.
Note: WithImmediate and Trigger runs are never jittered, since both mean “run now”.
How it works#
At startup each host bootstraps the cron job’s scheduler (the cluster-wide singleton), which sets up the schedule:
- If the schedule is already registered, bootstrapping does nothing — so it is safe for every host to trigger it, and it stays registered across restarts.
- Otherwise it dispatches the repeating job that drives the schedule and records its ID.
WithImmediateadditionally runs the job once right away on this first registration.
Because the actor is a single cluster-wide instance with turn-based execution, concurrent registrations from multiple hosts are automatically collapsed to a single recurring job. It is safe to re-register the actor on every instance in the cluster.
Triggering a run on demand#
The on-demand operations are bound to an actor.Service via Service(...), which you obtain from a host with host.Service()
Call Trigger on the resulting service to run the job once, immediately, regardless of the schedule:
cleanup := cleanupJob.Service(host.Service())
err := cleanup.Trigger(ctx)The run happens on the runner, so triggering returns promptly even if a previous run is still going. Multiple triggers that pile up while a run is still pending are collapsed into a single run.
Unregistering#
Calling Unregister cancels the recurring job and clears the actor’s state, so a later startup re-registers it cleanly:
cleanup := cleanupJob.Service(host.Service())
err := cleanup.Unregister(ctx)