Synsemadocsv0.6.xENES

Biblioteca estándar

Cron

Un scheduler de background integrado. Cada job corre en su propio thread, sin bloquear, y ejecuta su task de verdad — los contadores de cron_list() reflejan ejecuciones reales.

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")

Programar§

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

El task debe ser de 0 parámetros y estar definido en el top-level (el job lo ejecuta por nombre). Un task con parámetros obligatorios falla en la registración con un error claro — envolvelo en un task sin argumentos. cron_every exige un intervalo positivo; cron_after acepta delay 0 (ejecuta ya mismo). El argumento task es la referencia (sync_inventory) o su nombre como texto ("sync_inventory"); ambos builtins devuelven el nombre del job (texto), que es lo que toma cron_cancel(nombre).

Gestionar§

cron_cancel("sync_inventory")       -- stop a job
let jobs be cron_list()             -- list all jobs
print(cron_status())                -- formatted status

Cada entrada de cron_list() trae name, schedule ("every 300.0s", "after 60.0s" o la expresión cron), interval (segundos, o nothing en jobs por expresión), repeating, active, run_count (ejecuciones completadas), errors (ticks que terminaron en error), next_run (timestamp unix del próximo disparo) y tz ("UTC", "+HH:MM" o el nombre IANA, como "America/Santiago"; nothing en jobs por intervalo). cron_status() imprime lo mismo como texto: [active] daily_report: at '0 9 *' (UTC), next 2026-08-30T09:00:00Z, runs: 3, errors: 0. Registrar un job con el mismo nombre reemplaza al anterior (los contadores arrancan de cero).

Semántica§

- una hora fija (30 2 ) que cae en la hora que se saltea en primavera corre apenas después del salto en vez de perderse; - una hora fija en la hora que se repite en otoño corre una vez, la primera; - un job con en el campo de minuto u hora (/15 , 0 *) sigue el tiempo real: los minutos salteados no existen, y la hora repetida corre dos veces.

Una expresión inválida, una que nunca matchea (0 0 31 2 *), una zona desconocida, una opción desconocida u opciones con un intervalo numérico fallan en la registración; no queda nada programado.

Bajo serve: estado compartido§

Bajo synsema serve, los jobs corren con el mismo estado compartido y las mismas capabilities que las rutas: db, state_*, memoria, blackboard. Los jobs del top-level arrancan recién cuando el server ya está sirviendo, y un cron_every registrado desde una ruta funciona y es visible globalmente.

task tick()
    state_incr("heartbeats", 1)

cron_every(60, tick)

serve on 8080
    route "GET /health"
        give state_get("heartbeats")

Mantener los jobs vivos§

Bajo run, los jobs ejecutan mientras el programa viva y se detienen cuando termina. Los jobs comparten estado entre ellos (un job puede leer lo que otro escribió con state_* o remember). Para intercambiar datos con el resto del programa, usá efectos externos: un archivo (write_file/read_file), una base en disco, o el blackboard (share/observe). Bajo serve no hace falta nada de esto: jobs y rutas ya ven el mismo estado compartido.

Usá synsema serve para mantener el proceso — y la programación — corriendo, incluso sin rutas:

synsema serve scheduler.syn         -- "Serving N cron job(s). Press Ctrl+C to stop."

Los threads esperan estacionados (cero CPU entre ticks); el intérprete de cada job se construye una vez y se reusa.