Standard library / primitives
Cron
A built-in background scheduler. Each job runs on its own thread, non-blocking, and truly executes its task — the counters in cron_list() reflect real executions.
-- Doc example: cron scheduler. Jobs run on background threads and EXECUTE their
-- task for real — the doctest asserts the observable effect, not just registration.
intent: "doc example: cron"
require time
require file("_doctest_cron.txt")
task write_marker()
write_file("_doctest_cron.txt", "cron ran")
task daily_report()
give "report"
print("scheduled jobs: " + text(length(cron_list())))
test "a scheduled job executes its task and the counters tell the truth"
cron_after(0.2, write_marker)
sleep(0.8)
assert_eq(read_file("_doctest_cron.txt"), "cron ran")
assert_eq(length(cron_list()), 1)
each j in cron_list()
assert_eq(j["name"], "write_marker")
assert_eq(j["run_count"], 1)
assert_eq(j["errors"], 0)
test "cron_every takes an IANA zone: 09:00 local time all year, shown in cron_list().tz"
let name be cron_every("0 9 * * *", daily_report, {"tz": "America/Santiago"})
assert_eq(name, "daily_report")
let j be where(cron_list(), (x) => x.name == "daily_report")[0]
assert_eq(j.tz, "America/Santiago")
assert_eq(j.schedule, "0 9 * * *")
assert_eq(j.interval, nothing)
let next be date_parts(datetime(j.next_run, "America/Santiago"))
assert_eq([next.hour, next.minute], [9, 0]) -- whatever the date, it is 09:00 there
assert(contains(cron_status(), "(America/Santiago)"))
assert_eq(cron_cancel("daily_report"), true)
test "a fixed offset still works; an unknown zone or options with an interval fail at registration"
cron_every("30 8 * * mon-fri", daily_report, {"tz": "-03:00"})
let j be where(cron_list(), (x) => x.name == "daily_report")[0]
assert_eq(j.tz, "-03:00")
let next be date_parts(datetime(j.next_run, "-03:00"))
assert_eq([next.hour, next.minute], [8, 30])
assert(next.weekday <= 5) -- mon-fri
cron_cancel("daily_report")
assert_error(() => cron_every("0 9 * * *", daily_report, {"tz": "Mars/Olympus"}))
assert_error(() => cron_every(60, daily_report, {"tz": "UTC"}))
assert_error(() => cron_every("0 0 31 2 *", daily_report)) -- never matches
assert_eq(length(where(cron_list(), (x) => x.name == "daily_report")), 0)
test "interval jobs have no zone"
cron_every(3600, daily_report)
let j be where(cron_list(), (x) => x.name == "daily_report")[0]
assert_eq(j.tz, nothing)
assert_eq(j.interval, 3600)
assert_eq(j.schedule, "every 3600.0s")
cron_cancel("daily_report")Scheduling§
task sync_inventory()
let data be http_get("https://api.warehouse.com/stock")
share data as "inventory"
cron_every(300, sync_inventory) -- every 5 minutes (interval: end of a run → next start)
cron_after(3600, send_reminder) -- once, after 1 hour
-- Wall-clock: a cron expression (5 fields) or an alias
cron_every("0 9 * * *", daily_report) -- every day at 09:00 UTC
cron_every("30 8 * * mon-fri", standup, {"tz": "-03:00"}) -- weekdays 08:30, fixed -03:00 offset
cron_every("0 9 * * *", daily_report, {"tz": "America/Santiago"}) -- 09:00 Santiago time, all year (DST included)
cron_every("*/15 * * * *", sync_inventory) -- :00 :15 :30 :45, aligned to the clock
cron_every("@hourly", rotate_logs) -- @hourly @daily @weekly @monthly @yearly
The task must take 0 parameters and be defined at the top level (the job runs it by name). A task with required parameters fails at registration with a clear error — wrap it in a zero-argument task. cron_every requires a positive interval; cron_after accepts a delay of 0 (runs right away). The task argument is the reference (sync_inventory) or its name as text ("sync_inventory"); both builtins return the job name (text), which is what cron_cancel(name) takes.
Managing§
cron_cancel("sync_inventory") -- stop a job
let jobs be cron_list() -- list all jobs
print(cron_status()) -- formatted status
Each cron_list() entry carries name, schedule ("every 300.0s", "after 60.0s" or the cron expression), interval (seconds, or nothing for expression jobs), repeating, active, run_count (completed executions), errors (ticks that ended in an error), next_run (unix timestamp of the next fire) and tz ("UTC", "+HH:MM" or the IANA name such as "America/Santiago"; nothing for interval jobs). cron_status() prints the same as text: [active] daily_report: at '0 9 *' (UTC), next 2026-08-30T09:00:00Z, runs: 3, errors: 0. Registering a job with the same name replaces the previous one (counters restart from zero).
Semantics§
- Two kinds of schedule. A number is an interval: a fixed delay between the end of one execution and the start of the next (it drifts by the run's duration — fine for "every 6 hours"). A text is a cron expression:
minute hour day month weekdaywith, rangesa-b, steps/n, lists, month/weekday names (jan..dec,sun..sat;0and7are Sunday), or an alias (@hourly,@daily,@weekly,@monthly,@yearly). When both day-of-month and weekday are restricted, either one matching fires the job (the classic Vixie rule). The job fires at the next matching minute after the previous run ends — occurrences that fall while a run is still in progress are skipped, never queued. - UTC by default. Like every
timebuiltin.{"tz": "-03:00"}(or"+05:30") reads the expression in a fixed offset.{"tz": "America/Santiago"}reads it in an IANA zone (engine v0.6.30+) and follows that zone's clock changes with the rule every Linux cron uses (Vixie cron / cronie):
- a fixed time (30 2 ) that falls in the hour skipped in spring runs right after the jump instead of being lost; - a fixed time in the hour repeated in autumn runs once, the first time; - a job with in the minute or hour field (/15 , 0 *) follows real time: skipped minutes do not exist, and the repeated hour runs twice.
A bad expression, one that never matches (0 0 31 2 *), an unknown zone, an unknown option or options with a numeric interval all fail at registration; nothing gets scheduled.
- No overlap. A job never runs two ticks at once: if the task takes longer than the interval, ticks serialize.
- Errors: the job keeps going. A runtime error in the task increments
errors, is logged through the server log ([serve] [cron] job 'x' failed: …), and the job stays scheduled for the next tick. The process never dies because of a failed tick. - In-memory state, no catch-up. A restart re-registers the jobs when the top level runs again;
run_countstarts from 0 and runs missed while the process was down do not exist — for expressions too. That is deliberate: the scheduler holds no state that could lie. If you need "it ran late", the program owns that decision — keeplast_runyourself (a file or a table) and compare it withnow()/next_runon boot, thencron_after(0, task)if it is overdue.
Under serve: shared state§
Under synsema serve, jobs run with the same shared state and the same capabilities as your routes: db, state_*, memory, blackboard. Top-level jobs start only once the server is already serving, and a cron_every registered from a route works and is globally visible.
task tick()
state_incr("heartbeats", 1)
cron_every(60, tick)
serve on 8080
route "GET /health"
give state_get("heartbeats")
Keeping jobs alive§
Under run, jobs execute while the program lives and stop when it ends. Jobs share state with each other (one job can read what another wrote via state_* or remember). To exchange data with the rest of the program, use external effects: a file (write_file/read_file), an on-disk database, or the blackboard (share/observe). Under serve none of this is needed: jobs and routes already see the same shared state.
Use synsema serve to keep the process — and the schedule — running, even with no routes:
synsema serve scheduler.syn -- "Serving N cron job(s). Press Ctrl+C to stop."
Job threads wait parked (zero CPU between ticks); each job's interpreter is built once and reused.