Skip to content

Digging deeper

Task scheduling

Run functions, commands and queue jobs on cron expressions, in process or from one cron entry.

Last updated:

The scheduler runs your code on a timetable: send a report every morning, prune old rows every hour, retry failed payments every five minutes. You declare every task in Go, in one file, instead of maintaining crontab lines on a server. New to it? Follow the step-by-step guide below the reference.

Go
// routes/schedule.go, passed to vukem.WithSchedule by bootstrap
func Schedule(s *schedule.Schedule, app *vukem.App) {
	s.Command("reports:send", "--daily").DailyAt("08:00").WithoutOverlapping()
	s.Job(jobs.PruneSessions{}).Hourly()
	s.Func("ping", func(ctx context.Context) error { ... }).EveryFiveMinutes()
	s.Func("invoices", sendInvoices).Cron("30 9 1 * *").In(saoPaulo)
}

Frequencies#

MethodExpression
EveryMinute() (default)* * * * *
EveryFiveMinutes()*/5 * * * *
Every(15 * time.Minute)*/15 * * * * (whole minutes dividing an hour or a day)
Hourly(), HourlyAt(7)0 * * * *, 7 * * * *
Daily(), DailyAt("08:00")0 0 * * *, 0 8 * * *
Weekdays()keeps the time, Monday–Friday
WeeklyOn(time.Monday, "09:00")0 9 * * 1
MonthlyOn(1, "00:00")0 0 1 * *
Cron(expr)any five-field expression

Cron expressions support *, values, ranges, steps (*/15, 10-50/20), lists, JAN/MON names and @hourly, @daily, @weekly, @monthly, @yearly. When both day fields are set, a day matches either (Vixie cron). A bad expression panics at boot; the parser is fuzzed.

Time zones and overlap#

  • SCHEDULE_TIMEZONE (IANA name, default UTC) applies to every task; .In(loc) overrides one. The zone database is embedded, so it works on Windows hosts without one.
  • WithoutOverlapping() skips a run while the previous one still holds its lock (released when it ends, or after 24h). Locks use app.Cache(): set CACHE_STORE=database or use Redis when several servers run the schedule.
  • OnOneServer() runs each occurrence on one server: the first scheduler to take the occurrence's lock (task name and minute, kept for an hour) in app.Cache() runs it, the others skip it. It needs a cache the servers share (CACHE_STORE=database or Redis): with the in-process memory cache, or a custom store that does not declare cache.Sharer (see shared stores), the schedule does not build (schedule.ErrLocksNotShared), so serve and schedule:work stop at boot instead of running the task on every server.
  • Lock keys include the app name (APP_NAME, lowercased, other characters as -), so several apps can share one Redis or cache table; give each app a unique APP_NAME (or its own CACHE_PREFIX). The keys changed in v0.25.0: a run in progress during the upgrade deploy is not seen by the new version.

Running it#

serve (the app binary's default command, and vukem serve in development) runs the scheduler in process next to the HTTP server, so a single-process deploy (one Railway, Fly or Render service) still runs your tasks and the built-in hourly session:prune (SESSION_DRIVER=database) and cache:prune (CACHE_STORE=database). The scheduler ticks at the start of every minute, starts only when tasks exist, and stops with the server: on SIGINT/SIGTERM it stops ticking and gives running tasks SHUTDOWN_TIMEOUT to finish, then cancels their ctx before shutdown hooks run. Tasks must honor ctx (pass it to queries and HTTP calls, check it in loops): one that ignores it keeps the process alive until the platform kills it.

To run the schedule in its own process instead, set SCHEDULE_IN_SERVE=false on the web process and run one of:

Terminal
vukem schedule:work    # in process: ticks at the start of every minute
vukem schedule:list    # each task and its next run

Or one crontab entry calling schedule:run every minute:

Text
* * * * * cd /srv/app && ./app schedule:run

Several replicas#

Every process that runs the scheduler (each serve replica with the default SCHEDULE_IN_SERVE=true, plus any schedule:work) fires every due task. There is no leader election:

  • A task without WithoutOverlapping() runs once per replica each time it is due.

  • WithoutOverlapping() only skips a run while another one still holds the lock. With the default in-memory cache each replica has its own locks, so it protects nothing across replicas. With a shared store (CACHE_STORE=database or Redis) a replica skips the task while another is still running it, but a short task that finishes before the next replica checks can still run again in the same minute.

  • OnOneServer() with a shared cache runs the task once per occurrence across replicas.

The built-in prune tasks are idempotent, so running them on every replica is safe. For tasks that must run exactly once per schedule (emails, charges, draws), mark them OnOneServer() with a shared cache, or set SCHEDULE_IN_SERVE=false on the web replicas and run a single schedule:work process.

schedule:work stops the same way (running tasks get SHUTDOWN_TIMEOUT, then their ctx is cancelled); schedule:run passes its own ctx, cancelled on SIGINT/SIGTERM.

Tasks due at the same minute run concurrently; failures and panics are logged and do not stop the others. Command tasks run in the same process through app.Call.

Step by step: a daily cleanup task#

1. Write the task as a function that honours ctx

app/tasks/prune_drafts.go
// PruneDrafts deletes drafts nobody touched in 30 days.
func PruneDrafts(db *database.DB, now func() time.Time) func(context.Context) error {
	return func(ctx context.Context) error {
		_, err := db.ExecContext(ctx, `DELETE FROM posts WHERE status = 'draft' AND updated_at < ?`, now().AddDate(0, 0, -30))
		return err
	}
}

Passing ctx to the query lets the scheduler cancel it on shutdown.

2. Declare it in routes/schedule.go

routes/schedule.go
func Schedule(s *schedule.Schedule, app *vukem.App) {
	db, err := app.DB()
	if err != nil {
		app.Log.Error("schedule: database", "err", err)
		return
	}

	s.Func("posts:prune-drafts", tasks.PruneDrafts(db, app.Now)).
		DailyAt("03:00").
		WithoutOverlapping()
}

The starter's bootstrap/app.go already passes this function to vukem.WithSchedule. The name (posts:prune-drafts) appears in logs and in schedule:list.

3. Check the timetable

Terminal
vukem schedule:list
# TASK                 EXPRESSION  NEXT RUN
# posts:prune-drafts   0 3 * * *   2026-10-18T03:00:00Z

4. Run it

Nothing else is needed: vukem serve in development and the app binary's serve in production run the scheduler in process. To run every due task once, right now, use vukem schedule:run.

Tasks that are queue jobs or commands#

  • s.Job(jobs.SendDigest{}).WeeklyOn(time.Monday, "09:00") only dispatches the job; a queue worker runs it, with the queue's retries. Prefer it for work that may fail and should be retried.
  • s.Command("reports:send", "--daily") runs an app command in the same process, exactly as vukem reports:send --daily would.

Testing#

app.Schedule() returns the schedule your app declared, so a test can check that a task exists and when it runs next:

Go
s, err := app.Schedule()
if err != nil {
	t.Fatal(err)
}

for _, task := range s.Tasks() {
	if task.Name() == "posts:prune-drafts" {
		if got := task.Expression(); got != "0 3 * * *" {
			t.Fatalf("expression = %q", got)
		}
	}
}

Test the task's function itself like any other function: call it with a context.Context and a fake clock.

Pitfalls#

  • Times are UTC by default. DailyAt("08:00") is 08:00 UTC unless SCHEDULE_TIMEZONE or .In(loc) says otherwise. Around daylight-saving changes, a local time can be skipped or repeated.
  • Long tasks overlap. A five-minute task scheduled EveryMinute() runs five copies at once without WithoutOverlapping().
  • Several replicas run every task. Read Several replicas before scaling the web service.
  • Ignoring ctx. A task that does not pass ctx to its queries and HTTP calls delays shutdown until the platform kills the process.
  • Heavy work in a task. Tasks run inside the web process by default; dispatch a queue job for anything long or CPU-heavy so requests stay fast.

Security notes#

  • Tasks run with the app's full privileges and without a request, so there is no user to authorize: never build a task's behaviour from data a user can edit without validating it.
  • Make tasks that move money or send messages idempotent (check "already sent" before sending), so a duplicate run is harmless.
  • Failures and panics are logged with the task name; route them to your alerting (for example Telegram logging).