Scheduling
Every job has a schedule_type that decides which fields govern when it fires.
schedule_type |
Fields it uses |
|---|---|
cron |
cron_expression |
rate |
cron_expression, as an @every <duration> descriptor |
one_off |
run_at, run_at_fired_at, one_off_after_run — cron_expression is ignored entirely |
webhook |
None — it holds no schedule fields, and fires only on an inbound POST |
Expressions are parsed by robfig/cron v3 in standard 5-field form:
minute hour day-of-month month day-of-weekSeconds are not enabled — a 6-field expression is not valid here. Descriptors are
supported: @hourly, @daily, @weekly, @monthly, @every <duration>.
Expressions are validated when you write the row, so a malformed expression is rejected at the API rather than silently failing later.
Timezone
Section titled “Timezone”Cron expressions are evaluated in zilean-core’s local time, as set by the TZ
environment variable. There is no per-job timezone and no scheduler-wide override.
Because this is invisible when wrong, the server reports what it actually resolved:
curl https://<your-host>/api/meta# {"timezone":"America/Los_Angeles"}A rate schedule is written into the same cron_expression column as an @every
descriptor:
@every 90s@every 5m@every 1h30mThe shape is validated against @every <duration>, and the duration itself is parsed by
the cron library. There is no separate code path — rate rows go through the identical
reconciler as cron rows.
One-off
Section titled “One-off”A one-off job fires exactly once, at run_at.
- It is never cron-scheduled; the reconciler skips one-off rows outright.
- It is inserted directly onto the queue with a scheduled delivery time of
run_at, the moment it becomes eligible — on create, on update, or on being enabled. run_at_fired_atis the “already inserted” guard. It is set at insert time, not run time, and is server-managed — ignored on create and update.- The queue insert and the
run_at_fired_atmarker commit together in one transaction. If the insert fails, the job stays unfired and can be retried, and it is never inserted twice. run_atmust be in the future when the job has not yet fired, and becomes immutable once it has.- Switching
schedule_typeaway fromone_offclearsrun_at_fired_at. pool_countmust be1.POST /jobs/{id}/runis rejected with 409 for a one-off job — a manual trigger would run it early, which is precisely what a one-off schedule exists to prevent.
What happens after it runs
Section titled “What happens after it runs”one_off_after_run decides:
| Value | Behavior |
|---|---|
keep (default) |
The run finishes, the row is disabled, and it stays for inspection. |
delete |
The row is removed once the run finishes. |
This is applied on every terminal path, including retry exhaustion, so a failing
one-off is still cleaned up according to its setting. Unknown values fall through to
keep.
Webhook
Section titled “Webhook”A webhook job fires when something outside Zilean sends it an HTTP POST. There is no
time or interval involved — the inbound request is the trigger.
- It is never cron-scheduled; the reconciler skips webhook rows outright, exactly as it skips one-off rows. No cron entry is ever created for it.
cron_expressionandrun_atare both meaningless for it. A leftovercron_expressionis accepted and ignored rather than rejected.- Each webhook job has its own URL,
POST /api/webhooks/{job_id}/{token}. The token in the path is the credential — the route sits outside admin session auth, so anything holding the URL can fire the job. - The token is generated the moment
schedule_typeis set towebhook. It is stored only as a sha256 digest, so the plaintext is shown exactly once — in the response to the create or update that set the schedule type, or from a rotate action. Afterwards the job carries an 8-characterwebhook_token_hintso you can tell which token is in use. - Rotating the token invalidates the old URL immediately, with no redeploy. Rotation
is an explicit action — from the admin UI or
POST /api/jobs/{id}/webhook/rotate— and a routine edit of the job never rotates the token, so a URL a third party already holds keeps working across unrelated edits. POST /jobs/{id}/runis rejected with 409, the same as a one-off job: a manual trigger would fire it with an empty payload, and the run’s source should stay unambiguous.pool_countandoverlap_policywork exactly as for any other schedule type. A webhook job can havepool_count > 1, and under the defaultoverlap_policy: skip, a call that arrives while a previous loop is still in flight returns 409 — retryable by whatever is sending the webhook.- Webhook chains are capped at 8 hops. A webhook job can itself call another webhook
job, and every HTTP request a run makes carries
X-Zilean-Webhook-Hops—1when the run was not webhook-triggered, otherwise the inbound hop count plus one. Zilean sets that header itself, overriding any header of the same name configured on the job. The receiving endpoint refuses a request that arrives at 8 or more hops, so a chain that loops back on itself terminates instead of running forever. Workflow HTTP nodes do not send the header — a workflow cannot be webhook-triggered. - A job cannot target its own webhook URL. Saving an existing job — an edit or an
import update — whose URL points at its own
/api/webhooks/…endpoint is rejected with 400. The check inspects the URL as written, including a templated host such as{{variable}}in the host position, but it cannot see a path assembled from variables at call time; the webhook hop limit bounds that case. A create is not checked, because the token does not exist yet at that point.
Payload
Section titled “Payload”The inbound request body and its metadata reach the triggered job:
- For an HTTP job, they are available as flat substitution variables, the same
{{name}}mechanism as global variables:{{webhook_body}},{{webhook_event}},{{webhook_content_type}},{{webhook_request_id}},{{webhook_received_at}}and{{webhook_job_id}}. - For a script job, the body arrives on stdin, and the metadata as
ZILEAN_WEBHOOK_*environment variables. The payload never reachesscript_argsor the command line, and is never shell-interpolated — it can change what data the already-chosen script reads, never what the script itself does. - The inbound hop count is among those variables:
ZILEAN_WEBHOOK_HOPSis set on a webhook-triggered script run and absent otherwise. It is advisory — a script that forwards the call to another webhook must sendX-Zilean-Webhook-Hopsitself, incrementing the value it received and treating an absent value as0.
Both survive a retry and a loop continuation: the payload and metadata are forward-copied into follow-up loop iterations.
Request handling
Section titled “Request handling”zilean-webhook enforces these itself, in addition to whatever your reverse proxy does:
- The request body is capped at 256 KiB.
- The request must carry a JSON
Content-Type; anything else is a 415. - Every lookup failure returns the same 404 — a wrong token, an unknown or disabled job, and a disabled group are indistinguishable, so the endpoint never reveals which case applied.
- A request whose
X-Zilean-Webhook-Hopsheader is 8 or more is refused with a 508 (webhook hop limit exceeded) and the job never fires. An absent or malformed header counts as0, and the hop check never reveals whether a job exists — a bad token or unknown job still gets the same 404. - A call that arrives while a run is already in flight for that pool index is a 409, retryable by the sender.
- A successful call is a 202. The payload is never logged or echoed — only the job id, byte size, content type and a sha256 of the body.
Because this route is public and effectively unauthenticated apart from the per-job token,
the reverse-proxy rate limit is the primary control, with an in-process per-IP limiter
as defense-in-depth. The same layering applies to the admin UI’s unauthenticated login
and invite-acceptance POSTs: each is per-IP rate limited (by default 10 POSTs per minute,
burst 5, configurable) and body-capped in-process, and the proxy limit in front of them
remains the primary control. With UI_TRUST_PROXY_HEADERS unset, clients behind a
reverse proxy share one bucket.
See Deploying for the routes and
Environment Variables for
WEBHOOK_TRUST_PROXY_HEADERS and UI_TRUST_PROXY_HEADERS.
The reconciler
Section titled “The reconciler”zilean-core reconciles the registry against its in-memory cron schedule every 30
seconds. This interval is not configurable. It also reconciles immediately on startup.
What happens each tick:
- Enabled jobs are re-read from the registry.
- A job whose
cron_expressionis unchanged is left alone, so its next-run timer is not reset by an unrelated edit. - A job whose expression changed has its entry removed and re-added.
- An invalid expression is logged and skipped.
- Jobs that are disabled, deleted, or gated behind a disabled group have their entries removed.
Why most edits take effect immediately
Section titled “Why most edits take effect immediately”Only cron_expression is cached by the scheduler. The queued job payload carries just
{job_id, loop_iteration, pool_index}, and the worker re-reads the job row on every
attempt.
So a change to the URL, method, headers, auth, loop settings, retry settings or script body takes effect on the very next attempt — no restart, no reconcile wait. A change to the schedule itself takes effect within 30 seconds.
Double-fire protection
Section titled “Double-fire protection”Inserts are made with a uniqueness guard scoped to the reconcile interval, so the same firing cannot be duplicated across two close ticks. A manual trigger deliberately skips this — it should fire now, not deduplicate against a recent cron tick.
Disabling jobs
Section titled “Disabling jobs”Disabled state is checked twice, deliberately:
- The reconciler only schedules enabled jobs (and skips jobs whose group chain is disabled).
- The worker re-checks
enabledand the group chain at execution time and exits cleanly if either is off.
So disabling a job stops work already queued, not just future firings.
Retention sweeps
Section titled “Retention sweeps”Core runs four cleanup passes once at startup and then every 24 hours:
| Sweep | Retention |
|---|---|
worker_metrics |
30 days |
job_call_stats |
30 days |
| Expired sessions | 24-hour grace |
audit_log |
Runtime-configurable (see Users & permissions) |
Stale lock backstop
Section titled “Stale lock backstop”If a worker process crashes mid-attempt, its overlap lock would otherwise be held forever. A lock older than one hour is stolen by the next firing.
A legitimately long-running job never trips this: the lock timestamp is refreshed on every attempt, so a multi-hour loop stays fresh. See Loop, retry & pooling.