Schedules
Cron helpers, every-intervals, app timezones, per-tenant rows, leader locks, catch-up one, and Console overrides.
Named schedules trigger Flows on a calendar cron or a fixed duration. Declare with
clock / helpers, set the zone once on the app, then bind with on(clockDecl, flow).
For developers running daily digests, health pings, and per-tenant billing loops on okengine.
The one rule
Calendar work: prefer oke({ clock: { timezone } }) (or defineConfig) and omit
timezone on each schedule. Intervals: durations are ms|s|m|h|d only — no weeks.
Both shapes share leader locks, catch-up "one", and the Store.
Two kinds, one species
on(trigger, flow)clock.every("sweep", "1h")fixed interval- Console
- Listed, with health
- Use
- Interval — purge, sweep, ping
clock.daily("report", { at: "06:00" })wall clock- Console
- Listed, with health
- Use
- Named — zone from oke({ clock }) · pause / edit when overridable
one Flow
Same on(trigger, flow). The do never knows whether an interval or a wall-clock helper woke it.
Smallest Example
Declare a schedule and bind a Flow
import { clock } from "okengine";
export const dailyReportClock = clock.daily("reports.daily", { at: "06:00" });import { on, flow } from "okengine";
import { dailyReportClock } from "@/clocks/reports";
export const runDailyReport = on(
dailyReportClock,
flow("reports.runDaily", {
plane: "operator",
do: async (_, fx) => {
await fx.call(buildDailyReport, { at: fx.clock.now() });
},
}),
);Set the zone once so each schedule stays short:
import { oke } from "okengine";
export const app = oke({
name: "notes",
clock: { timezone: "Asia/Riyadh" },
});Or defineConfig({ clock: { timezone: "Asia/Riyadh" } }). oke({ clock }) wins over
config; a per-clock timezone wins over both.
What fires
Every morning at 06:00 in the declared zone, the leader instance runs reports.runDaily.
do receives no payload — use fx.clock.now() when you need the fire instant.
With oke dev, the scheduler reconciles reports.daily into the Store and leader-elects
before each tick. Under drivers.clock.test = "frozen", advance time in tests instead of
waiting for dawn.
Jobs are Flows
on(clockDecl, flow) is the same species as HTTP handlers and Signal consumers. There is no
separate job runner — see Consumers · Clock Jobs.
Progressive Patterns
From helpers and intervals to per-tenant rows and Console-tunable schedules:
Named helpers compile to the same ClockDecl (a five-field cron or every string):
import { clock } from "okengine";
export const daily = clock.daily("reports.daily", { at: "06:00" });
export const rollup = clock.hourly("metrics.rollup", { minute: 0 });
export const digest = clock.weekly("notes.digest", {
on: ["mon", "fri"],
at: "09:00",
});
export const close = clock.monthly("billing.close", { on: [1, 15], at: "00:00" });
export const ping = clock.every("health.ping", "30s");Prefer the named helpers above for fixed schedules. The bare callable remains fully
supported for the same ClockDecl shape when you need a raw string or a schedule
chosen programmatically: clock("x", { cron: "0 6 * * *" }), Bun nicknames
(@hourly), and clock("x", { every: "30s" }).
Consequence: Manifest / Store still see cron: "0 6 * * *" — helpers are declare-time sugar.
Helper Reference
| Helper / form | Signature | Default / notes | Compiles to |
|---|---|---|---|
clock | clock(name, { cron? | every?, … }) | Need cron and/or every | same |
clock.daily | clock.daily(name, { at?, … }) | at default "00:00"; invalid at → cron at: expected "HH:MM" | five-field cron |
clock.hourly | clock.hourly(name, { minute?, … }) | minute default 0 | five-field cron |
clock.weekly | clock.weekly(name, { on, at?, … }) | on required (sun…sat / 0–6); at default "00:00" | five-field cron |
clock.monthly | clock.monthly(name, { on, at?, … }) | on required (day of month); no “last day” token | five-field cron |
clock.cron | clock.cron(name, expr | fields, opts?) | Empty field bag throws; invalid string → Bun.cron.parse | five-field / nickname |
clock.every | clock.every(name, duration, opts?) | Same duration grammar as fx.clock.ago / sleep | every string |
clock.perTenant | clock.perTenant(name, opts) | Expands {name}#{tenantId} rows | same + perTenant |
At least one of cron or every is required on the callable form. Empty options throw
clock("name"): require cron or every. Invalid cron throws at declare (invalid cron …).
An empty clock.cron field bag throws
cron fields: require at least one of at, minute, hour, dayOfMonth, month, dayOfWeek.
Timezone Resolution
Cron math needs an IANA zone. Resolve it once; override only when a schedule must differ.
| Source | Wins when | Example |
|---|---|---|
Per-clock timezone | Always | clock.daily("x", { at: "06:00", timezone: "UTC" }) |
oke({ clock: { timezone } }) | No per-clock zone | oke({ name: "app", clock: { timezone: "Asia/Riyadh" } }) |
defineConfig({ clock: { timezone } }) | No oke({ clock }) | config default |
| Built-in default | Nothing else set | "UTC" |
import { oke } from "okengine";
import { clock } from "okengine";
export const app = oke({
name: "notes",
clock: { timezone: "Asia/Riyadh" },
});
// Inherits Asia/Riyadh
export const digest = clock.daily("notes.digest", { at: "08:00" });
// Explicit UTC wins over the app default
export const utcRollup = clock.hourly("metrics.utc", {
minute: 0,
timezone: "UTC",
});{ tz: "…" } is not an option — it is ignored. Intervals (every) are duration-based and
do not consult the zone for tick spacing; the zone still lands on the Store row.
Binding & Input
Bind with on(clockDecl, flow("name", { do })). One Flow can write clock.every
inside on() — Clock · Inline or named export.
do has no payload; read time through fx.clock:
import { clock } from "okengine";
export const cleanupClock = clock.every("metrics.cleanup", "1h");import { on, flow } from "okengine";
import { lt } from "drizzle-orm";
import { cleanupClock } from "@/clocks/metrics";
import { db, metricLogs } from "@/schema";
export const cleanup = on(
cleanupClock,
flow("metrics.cleanup", {
plane: "operator",
do: async (_, fx) => {
await fx
.store(db)
.delete(metricLogs)
.where(lt(metricLogs.timestamp, fx.clock.ago("7d")));
return { at: new Date(fx.clock.now()).toISOString() };
},
}),
);| Concern | Rule |
|---|---|
| Trigger | on(clockDecl, flow) — same Flow species as HTTP / Signal |
| Input | none — use _ |
| Time | fx.clock.now / ago / fromNow / duration only |
| Plane | Prefer plane: "operator" for background work |
| Import | The clock(…) module must load at boot so reconcile sees the decl |
Options Reference
Shared options on clock(name, options) and helpers:
| Option / helper | Type | Default | Meaning |
|---|---|---|---|
cron | string | — | Five-field cron or Bun nickname |
every | string | — | Interval ("30s", "1h", "7d", …) |
timezone | IANA string | app default / "UTC" | Zone for cron math |
overridable | boolean | false | Console may override schedule in the Store |
perTenant | boolean | false | Expand {name}#{tenantId} rows |
description | string | the name | Console / docs blurb |
oke({ clock: { timezone } }) | app option | — | Default zone when timezone omitted |
defineConfig({ clock: { timezone } }) | config | — | Same; overridden by oke({ clock }) |
Duration units (every)
| Unit | Example | Milliseconds |
|---|---|---|
ms | "200ms" | 200 |
s | "30s" | 30_000 |
m | "5m" | 300_000 |
h | "1h" | 3_600_000 |
d | "7d" | 604_800_000 |
Per-tenant Schedules
clock.perTenant expands one Store row per tenant ({name}#{tenantId}). The bare
template name never ticks — runNow("invoices") returns false.
Tenant ids come from reconcile (tenantIds). New tenants get rows; deleted tenants mark
those rows orphaned.
| Shape | Example | Fires? |
|---|---|---|
| Template (declared name) | invoices | No — never ticked |
| Expanded row | invoices#acme | Yes — leader-elected |
Consequence: ten tenants → ten leases. Catch-up is "one" per row, so downtime
does not replay a burst of missed hours for each tenant.
Leader Lock
Multi-instance deploys need a shared clock driver — otherwise every pod fires the same tick. Dev/prod default is postgres (test is frozen). A short lease (default 30s) means only one instance runs each fire.
Consequence: three pods calling runNow still execute the Flow once. After the lease
expires, another instance may take the next tick.
| Driver | Cross-instance lock | Best for |
|---|---|---|
postgres | Yes — shared oke_crons | Dev/prod multi-instance (default) |
file | Yes — on one host (.oke/crons.json) | Single-host multi-process |
memory | No — single process only | Local single process |
frozen | Test harness — you drive tick / advance | Tests |
Catch-up Policy
Catch-up — count every miss, fire once
catchUp: "one"missMissed slots (health)
+1+2+3+4+5
- missedRuns
- 1
- catchUp
- "one"
Runtime fire
Five ghosts stay counted. The handler runs once when the lease is taken — not once per missed hour.
Catch-up is "one": after 5 hours down on an hourly clock, the next tick fires once.
Missed slots are visible as missedRuns — they are not replayed as a burst.
Consequence: a digest that missed the night still runs once at boot, not 24 times.
Console health on each row exposes driftMs, overdue, missedRuns, and catchUp: "one".
Flow retry is separate — it retries a failed fire, not missed calendar slots.
Store Lifecycle & Console
Named clocks reconcile into the Store at boot. The scheduler reads effective state from the Store, never the source declaration directly.
Declared (Manifest / code) ← truth for names and defaults
Override (Store, overridable) ← operational drift
Effective = declared + override ← what actually runs| Status | Meaning | Fires? |
|---|---|---|
active | Reconciled and enabled | Yes (when due + lease) |
paused | Operator paused via Console | No — until active again |
orphaned | Decl removed (or tenant gone) | No — row kept, never deleted |
Reconcile upserts still-declared clocks as active (preserving lease / lastRunAt /
overrides when overridable). Removed declarations become orphaned.
| Console action | Requirement | Error when missing |
|---|---|---|
| Run now | Active row + lease | ClockResourceNotFoundError — cron "{id}" not found |
| Pause | Existing row | same |
| Edit cron/every | overridable: true | ScheduleNotOverridableError |
Consequence: without overridable: true, change the schedule in source and redeploy —
Console edits are refused. Redeploying a still-overridable clock preserves overrides.
DST gap / fall-back overlap attaches a Store warning (gap / overlap); UTC never
warns; the scheduler still ticks. Detection covers simple M H * * * / M H * * DOW
forms; on overlap days crontab fires once (first occurrence).
| Prefer | When |
|---|---|
timezone: "UTC" | No DST warnings |
Non-ambiguous local hour (08:00, not 02:00) | Digests that stay silent on DST days |
Troubleshooting
Pass {cron} and/or {every}, or use a helper (clock.daily, clock.every, …). Declaration
throws before on().
Cause: the expression failed Bun.cron.parse. Fix the five-field string / nickname, or fix
structured fields (at must be "HH:MM"). Error shape: clock("…"): invalid cron "…" (…).
at on helpers / field bags must be "HH:MM" or "H:MM" with hour 0–23 and minute 0–59.
"6am" and "06:00:00" fail.
Unknown duration strings parse as 0 and never become due. Use ms|s|m|h|d only. Confirm the
Flow is bound with on(clockDecl, flow) and the clock module is imported at boot.
Use timezone: "Asia/Riyadh" on the declaration, or set the app default with
oke({ clock: { timezone: "Asia/Riyadh" } }) /
defineConfig({ clock: { timezone: "Asia/Riyadh" } }). { tz: "…" } is ignored.
Catch-up is "one" per Store row. A burst usually means several clocks or clock.perTenant rows
(one per tenant), not a replay of missed slots.
Need shared drivers.clock postgres (or file on one machine). memory does not elect a leader
across processes.
Expected for perTenant: true — only {name}#{tenantId} rows fire. Ensure tenant ids are
available at reconcile.
Cause: clock "{name}" is not overridable. Add overridable: true and redeploy, or change the
schedule in source.
Cause: cron "{id}" not found (or run "{id}" not found). The Console action targeted a name
that is not in the reconciled Store — check spelling against your clock() declarations.
Cause: A clock flow on "{trigger}" has no name.
Fix: pass an explicit name — on(clockDecl, flow("reports.runDaily", { do })).
Inline clock.every still needs flow("…") — Clock · Inline or named export.
Informational. Pick a UTC cron, a non-ambiguous local hour, or accept the warning. The job still schedules.
Console pause sets status: "paused". The scheduler skips it. A later reconcile of a
still-declared clock restores active (and keeps overrides when overridable).
Learn more
- Clock overview —
fx.clockhelpers and drivers - Clock · Inline or named export — one Flow vs shared schedule
- Durable Sleep —
fx.clock.sleep(label, duration) - Consumers · Clock Jobs — bind with
on(clockDecl, flow) - fx —
fx.clock.now/ago/fromNow/duration - Errors —
ScheduleNotOverridableError·ClockResourceNotFoundError· OKE1072