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")
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)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("*/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 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") shifts the expression by a fixed offset. IANA zones with daylight saving (America/Sao_Paulo) are not supported and fail with a clear error — write the offset, or schedule in UTC. A bad expression, one that never matches (0 0 31 2 *), 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.