Async & Background

Scheduling

Task scheduling in code, cron events and jobs, replica-safe with a Redis-lease leader election.
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 * * *":

internal/modules/shop/jobs/register.go
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:

internal/modules/shop/jobs/nightly_sales_report.go
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.

MethodFires
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:

  1. Every worker replica tries to acquire the lease key (SETNX, expiring after SCHEDULER_LEASE_TTL).
  2. 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).
  3. 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.

gpsystem: internal/modules/shop/jobs/register.go
// "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:

VariableDefaultMeaning
SCHEDULER_TIMEZONEUTCIANA timezone the cron expressions are evaluated in
SCHEDULER_LEASE_TTL15sthe leader lease's TTL; the leader renews it every TTL/3
SCHEDULER_NAMESPACEgpsystemnamespace 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.

Copyright © 2026