Scheduling
import "github.com/gp-system/gpsystem/scheduler"
scheduler lets you define your schedule in code, not a crontab: you build a Schedule fluently and the worker runs it. Running on a single server among replicas is the default behavior here, not an opt-in: among N worker replicas, a Redis-lease leader election decides which one fires, with no extra flag and no cache lock to configure.
You can schedule two kinds of things:
- an event: a fire is a plain dispatch, the event fans out to all of its listeners;
- a job: a single named handler, no fan-out.
Building the schedule
Every module's generated jobs/register.go provides a Register(sched, deps); the worker composes them into a single schedule. The shop's nightly report was generated by add job shop nightlySalesReport --cron "0 3 * * *":
package jobs
import (
"github.com/acme/shop/internal/modules/shop"
"github.com/gp-system/gpsystem/scheduler"
// gpsystem:job-imports
)
func Register(sched *scheduler.Schedule, deps shop.Dependencies) {
sched.Job("shop.nightlySalesReport", "0 3 * * *", nightlySalesReport(deps))
// gpsystem:jobs
}
The job's handler stub lives in its own file; the body is yours:
package jobs
import (
"context"
"github.com/acme/shop/internal/modules/shop"
)
func nightlySalesReport(deps shop.Dependencies) func(context.Context) error {
return func(ctx context.Context) error {
report, err := buildSalesReport(ctx, deps.DB)
if err != nil {
return err
}
return deps.Mailer.Send(ctx, salesReportMessage(report))
}
}
A returned error triggers a retry (up to the default budget of 25, or the entry's own queue.MaxRetry); a transient DB error doesn't cost you the report.
Event schedules vs job schedules
The Schedule fluent API: every method returns *Schedule, so calls chain.
| Method | Fires |
|---|---|
Cron(spec, ev, opts...) | on a cron expression |
Every(d, ev, opts...) | every d (translates to an @every descriptor) |
EveryMinute(ev, opts...) | at the top of every minute |
Hourly(ev, opts...) | at the top of every hour |
Daily(ev, opts...) | at midnight, per SCHEDULER_TIMEZONE |
DailyAt("HH:MM", ev, opts...) | daily at the given time (24-hour; panics at setup on a malformed value) |
Job(name, spec, fn, opts...) | runs fn func(context.Context) error on a cron |
Which one when? An event schedule when the fire needs several independently retried reactions, or when the same event is also dispatched at runtime: the listeners don't know and don't need to know whether a cron or a user action dispatched it. A job schedule when it is one named piece of work, like the shop's report.
// Event schedule: every fire dispatches a fresh event that fans out
// to all of its listeners, exactly like a runtime Dispatch.
sched.Every(15*time.Minute, shopevents.SyncInventory{})
sched.DailyAt("06:30", shopevents.DigestDue{})
// Job schedule: one handler, no fan-out; the task type is "job:<name>".
sched.Job("shop.nightlySalesReport", "0 3 * * *", nightlySalesReport(deps))
The opts... are the usual enqueue options (e.g. queue.OnQueue("reports"), queue.Timeout(10*time.Minute)), and apply to the task submitted on each fire.
One technical detail that provides the guarantees: when a scheduled event fires, the worker uses the trigger task's id as the envelope id. So every fire is a new dispatch (every listener receives it again), but a retry of the same fire is idempotent: it doesn't double the fan-out.
Cron syntax
The spec is the standard 5-field cron (minute hour day-of-month month day-of-week), plus asynq's descriptors:
"0 3 * * *" every day at 03:00
"*/10 * * * *" every 10 minutes
"0 8 * * 1" Mondays at 08:00
"@every 30m" every 30 minutes (not on the clock grid, counted from start)
"@daily" at midnight
Expressions are evaluated in SCHEDULER_TIMEZONE (an IANA name, e.g. Europe/Budapest); the default is UTC, so the shop's "0 3 * * *" is 03:00 UTC.
Replica safety: the leader election
The schedule is executed by the Runner, which you never construct by hand: worker.Run starts it automatically when the schedule is non-empty. Inside, asynq's Scheduler does the work, guarded by a Redis lease:
- Every worker replica tries to acquire the lease key (
SETNX, expiring afterSCHEDULER_LEASE_TTL). - Only the lease holder registers and runs the cron entries; it renews the lease every
LEASE_TTL/3(a Lua compare-and-act, so a stale old leader cannot clobber the new one). - If the leader dies or loses the lease, its firing stops, and another replica takes over after at most
LEASE_TTL.
A brief overlap is possible on handover (the old leader issued one more tick, the new one did too), but the rest of the system absorbs it: delivery is at-least-once anyway, and listeners are idempotent. When several applications (or environments) share one Redis, separate their lease keys with SCHEDULER_NAMESPACE; otherwise they share each other's leader election.
// "run on one server" is not an option here, it's the only mode of operation
sched.Job("shop.nightlySalesReport", "0 3 * * *", nightlySalesReport(deps))
What runs where
An important difference from a crontab: firing and execution are decoupled. The leader only enqueues: the schedule:<event> or job:<name> task lands on the queue, and any worker replica may process it, under the usual concurrency and queue weights. A heavy report therefore doesn't load "the scheduler machine"; it loads the pool.
Configuration
scheduler.Config arrives embedded in worker.Config, under the SCHEDULER_ prefix:
| Variable | Default | Meaning |
|---|---|---|
SCHEDULER_TIMEZONE | UTC | IANA timezone the cron expressions are evaluated in |
SCHEDULER_LEASE_TTL | 15s | the leader lease's TTL; the leader renews it every TTL/3 |
SCHEDULER_NAMESPACE | gpsystem | namespace of the lease key; set it to your app's name when several apps share one Redis |
Patterns used
Redis-lease leader election (SETNX to acquire, a Lua compare-and-renew script to renew): see Design patterns.