Skip to content

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

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

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:

Terminal window
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 1h30m

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

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_at is 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_at marker commit together in one transaction. If the insert fails, the job stays unfired and can be retried, and it is never inserted twice.
  • run_at must be in the future when the job has not yet fired, and becomes immutable once it has.
  • Switching schedule_type away from one_off clears run_at_fired_at.
  • pool_count must be 1.
  • POST /jobs/{id}/run is 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.

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.

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_expression and run_at are both meaningless for it. A leftover cron_expression is 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_type is set to webhook. 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-character webhook_token_hint so 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}/run is 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_count and overlap_policy work exactly as for any other schedule type. A webhook job can have pool_count > 1, and under the default overlap_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 — 1 when 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.

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 reaches script_args or 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_HOPS is set on a webhook-triggered script run and absent otherwise. It is advisory — a script that forwards the call to another webhook must send X-Zilean-Webhook-Hops itself, incrementing the value it received and treating an absent value as 0.

Both survive a retry and a loop continuation: the payload and metadata are forward-copied into follow-up loop iterations.

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-Hops header is 8 or more is refused with a 508 (webhook hop limit exceeded) and the job never fires. An absent or malformed header counts as 0, 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.

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

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.

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.

Disabled state is checked twice, deliberately:

  1. The reconciler only schedules enabled jobs (and skips jobs whose group chain is disabled).
  2. The worker re-checks enabled and 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.

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)

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.