Skip to main content
zerotal

Scheduler

Run tasks on a cron-like schedule without editing the system crontab. Drop a Schedule subclass in app/schedules/ and it is auto-registered at boot; a fluent Scheduler facade is also available for quick inline definitions. Schedules fire in the worker process (bun zt worker).

Getting Started

# in your project root
bun add @zerotal/scheduler

Register the provider

Add SchedulerProvider to the providers array in bootstrap/providers.ts:

// bootstrap/providers.ts
import { SchedulerProvider } from "@zerotal/scheduler";

const providers = [
  // …your other providers
  SchedulerProvider,
];

export default providers;

Registering the provider switches on the following, in lifecycle order:

  • onRegister — registers the app/schedules/ convention, binds the scheduler manager and the scheduler.runs run store as lazy singletons, and contributes the static-config check to zt doctor.
  • onBooting — resolves the scheduler binding so it is ready before boot finishes.
  • onBooted — subscribes the run log to task events and lazily registers the schedule:list and schedule:runs commands (when a command runner is present).
  • onStarted — calls scheduler.start(), arming every registered cron.
  • onStopped — calls scheduler.stop(), so nothing leaks between boots or test suites.

Note — The provider itself loads in web, console, and worker, but the app/schedules/ discovery convention runs only in worker (to execute the tasks) and console (so schedule:list can enumerate them). It never runs in web, so your HTTP instances don't fire cron. See Conventions.

Configuration

Create config/scheduler.ts with the SchedulerConfig() helper so every field stays type-checked:

// config/scheduler.ts
import { SchedulerConfig } from "@zerotal/scheduler";
import { env } from "zerotal";

export default SchedulerConfig({
  timezone: env("APP_TIMEZONE", "Africa/Johannesburg"),
});
FieldRequiredDefaultDescription
timezonenothe system zoneIANA zone every cron expression is read in, unless a task sets its own timezone.

Timezones

A cron expression is a wall clock: 0 3 * * * means three in the morning somewhere. By default that somewhere is the server's zone. Set scheduler.timezone to make it one zone for the whole app, or timezone on a single schedule to override it:

export class SendDailyReports extends Schedule {
  cron = "0 8 * * *";
  timezone = "Africa/Johannesburg"; // 08:00 there, whatever the server is on
}

The zone is evaluated by Zerotal, not by Bun.cron — which reads the system zone and has no option to change it. A zoned task ticks every minute and runs on the ticks where its expression matches the clock in its own zone, so it stays correct across a daylight-saving change rather than drifting by an hour twice a year. A minute is also the finest granularity Bun.cron accepts, so nothing is given up.

Two consequences worth knowing:

  • A skipped hour skips the schedules inside it, and a repeated hour runs them twice. That is what every cron does. A task at 0 2 * * * in a zone that springs from 01:59 to 03:00 does not run that day.
  • An unknown zone name refuses at boot, loudly, and takes only its own task out. A registration failure used to propagate: the worker died during boot and restart-looped, so one bad schedule stopped every schedule in the app. Now the others start and the log names the one that did not.

Defining schedules

Create a class that extends Schedule, put the work in handle(), and declare the cadence with either a cron string or the fluent frequency() method. Every Schedule subclass under app/schedules/ is discovered and registered automatically — no manual wiring, no central list.

// app/schedules/SendDailyReports.ts
import { Schedule } from "@zerotal/scheduler";
import { Queue } from "@zerotal/queue";
import { SendReportsJob } from "../jobs/SendReportsJob.ts";

export class SendDailyReports extends Schedule {
  cron = "0 8 * * *"; // every day at 08:00
  timezone = "Africa/Johannesburg";
  withoutOverlapping = true;

  async handle(): Promise<void> {
    await Queue.dispatch(new SendReportsJob());
  }
}

Prefer the fluent frequency builder over a raw cron string when it reads better — override frequency() and return a configured task:

// app/schedules/WarmCache.ts
import { Schedule, type SchedulerBuilder } from "@zerotal/scheduler";

export class WarmCache extends Schedule {
  override frequency(every: SchedulerBuilder) {
    return every.everyFiveMinutes();
  }
  withoutOverlapping = true;

  async handle(): Promise<void> {
    await Cache.forget("posts:page:1");
  }
}

Class-based vs the facade — which should I use?

  • Class-based (Schedule subclass) — the default for anything non-trivial. It's auto-discovered, testable in isolation, and keeps each task in its own file under app/schedules/.
  • The Scheduler facade — reach for it for one-liners or inline definitions inside a provider (see Inline schedules).

Settings reference

Every setting is an optional property (or method) on your Schedule subclass:

SettingTypeDescription
handle()method (required)The work to perform on each run.
cronstringCron expression (5- or 6-field). Set this or override frequency().
frequency(every)methodBuild the cadence fluently; return the task (see helpers below).
namestringTask name in schedule:list and logs. Defaults to the class name.
timezonestringIANA timezone the cron is evaluated in — overrides scheduler.timezone. See Timezones.
withoutOverlappingboolean | OverlapLockOptionsSkip a tick while a previous run is active; also takes a cross-process lock when a lock driver is configured.
environmentsstring[]Only run when APP_ENV is one of these.
inBackgroundbooleanRun the body without blocking the scheduler tick.
between[string, string]Only run between "HH:MM" and "HH:MM".
unlessBetween[string, string]Never run between "HH:MM" and "HH:MM".
pingBefore / pingAfter / pingOnSuccess / pingOnFailurestringHealth-check URLs fetched at each lifecycle point.
appendOutputTostringAppend captured console output to a file.
emailOutputTostringEmail captured console output (needs an output mailer).
when()method → booleanDynamic guard — run only when truthy.
skip()method → booleanDynamic guard — skip when truthy.

Warning — These are instance properties. static cron = "…" typechecks (it merely declares a new static member) but registers nothing — unlike static fillable on a model or static layout on a Flow component. Discovery warns at boot when it sees static schedule config, and bun zt doctor reports it.

// app/schedules/NightlyBackup.ts
import { Schedule, type SchedulerBuilder } from "@zerotal/scheduler";

export class NightlyBackup extends Schedule {
  frequency(every: SchedulerBuilder) {
    return every.dailyAt("02:30");
  }
  environments = ["production"];
  between: [string, string] = ["00:00", "05:00"];
  pingOnSuccess = "https://hc-ping.com/abc";

  async handle(): Promise<void> {
    /* … */
  }
  when() {
    return featureFlags.backupsEnabled;
  }
}

Frequency helpers

The frequency(every) builder (and the Scheduler facade) expose fluent cadence methods. Each returns the configured task.

MethodCron expressionDescription
.everySecond()* * * * * *Every second (6-field)
.everyFiveSeconds()*/5 * * * * *Every five seconds
.everyThirtySeconds()*/30 * * * * *Every thirty seconds
.everyMinute()* * * * *Every minute
.everyFiveMinutes()*/5 * * * *Every five minutes
.everyFifteenMinutes()*/15 * * * *Every fifteen minutes
.everyThirtyMinutes()*/30 * * * *Every thirty minutes
.hourly()0 * * * *Top of every hour
.hourlyAt(15)15 * * * *A specific minute each hour
.daily()0 0 * * *Midnight every day
.dailyAt("13:30")30 13 * * *A specific time daily
.twiceDaily(1, 13)0 1,13 * * *Two specific hours daily
.weekly()0 0 * * 0Midnight every Sunday
.mondays() … .sundays()0 0 * * NA specific weekday at midnight
.weekdays() / .weekends()0 0 * * 1-5 / 6,0Mon–Fri / Sat–Sun
.days([1, 4])0 0 * * 1,4Specific weekdays
.monthly()0 0 1 * *Midnight on the 1st
.twiceMonthly(1, 16)0 0 1,16 * *Two days each month
.lastDayOfMonth("23:00")guardedLast calendar day of the month
.quarterly() / .quarterlyOn()0 0 1 1,4,7,10 *First day of each quarter
.yearly() / .yearlyOn()0 0 1 1 *Once a year
.cron("0 9 * * 1")customAny raw cron expression

Warning — Sub-minute cadences use a 6-field cron (sec min hour day month weekday). They only make sense in a long-lived worker process — don't pair them with inline polling. .lastDayOfMonth() schedules a daily check (28-31) guarded by a when() that fires only on the actual last day.

Inline schedules

For quick, in-code definitions (e.g. inside a provider) use the Scheduler facade, which exposes the underlying manager fluently:

// in a provider's onBooted()
import { Scheduler } from "@zerotal/scheduler";

Scheduler.job("cleanup-sessions", () => Session.prune()).daily();
Scheduler.job("warm-cache", () => Cache.forget("posts:page:1")).cron("*/5 * * * *");

// Or register a task directly:
Scheduler.add("rotate-logs", "0 */6 * * *", () => rotateLogs());

job() returns a SchedulerBuilder; each cadence method returns the ScheduledTask, so you can chain the same fluent tuning the class form exposes declaratively:

// in a provider's onBooted()
Scheduler.job("nightly-backup", () => runBackup())
  .dailyAt("02:30")
  .timezone("Africa/Johannesburg")
  .withoutOverlapping({ expiresAfterMinutes: 30 })
  .environments(["production"])
  .between("00:00", "05:00")
  .onSuccess(() => logger.info("backup ok"))
  .onFailure((err) => logger.error("backup failed", err))
  .pingOnSuccess("https://hc-ping.com/abc");
Tuning methodEffect
.timezone(tz)Evaluate the cron in an IANA timezone.
.withoutOverlapping(opts?)Skip a tick while a prior run is active.
.environments([...])Only run in the listed APP_ENVs.
.between(s, e) / .unlessBetween(s, e)Time-window guards ("HH:MM").
.when(fn) / .skip(fn)Dynamic run / skip guards.
.runInBackground()Don't block the scheduler tick.
.onStart/onSuccess/onFailure(fn)Lifecycle callbacks (failure receives the Error).
.pingBefore/pingAfter/pingOnSuccess/pingOnFailure(url)Health-check pings.
.appendOutputTo/sendOutputTo/emailOutputToCapture console output (see below).

Tip — Prefer class-based schedules for anything non-trivial — they're discoverable, testable, and keep each task in its own file. Reach for the facade for one-liners.

Listing schedules

# in your project root
bun zt schedule:list

Prints every registered task with its cron expression, a human-readable description of the cadence, and the next computed run time:

Scheduled tasks (2)
  Name         SendDailyReports
  Expression   0 8 * * *
  Description  At 08:00 every day
  Next run     2026-06-22T06:00:00.000Z

Is anything actually running them?

Schedules register in the worker and console environments, not in web. That is the right design — HTTP instances should not run cron — and it means a second process is required. The framework starts happily without one.

An app shipped to production with no worker, and every scheduled task silently did not execute for weeks. No hold was released, no reminder was sent, nothing logged, because from the web process's point of view nothing was wrong. They found it by going looking.

zt doctor now looks for you:

✖ Scheduler — 3 schedule(s) registered, and no worker has ever checked in. Nothing is
  running them. This is silent by nature — the web process has no way to notice, and
  the work simply does not happen.
    fix: Start the worker process: `bun zt worker`. It is a second process; the web
         server does not run this.

The worker records a check-in every minute while it runs. A check-in older than fifteen minutes is a warning rather than a failure, because a worker mid-restart is not a missing worker.

It needs a shared cache to mean anything

The beat is written to the cache, because the process reading doctor is not the process running the schedules — and often not the same machine. Your cache driver decides what that can see:

DriverSees a worker on…
sqlite (default)another process on the same box
redisanother machine
memorynothing — it is private to each process

On memory the check stands aside and says so rather than reporting a missing worker. A check that cried wolf on every app using the memory driver is one people would learn to skip, and then it would not be there for the case it exists for.

Reading it yourself

The primitive is @zerotal/core/heartbeat, if you want the same signal on an ops page:

import { Heartbeat } from "@zerotal/core/heartbeat";

const seen = await Heartbeat.lastSeen("scheduler");
// { status: "seen", ageSeconds, beat } | { status: "never" } | { status: "unknown", reason }
NameDescription
Heartbeatbeat() records a check-in, start() beats on an interval and returns a stopper, lastSeen() reads one.
BeatWhat a check-in records — at, pid, detail.
BeatLookupThe three answers: seen, never, and unknown with a reason.
workerLivenessCheckBuilds the doctor check above for a worker kind.
describeBeatRenders a BeatLookup as the prose used in the report.

unknown is a distinct answer from never on purpose, and every consumer has to keep them apart: one means nothing is running, the other means this process cannot see whether anything is.

Run history

Every completed execution — success or failure — is recorded to a capped JSONL file under storage/framework/, so the history survives restarts. "Did the retention sweep run last night?" is answered from the record, not from memory:

# in your project root
bun zt schedule:runs                  # recent runs, newest first
bun zt schedule:runs popia:sweep      # one task's runs
bun zt schedule:runs --limit 50
Recent runs (2)
  Task      popia:sweep
  Started   2026-08-10T03:00:00.000Z
  Duration  5210 ms
  Result    OK

Configure it under runLog in config/scheduler.ts — enabled (default: on, except under APP_ENV=test), path, and keep (records retained after compaction, default 500). The store is bound in the container as scheduler.runs; rebind it to keep the history somewhere else, such as Redis. The monitoring panel's scheduled-tasks section reads the same record, so a task that last ran before a deploy shows that run — marked "(recorded)" — instead of "Never run".

Skipped ticks (environment, time window, when()/skip() guards, overlap) are deliberate non-runs and are not recorded.

Preventing overlapping runs

A long task can still be running when its next tick fires. withoutOverlapping skips the new tick while the previous run is active:

// app/schedules/RebuildSearchIndex.ts
import { Schedule } from "@zerotal/scheduler";

export class RebuildSearchIndex extends Schedule {
  cron = "*/5 * * * *";
  withoutOverlapping = true; // in-process guard

  async handle(): Promise<void> {
    /* … */
  }
}

The true form always guards within a single process, and — when a lock driver is configured (Redis or SQLite via the lock primitive) — also takes a cross-process lock so only one worker runs the task per tick across all your machines. Cross-process locking is on by default; pass { crossProcess: false } to guard within this process only:

// app/schedules/RebuildSearchIndex.ts
withoutOverlapping = { expiresAfterMinutes: 30 }; // cross-process (default)
// withoutOverlapping = { crossProcess: false };        // in-process guard only

With no lock driver registered it degrades to the in-process guard. A skipped tick emits a TaskSkipped event with reason "overlap" (in-process) or "lock" (cross-process).

expiresAfterMinutes is a recovery time, not a duration budget. The lock is refreshed while the task runs — see Long-running work — so it only has to outlive a missed heartbeat. It answers "how long after this host dies before another may take the task over", and defaults to 5 minutes.

That is a change in meaning worth knowing if you set it before: it used to have to cover the longest the task might ever run, which is why it defaulted to 24 hours and why a crashed scheduler could block a task until the next afternoon. A long-running task no longer needs a long value here — set one only if you want a crash to take longer to recover from.

Pass { refresh: false } for the old behaviour, where the task must finish inside expiresAfterMinutes or lose its lock.

Capturing output

Anything the task writes to console.log can be persisted or emailed:

Setting / methodBehaviour
appendOutputToAppend captured output to a file (keeps history).
sendOutputToOverwrite a file with the latest run's output.
emailOutputToEmail the output — requires an output mailer (see below).

Note — sendOutputTo is a facade-only tuning method; on a Schedule subclass, use the appendOutputTo or emailOutputTo properties.

// app/schedules/GenerateSitemap.ts
import { Schedule } from "@zerotal/scheduler";

export class GenerateSitemap extends Schedule {
  cron = "0 3 * * *";
  appendOutputTo = "storage/logs/sitemap.log";

  async handle(): Promise<void> {
    console.log(`Sitemap generated with ${count} URLs`); // captured to the log
  }
}

emailOutputTo needs a mailer wired once at boot — set ScheduledTask.outputMailer, a (email, subject, body) => void | Promise<void> function, in a provider's onBooted() (without it, the output is logged with a notice instead of sent):

// in a provider's onBooted()
import { ScheduledTask } from "@zerotal/scheduler";
import { Notify } from "@zerotal/notifications";

ScheduledTask.outputMailer = async (email, subject, body) => {
  // ScheduleOutputNotification implements toMail() from subject/body.
  await Notify.send({ email }, new ScheduleOutputNotification(subject, body));
};

Observability — task events

Every run emits a framework event you can listen for to feed metrics, logs, or alerts. Subscribe in a provider's onBooted():

// in a provider's onBooted()
import { FrameworkEvents } from "zerotal";
import { TaskRan, TaskFailed, TaskSkipped } from "@zerotal/scheduler";

FrameworkEvents.on(TaskRan, (e) => metrics.timing(`schedule.${e.name}`, e.durationMs));
FrameworkEvents.on(TaskFailed, (e) => logger.error(`schedule ${e.name} failed: ${e.error}`));
FrameworkEvents.on(TaskSkipped, (e) => logger.debug(`schedule ${e.name} skipped (${e.reason})`));
EventFieldsEmitted when
TaskRanname, durationMs, okA run finishes (success or handled failure).
TaskFailedname, durationMs, errorThe handler throws (error is the message).
TaskSkippedname, reasonA tick is skipped before running.

TaskSkipped.reason is one of "env", "window", "when", "skip", "overlap", or "lock" — matching each guard.

In the monitoring panel

A cron task that silently stops firing is one of the harder failures to notice: nothing errors, work just stops happening. When @zerotal/monitor is installed, the scheduler contributes a Scheduled tasks section to it — no configuration, just both providers registered.

It leads with counts of tasks that are currently running, failing, or have never run at all, then lists every task with its cron expression, last result, run duration and next due time. The "never run" count is the one worth watching: a task that has been registered but never fired usually means a guard or an environment filter is excluding it.

The scheduler does not depend on the monitor package to do this — it resolves the panel's contribution surface from the container at boot and describes the section as data. To keep the scheduler but drop the section, set sections: { scheduler: false } in config/monitor.ts.

Testing

ScheduledTask exposes introspection getters and a runNow() that executes the handler immediately, bypassing the cron/time-window guards — ideal in tests:

// in a test
import { Scheduler } from "@zerotal/scheduler";

const task = Scheduler.job("report", () => generateReport()).dailyAt("08:00");

await task.runNow(); // run the body now, ignoring the schedule
expect(task.lastOk).toBe(true);
expect(task.lastRunAt).toBeInstanceOf(Date);

// Assert the cadence without waiting for the clock
const next = task.nextRunAt(new Date("2026-06-21T09:00:00Z"));
expect(next?.toISOString()).toBe("2026-06-22T08:00:00.000Z");

Running the worker

Schedules fire in worker mode — a separate Bun process started by the CLI. When bun zt worker boots, the framework sets APP_ENV=worker.

# in your project root
bun zt worker       # starts the queue worker + scheduler

For simpler deployments, AppServiceProvider.onStarted() can run inline polling instead of a dedicated worker process (skip it when this IS the worker):

// app/providers/AppServiceProvider.ts (onStarted)
override async onStarted(): Promise<void> {
  if (Bun.env.APP_ENV === "worker") return; // dedicated worker handles it

  setInterval(async () => {
    if (Queue.isShuttingDown) return;
    await Queue.processNext("default").catch(console.error);
  }, 500);
}

For production, run the worker as a separate process so it can be scaled, restarted, and monitored independently of the web server.

A web process says so when it is not running your schedules. app/schedules/ is only discovered in worker and console, so a web process skips it by not looking — which used to be completely silent, and is how an app runs for weeks in production with every schedule written and none of them ever firing. A boot line now names it:

Skipping 3 file(s) in app/schedules — the "schedules" convention does not run in env=web (it runs in: worker, console).

Seeing that on a web process is correct. Seeing it and having no worker running is the hole.

References

The Scheduler facade resolves the scheduler container binding — a SchedulerManager. job() returns a SchedulerBuilder; cadence methods return a ScheduledTask.

Commands

@zerotal/scheduler ships one command:

CommandWhat it does
bun zt schedule:listList scheduled tasks with their next run time
bun zt schedule:runs [name]Recent recorded runs, newest first (--limit to page)

SchedulerManager

MethodSignatureDescription
addadd(name: string, cron: string, cb: TaskCallback): ScheduledTaskRegister a task from a raw cron expression.
jobjob(name: string, cb: TaskCallback): SchedulerBuilderStart a fluent definition; pick a cadence next.
startstart(): voidArm every registered task (called in onStarted).
stopstop(): voidStop every running task.
tasksget tasks(): ReadonlyMap<string, ScheduledTask>The registered tasks, keyed by name.

Timezone helpers

The zone arithmetic the scheduler uses to evaluate a cron somewhere other than the server, exported because an app doing its own time-window logic needs the same answers.

ExportSignatureDescription
isValidTimeZoneisValidTimeZone(tz: string): booleanWhether this runtime knows the IANA zone. Check before storing one a user typed.
wallClockInwallClockIn(date: Date, tz: string): DateThe same instant, shifted so the Date's local getters read that zone's clock face.
CronExpression.matchesInmatchesIn(date: Date, tz: string): booleanWhether the expression fires at date, read in tz.
CronExpression.nextRunAfterInnextRunAfterIn(expr, from: Date, tz): Date | nullThe next real instant the expression fires on that zone's clock — correct across a DST change.

wallClockIn returns a Date that is a lie about the instant and true about the clock face: its epoch value is off by the zone offset. Pass it to a field comparison, never back to a caller.

Errors

ErrorThrown when
SchedulerErrorBase class for everything this package throws. Catch it to catch them all.
UnknownTimeZoneErrorA task declares a timezone this runtime does not know — at registration, naming the task.

ScheduledTask introspection

MemberSignatureDescription
runNow()runNow(): Promise<void>Runs the handler now, skipping all guards.
nextRunAt(from?)nextRunAt(from?: Date): Date | nullNext fire time after from (or null if never).
lastRunAtget lastRunAt(): Date | undefinedWhen the task last ran, or undefined.
lastOkget lastOk(): boolean | undefinedWhether the last run succeeded.
lastDurationMsget lastDurationMs(): number | undefinedDuration of the last run in ms.
isRunningget isRunning(): booleantrue while a run is in flight.

Types

TypeWhat it is
CronExpressionThe schedule string a task declares.
TaskGuardA condition deciding whether a due run actually happens — a feature flag, a leader election.
TaskHookWhat runs before or after a task.
ScheduleRunRecord, ScheduleRunStoreOne recorded run, and where the log is kept.
RunLogConfigHow much of that log is retained.
OutputMailerSending a task's output somewhere when it finishes.

Next steps

  • Queue — schedules typically dispatch jobs; the worker runs both.
  • Conventions — how app/schedules/ is discovered.
  • Events — the FrameworkEvents bus the task events flow through.
  • Notifications — wire the output mailer for emailOutputTo.