How it works
An occurrence is a work item
Apiary polls this plugin on the source's poll_interval. Each call is a fresh
process: the daemon writes one JSON request to stdin, reads one JSON response
from stdout, and the process exits. Nothing survives in memory between calls.
On each poll the plugin asks, for every routine: which occurrences of this cron expression have come due since the last one I emitted? Each answer becomes a work item whose id is the routine id and the occurrence instant:
That id is Apiary's dedup key, so it must be stable per occurrence — which is
exactly what a cron instant is. It is always rendered in UTC, so changing a
routine's timezone cannot make an already-emitted occurrence look new.
The item carries a routine:<id> label, so a workflow trigger can select one
routine without your config repeating itself, plus metadata (routine_id,
scheduled_for, schedule, timezone) readable from the task.
The cursor
Because the process holds no state, the "last occurrence emitted" cursor lives
in state_file:
{
"version": 1,
"routines": {
"nightly-dep-audit": {
"last_emitted": "2026-08-27T03:00:00Z",
"first_seen": "2026-08-20T14:22:10Z",
"emitted_count": 7
}
}
}
It is written atomically (temp file plus rename) before the poll answers, so a crash mid-write cannot leave a truncated cursor. If the cursor cannot be persisted the poll fails rather than emitting: the host logs it and retries on the next interval, which is the same recovery an unreachable ticket API gets.
That ordering makes the failure mode on a crash a skipped occurrence rather than a duplicated one. This is deliberate. A skipped run is visible as a gap and re-fires next occurrence; a duplicated run leaves an operator with two results and no way to tell which to trust.
Why not the host's since watermark?
Apiary passes a since timestamp on every poll, and it would be the
obvious cursor. It is not usable: the daemon keeps it in process memory and
resets it to zero on restart, so the first poll after every restart asks
for all of history. Trusting it would emit a backlog on each daemon
restart. The plugin treats since as informational.
A new routine emits nothing
The first poll that sees a routine registers a floor (first_seen) and emits
nothing. Adding a routine at noon must not immediately fire this morning's
03:00 occurrence, and catch-up must never reach back to before the routine was
configured.
The first real occurrence therefore fires at the first scheduled instant after you added it.
Missed occurrences
When the daemon is down, occurrences pass unobserved. What happens on the next
poll depends on catch_up:
catch_up |
Behaviour |
|---|---|
false (default) |
The whole missed window collapses to one run — the most recent missed occurrence. Ten missed nightly audits fire one audit. |
true |
The backlog is emitted in order, oldest first, bounded by max_per_poll — so a long outage drains over several polls instead of dispatching a week of agent runs in one tick. |
The default is off because "the nightly audit did not run last night" is worth acting on once and worthless five times.
The storm cap
max_per_poll (default 5) bounds how many occurrences all routines together
may emit in one poll. It mirrors the guardrail Apiary's own Prometheus source
uses against alert storms. Overflow stays in the state file and surfaces on the
next poll.
Daylight saving
Occurrences are computed in the routine's own location, so 0 3 * * * in
America/Sao_Paulo means 03:00 there regardless of where the daemon runs.
Across a DST boundary the two awkward cases are handled by construction:
- Spring forward, where the scheduled wall-clock time does not exist: the underlying cron implementation serves the day at the next valid instant. Occurrences stay strictly ordered, so the cursor always advances.
- Fall back, where the wall-clock time happens twice: the two occurrences are different instants, and because occurrence ids are rendered in UTC they get different ids. Neither is swallowed as a duplicate.