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
cron_after(3600, send_reminder) -- once, after 1 hour
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, interval, repeating, active, run_count (completed executions) and errors (ticks that ended in an error). Registering a job with the same name replaces the previous one (counters restart from zero).
Semantics
- Intervals, not wall-clock cron. The interval is a fixed delay between the end of one execution and the start of the next — there are no
"0 9 MON"expressions or clock alignment. - 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 missed runs are not replayed.
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.