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 theapp/schedules/convention, binds theschedulermanager and thescheduler.runsrun store as lazy singletons, and contributes the static-config check tozt doctor.onBooting— resolves theschedulerbinding so it is ready before boot finishes.onBooted— subscribes the run log to task events and lazily registers theschedule:listandschedule:runscommands (when a command runner is present).onStarted— callsscheduler.start(), arming every registered cron.onStopped— callsscheduler.stop(), so nothing leaks between boots or test suites.
Note — The provider itself loads in
web,console, andworker, but theapp/schedules/discovery convention runs only inworker(to execute the tasks) andconsole(soschedule:listcan enumerate them). It never runs inweb, 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"),
});
| Field | Required | Default | Description |
|---|---|---|---|
timezone | no | the system zone | IANA 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 (
Schedulesubclass) — the default for anything non-trivial. It's auto-discovered, testable in isolation, and keeps each task in its own file underapp/schedules/. - The
Schedulerfacade — 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:
| Setting | Type | Description |
|---|---|---|
handle() | method (required) | The work to perform on each run. |
cron | string | Cron expression (5- or 6-field). Set this or override frequency(). |
frequency(every) | method | Build the cadence fluently; return the task (see helpers below). |
name | string | Task name in schedule:list and logs. Defaults to the class name. |
timezone | string | IANA timezone the cron is evaluated in — overrides scheduler.timezone. See Timezones. |
withoutOverlapping | boolean | OverlapLockOptions | Skip a tick while a previous run is active; also takes a cross-process lock when a lock driver is configured. |
environments | string[] | Only run when APP_ENV is one of these. |
inBackground | boolean | Run 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 / pingOnFailure | string | Health-check URLs fetched at each lifecycle point. |
appendOutputTo | string | Append captured console output to a file. |
emailOutputTo | string | Email captured console output (needs an output mailer). |
when() | method → boolean | Dynamic guard — run only when truthy. |
skip() | method → boolean | Dynamic guard — skip when truthy. |
Warning — These are instance properties.
static cron = "…"typechecks (it merely declares a new static member) but registers nothing — unlikestatic fillableon a model orstatic layouton a Flow component. Discovery warns at boot when it sees static schedule config, andbun zt doctorreports 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.
| Method | Cron expression | Description |
|---|---|---|
.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 * * 0 | Midnight every Sunday |
.mondays() … .sundays() | 0 0 * * N | A specific weekday at midnight |
.weekdays() / .weekends() | 0 0 * * 1-5 / 6,0 | Mon–Fri / Sat–Sun |
.days([1, 4]) | 0 0 * * 1,4 | Specific weekdays |
.monthly() | 0 0 1 * * | Midnight on the 1st |
.twiceMonthly(1, 16) | 0 0 1,16 * * | Two days each month |
.lastDayOfMonth("23:00") | guarded | Last 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") | custom | Any 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 awhen()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 method | Effect |
|---|---|
.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/emailOutputTo | Capture 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:
| Driver | Sees a worker on… |
|---|---|
sqlite (default) | another process on the same box |
redis | another machine |
memory | nothing — 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 }
| Name | Description |
|---|---|
Heartbeat | beat() records a check-in, start() beats on an interval and returns a stopper, lastSeen() reads one. |
Beat | What a check-in records — at, pid, detail. |
BeatLookup | The three answers: seen, never, and unknown with a reason. |
workerLivenessCheck | Builds the doctor check above for a worker kind. |
describeBeat | Renders 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 / method | Behaviour |
|---|---|
appendOutputTo | Append captured output to a file (keeps history). |
sendOutputTo | Overwrite a file with the latest run's output. |
emailOutputTo | Email the output — requires an output mailer (see below). |
Note —
sendOutputTois a facade-only tuning method; on aSchedulesubclass, use theappendOutputTooremailOutputToproperties.
// 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})`));
| Event | Fields | Emitted when |
|---|---|---|
TaskRan | name, durationMs, ok | A run finishes (success or handled failure). |
TaskFailed | name, durationMs, error | The handler throws (error is the message). |
TaskSkipped | name, reason | A 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 inworkerandconsole, 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:
| Command | What it does |
|---|---|
bun zt schedule:list | List scheduled tasks with their next run time |
bun zt schedule:runs [name] | Recent recorded runs, newest first (--limit to page) |
SchedulerManager
| Method | Signature | Description |
|---|---|---|
add | add(name: string, cron: string, cb: TaskCallback): ScheduledTask | Register a task from a raw cron expression. |
job | job(name: string, cb: TaskCallback): SchedulerBuilder | Start a fluent definition; pick a cadence next. |
start | start(): void | Arm every registered task (called in onStarted). |
stop | stop(): void | Stop every running task. |
tasks | get 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.
| Export | Signature | Description |
|---|---|---|
isValidTimeZone | isValidTimeZone(tz: string): boolean | Whether this runtime knows the IANA zone. Check before storing one a user typed. |
wallClockIn | wallClockIn(date: Date, tz: string): Date | The same instant, shifted so the Date's local getters read that zone's clock face. |
CronExpression.matchesIn | matchesIn(date: Date, tz: string): boolean | Whether the expression fires at date, read in tz. |
CronExpression.nextRunAfterIn | nextRunAfterIn(expr, from: Date, tz): Date | null | The 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
| Error | Thrown when |
|---|---|
SchedulerError | Base class for everything this package throws. Catch it to catch them all. |
UnknownTimeZoneError | A task declares a timezone this runtime does not know — at registration, naming the task. |
ScheduledTask introspection
| Member | Signature | Description |
|---|---|---|
runNow() | runNow(): Promise<void> | Runs the handler now, skipping all guards. |
nextRunAt(from?) | nextRunAt(from?: Date): Date | null | Next fire time after from (or null if never). |
lastRunAt | get lastRunAt(): Date | undefined | When the task last ran, or undefined. |
lastOk | get lastOk(): boolean | undefined | Whether the last run succeeded. |
lastDurationMs | get lastDurationMs(): number | undefined | Duration of the last run in ms. |
isRunning | get isRunning(): boolean | true while a run is in flight. |
Types
| Type | What it is |
|---|---|
CronExpression | The schedule string a task declares. |
TaskGuard | A condition deciding whether a due run actually happens — a feature flag, a leader election. |
TaskHook | What runs before or after a task. |
ScheduleRunRecord, ScheduleRunStore | One recorded run, and where the log is kept. |
RunLogConfig | How much of that log is retained. |
OutputMailer | Sending 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
FrameworkEventsbus the task events flow through. - Notifications — wire the output mailer for
emailOutputTo.