iii-cron
v0.13.0Schedule functions with cron expressions.
Renamed to cron. Scheduled functions are now a standalone worker — install cron instead of the iii-cron engine builtin.
Use cron instead.
exact versions are immutable; binary and bundle artifacts are digest-pinned.
skill doc
Schedule a function on a cron expression
how-toWhen to use
Register a cron trigger when a function should run on a recurring schedule defined by a 6- or 7-field cron expression. The handler runs server-side, on a tokio task spawned by the engine, with once-only-execution semantics across a multi-instance fleet (when paired with the redis adapter). Each firing receives a payload that names which scheduled run fired and at what scheduled vs. actual time, so drift, reentrancy, and audit logging are all observable from inside the function.
Reach for it when:
- You need a function to run periodically without standing up a separate scheduler process or a system
crontabentry. - You need once-only firing across a fleet — nightly cleanup, hourly reports, batch maintenance — and you've configured
adapter.name: redisso the lock is shared across engine instances. - You want a scheduled job that is conditionally skipped (holiday calendar, feature flag, weekend pause) without threading the check inside the handler — set
condition_function_idand the engine evaluates it before each firing.
Use a state reactive trigger instead when the work should fire on data changes rather than on the clock. Use a stream reactive trigger instead when the work should fire on stream item changes or WebSocket lifecycle events.
Prerequisite: the iii-cron worker must be enabled in config.yaml (it is on by default). Handlers and triggers are registered from a connected worker via iii.registerFunction and iii.registerTrigger — the worker has no cron::* engine functions.
Inputs
Registration is a two-step pattern: define the handler function, then bind it to the cron trigger type.
Handler function
Register any function id. The handler receives the event payload documented in Outputs (same shape the engine passes to condition_function_id when configured).
// iii.registerFunction — handler id only; no engine payload.
{ "id": "jobs::cleanup-old-data" }Trigger registration
{
"type": "cron", // required. Must be exactly "cron".
"function_id": "jobs::cleanup-old-data", // required. Handler invoked on every firing.
"config": {
"expression": "0 0 2 * * * *", // required. 6- or 7-field cron expression: sec min hour dom month dow [year].
"condition_function_id": "jobs::is-weekday" // optional. Engine invokes this with the event; handler runs only on truthy.
}
}type, function_id, and config.expression are required. expression cannot be missing or empty — the worker rejects the registration synchronously when it can't parse it; the error surfaces as the Err from iii.registerTrigger.
The supported grammar is 6- or 7-field, ordered:
second minute hour day-of-month month day-of-week [year]The year field is optional. Six-field expressions imply year = *. Common patterns:
| Expression | Meaning |
|---|---|
0 * * * * * |
Every minute (at second 0). |
0 0 * * * * |
Every hour (at minute 0, second 0). |
0 0 2 * * * |
Every day at 02:00:00. |
0 0 0 * * 0 * |
Every Sunday at 00:00:00. |
0 */5 * * * * |
Every 5 minutes — note the leading 0 is seconds, not minutes; this fires at second 0, every 5 minutes. |
0 0 9-17 * * 1-5 * |
Every hour from 09:00 to 17:00, Monday–Friday. |
Reading a cron expression as if the leading position were minutes (the five-field convention many tools use) will produce schedules that fire 60× more often than intended. Always count fields and remember that position 0 is seconds.
condition_function_id is optional. When set, the engine calls the condition function with the same event payload the handler would receive and runs the handler only when the condition returns true. A condition that errors is logged and the handler is skipped (the distributed lock is released either way, so another instance does not attempt to re-fire the same scheduled run).
Outputs
When the schedule fires, the engine invokes function_id with this event object:
{
"trigger": "cron", // Always the literal string "cron".
"job_id": "jobs::cleanup-old-data", // The trigger id (same id you passed via `iii.registerTrigger({ id })` or the auto-derived id when you didn't pass one).
"scheduled_time": "2026-05-20T17:00:00.000+00:00", // When this firing was supposed to run, RFC 3339 UTC.
"actual_time": "2026-05-20T17:00:00.142+00:00" // When the worker actually invoked the handler, RFC 3339 UTC.
}triggeris always the literal"cron". Used to discriminate when one function is bound to multiple trigger types.job_idis the trigger's id. Useful when a single function is bound to multiple cron triggers (different schedules, different conditions) and the handler needs to dispatch on which one fired — for example, a reporting function bound to both an hourly summary trigger and a daily rollup trigger.scheduled_timeis the moment the cron schedule predicted this run for.actual_timeis the moment the worker actually invoked the handler. Subtractactual_time - scheduled_timeto surface scheduler drift caused by GC pauses, lock contention on the distributed adapter, or system load. Drift consistently above a few hundred milliseconds is a sign the engine is overloaded or thekvadapter is being hit cross-instance.- The same payload is passed to the condition function (when one is configured), so condition logic can branch on
job_idorscheduled_timewithout coordinating with the registration site.
The handler's return value is ignored. Errors from the handler are logged but do not affect the schedule; the next firing proceeds normally.
Worked example
Run a daily 02:00 UTC cleanup function:
{
"type": "cron",
"function_id": "jobs::cleanup-old-data",
"config": { "expression": "0 0 2 * * * *" }
}Three patterns reach for cron in different ways:
- Single scheduled handler. The registration above; the handler receives
trigger: "cron", the configuredjob_id, and a(scheduled_time, actual_time)pair on every firing — see the Outputs payload above for the exact shape. - Conditionally gated firing. Set
condition_function_idon the trigger config to a function that returns truthy on days/states when the handler should run. The condition receives the same event payload the handler would; onfalseor error the handler is skipped and the lock is released so the next firing proceeds normally. - Multiple schedules into one handler. Register the same
function_idagainst several triggers with distinct ids (e.g.jobs::generate-report.hourlyandjobs::generate-report.daily). Inside the handler, branch onevent.job_idto dispatch — the trigger id flows through asjob_idin every event.
For runnable scaffolds covering these patterns end-to-end (TypeScript, Python, and Rust), see the cron worker source and the SDK usage examples in the iii main repo.
Related
iii-cronadapter — the once-only-execution guarantee depends onadapter.name: redisfor multi-instance deployments. The defaultkvadapter only locks process-local (config keys:lock_ttl_ms,lock_index); on a multi-instance fleet every engine instance fires the same scheduled run. Switch toadapter.name: redis(config key:redis_url) so the lock is shared across instances.statereactive trigger — fire on data changes instead of on the clock.streamreactive trigger — fire on stream item changes; pair withcronfor "every hour, refresh the projection of the last hour's stream events."