Synsemadocsv0.6.xENES

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.

cron.syn
-- 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§

- 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.

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.