scheduler-service is the platform's clock — the daemon that turns the passage of time into events your playbooks can act on. It runs recurring cron grids, plain fixed intervals, one-shot instants and whole calendars of dates and, every time something is due, announces it on the internal event bus. It never does the downstream work itself: it simply fires a tick, and any number of business playbooks listen for that tick and decide what should happen. This register-once, react-many split keeps the timing logic in one place and the work wherever it belongs.
Good to know. scheduler-service is a publisher only. It emits ticks but consumes no work of its own. Think of it as a heartbeat: it tells the rest of the platform “it is now time for X,” and the playbooks you wrote take it from there.
You give scheduler-service a named schedule and exactly one time expression — a cron grid, a fixed interval, a single future instant, or a calendar of up to 100 instants. From then on, whenever the schedule comes due, the daemon publishes a single event — Fact.Schedule.Fired — stamped with the schedule's name. Your business playbooks trigger on that event and filter by name, so each schedule drives exactly the workflow you intended.
Because the daemon owns its own durable state, schedules survive restarts, fire even after downtime is over, and never fire twice for the same due moment. Each firing carries a stable identifier you can use as a de-duplication key, so a playbook can safely guarantee “do this work at most once per tick.” The result is a dependable timer you configure once and then forget about.
nightly-backup or dashboard-refresh). That name is what your playbooks filter on, and what shows up in the fired event.timezone (say Europe/Warsaw): cron expressions then keep local wall-clock hours through both daylight-saving transitions, and naive timestamps resolve in that zone. Leave it out and everything is evaluated in UTC, the same on every host.register_schedule upserts by name: re-registering an existing schedule updates its timing or payload in place and keeps its firing history; it does not create a duplicate.| What it is | The scheduler — cron grids, intervals, one-shots and calendars; the platform's time-driven heartbeat |
| Role | Publisher only — emits ticks, consumes no work |
| Playbook prefix | scheduler. |
| The event it emits | Fact.Schedule.Fired, carrying the schedule's name |
| Schedule format | Exactly one of cron_expr (5, 6, or 7 fields, or @every <N>[s|m|h|d]), interval_seconds, at (a one-shot instant) or dates (a calendar of up to 100 instants) |
| Timezones | Per-schedule IANA timezone (default UTC) — cron follows local wall-clock hours through daylight-saving changes; naive timestamps resolve in it |
| Exhausted schedules | Deregister themselves with Fact.Schedule.Deregistered — fired one-shots and finished calendars never pile up |
| Fleet spread | Optional jitter_secs (0–3600) — a deterministic offset past each grid instant; fire_id and scheduled_at stay on the grid |
| Sub-minute capable | Yes — lower the tick interval toward 1 second for “every N seconds” schedules |
| Service | binions-scheduler.service with a dedicated redis-binions-scheduler.service |
| Health endpoint | 127.0.0.1:9110 — /health/live, /health/ready, /metrics |
The pattern is always the same, and it separates cleanly into two kinds of playbook:
Fact.Schedule.Fired, filters by the schedule's name, and carries out the real work. You can have several playbooks react to the same tick, or split distinct schedules across distinct playbooks.Registration is not reserved for provisioning, though. A business playbook can register a schedule from data: a one-shot whose at comes straight out of a query row (at: ${prev.row.termin}), or a calendar assembled from a table of deadlines. Because registration upserts by name, re-running such a playbook refreshes the timers instead of duplicating them — the worked example below builds a whole reminder pipeline this way.
The key idea. Don't put a cron expression inside the playbook that does the work. Register the schedule once, then write your workflow to react to the tick by name. Changing the timing later is then a one-line re-registration — the business logic never moves.
In a playbook you address the daemon with the lowercase verb form run: scheduler.<operation>, which the platform turns into the daemon's internal Action.Schedule.<Verb>. The full set of six operations:
| Operation | What it does | Arguments |
|---|---|---|
scheduler.register_schedule | Create or update a named schedule — a cron grid, a fixed interval, a one-shot instant or a calendar of dates. Upserts by name — re-registering updates in place and keeps the firing history. | name; exactly one of cron_expr (also accepts @every <N>[s|m|h|d]) / interval_seconds / at / dates; optional timezone, jitter_secs (0–3600), payload, target |
scheduler.deregister_schedule | Remove a schedule so it stops firing. | name |
scheduler.pause_schedule | Temporarily stop a schedule from firing, keeping its definition. | name |
scheduler.resume_schedule | Resume a paused schedule on its normal cadence. | name |
scheduler.trigger_now | Fire a schedule immediately, out of band, without waiting for its next due time. Handy for testing the downstream workflow. | name |
scheduler.list_schedules | List the registered schedules, optionally narrowed to those whose name starts with a prefix. | optional prefix |
Watch the argument names. The cron expression argument is
cron_expr(with an underscore), notcron— and a schedule takes exactly one time expression:cron_expr,interval_seconds,atordates. For a plain “every N seconds/minutes” cadence useinterval_seconds(orcron_expr: "@every 45s") — a fixed interval is not expressible in cron (*/45fires at :00 and :45 of each minute, not every 45 seconds). And leavetimezoneout only if you mean UTC: cron hours and naive timestamps are resolved in whatever zone the schedule carries.
A provisioning playbook that runs once at first boot and registers a nightly schedule. It triggers on the platform booting and uses a filter so it sets up only when the relevant component starts:
name: provision-nightly-backup-schedule
trigger:
event: Fact.System.Boot
filter: { component.eq: playbook-service }
steps:
- id: register
run: scheduler.register_schedule
with:
name: nightly-backup
cron_expr: "0 2 * * *" # 02:00 every day — UTC, since no timezone is set
That registers a schedule called nightly-backup that becomes due at 02:00 UTC every day. Registration upserts by name, so re-running this playbook on a later boot simply confirms the same schedule rather than creating a second one. Prefer local time? Add timezone: Europe/Warsaw and the same grid follows the Warsaw wall clock instead — more on that below.
A separate business playbook listens for the tick by name and does the actual work — here, snapshotting a database table and uploading the result:
name: nightly-backup
trigger:
event: Fact.Schedule.Fired
filter: { name.eq: nightly-backup }
steps:
- id: snapshot
run: database.query
with:
table: orders
- id: upload
run: data.upload
with:
key: backups/orders-nightly
body: "${steps.snapshot.rows}"
Every night at 02:00 UTC the scheduler fires Fact.Schedule.Fired with name: nightly-backup; this playbook matches it and runs the two steps. The timing lives in the provisioning playbook above; the work lives here. Change the schedule later and this workflow is untouched.
Cron's smallest standard step is one minute, but scheduler-service also supports second-level fields and plain intervals, so you can refresh things far more often. This pair keeps a live dashboard fresh every five minutes by re-rendering a template through the show daemon:
# provisioning: register the fast schedule once
name: provision-dashboard-refresh
trigger:
event: Fact.System.Boot
filter: { component.eq: playbook-service }
steps:
- id: register
run: scheduler.register_schedule
with:
name: dashboard-refresh
cron_expr: "*/5 * * * *" # every 5 minutes
# business: re-render the dashboard on every tick
name: dashboard-refresh
trigger:
event: Fact.Schedule.Fired
filter: { name.eq: dashboard-refresh }
steps:
- id: render
run: show.render_template
with:
template: live-dashboard
output_path: "/live-dashboard"
For genuinely sub-minute schedules (every few seconds), lower tick_interval_secs in the configuration toward 1. The default of 30 seconds is fine for minute-and-coarser schedules and keeps the daemon almost idle the rest of the time.
Every schedule carries exactly one time expression. The four shapes cover recurring grids, steady cadences, single moments and whole calendars — and the last two are where schedules become data.
cron_exprscheduler-service accepts standard cron expressions with five, six, or seven fields. A five-field expression is the classic minute hour day-of-month month day-of-week form; the daemon treats it as a six-field expression with a leading 0 for seconds, so 0 2 * * * means “at the top of minute zero, 02:00.” Add a leading seconds field yourself (six fields) for second-level timing, and a trailing field for the year (seven fields) where you need it.
| Expression | Fires |
|---|---|
0 2 * * * | Every day at 02:00 |
*/5 * * * * | Every 5 minutes |
0 9 * * 1 | Every Monday at 09:00 |
*/30 * * * * * | Every 30 seconds (six-field form) |
All grid times follow the schedule's timezone — UTC unless you set one. The named shortcuts some cron tools accept (such as @daily or @hourly) are not supported; write the explicit fields. The one shortcut that is understood is @every <N>[s|m|h|d], which is really a fixed interval in cron clothing.
interval_secondsFor a steady “every N seconds” cadence, set interval_seconds — or write cron_expr: "@every 45s", which means the same thing. A fixed interval is not the same as a cron step: */45 fires at second :00 and :45 of each minute, while an interval of 45 fires every 45 seconds, anchored so the cadence never drifts. Interval firing is quantised to the engine tick, so keep tick_interval_secs at or below the cadence you care about.
atSet at to a single instant and the schedule fires exactly once, then deregisters itself. Timestamps come in two grammars: full RFC-3339 (2026-07-05T10:00:00+02:00) or the naive form YYYY-MM-DDTHH:MM[:SS], which is resolved in the schedule's timezone. The naive form is what makes one-shots data-driven: the due dates sitting in your database are rarely fully qualified timestamps, and at: ${prev.row.termin} lifts one straight out of a query row — no reformatting step in between.
datesdates takes a list of instants — from one to a hundred, in the same two timestamp grammars — and fires each of them exactly once: a payment plan, a term calendar, a season of deadlines in a single registration. If downtime swallowed some instants, the overdue ones catch up one per engine tick rather than all in one burst. After the last instant has fired, the calendar deregisters itself.
That self-cleanup is general: whenever a schedule can never fire again — a fired one-shot, a finished calendar, even a seven-field cron expression whose year range has run out — the engine emits Fact.Schedule.Deregistered and removes the entry, so no dead schedules pile up. The automatic deregistration carries the final firing's fire_id as its source_event_id, letting an audit trail tie the removal to the very tick that exhausted the schedule.
Every schedule can carry a timezone — an IANA zone name such as Europe/Warsaw. Leave it out and everything is evaluated in UTC, which behaves the same on every host. Set it, and two things change:
0 6 * * * with timezone: Europe/Warsaw fires at 06:00 Warsaw time in January and in July alike — the UTC moment shifts twice a year, the local hour never does. This is exactly what “before the office opens” jobs want.2026-11-15T09:00 (no offset) means 09:00 in the schedule's zone.The daylight-saving edge cases are handled the only sane way. A timestamp that falls in the spring-forward gap — a wall-clock time that never exists that night — is refused loudly at registration, so you find out immediately rather than silently at three in the morning. An ambiguous fall-back instant, which occurs twice, takes the first occurrence. And the Fact.Schedule.Registered confirmation echoes the timezone, so a provisioning playbook can verify what was resolved.
The pieces compose into a fully data-driven reminder chain: a daily cron refresh reads the pending payment terms, fans them out one fact per row, a per-item playbook registers a one-shot at each row's own due moment, and a final playbook sends the reminder when a one-shot fires. Four small playbooks, each trivial on its own:
name: register-terms-refresh
description: Provisioning — daily 06:00 Europe/Warsaw refresh cadence.
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: cadence
run: scheduler.register_schedule
with:
name: refresh-payment-terms
cron_expr: "0 6 * * *"
timezone: Europe/Warsaw
---
name: refresh-payment-terms
description: Business — read pending terms, fan them out one fact per row.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: refresh-payment-terms
steps:
- id: terms
run: database.query
with:
table: payment_terms
where:
status: pending
limit: 500
- id: fan_out
run: analytics.emit_items
with:
items: ${steps.terms.rows}
---
name: schedule-one-term
description: Business, per item — register a one-shot at the row's own due moment.
trigger:
event: Fact.Analytics.ItemEmitted
filter:
item.due_at.is_not_null: true
item.status.eq: pending
steps:
- id: one_shot
run: scheduler.register_schedule
with:
name: "term-${trigger.item.id}"
at: ${trigger.item.due_at}
timezone: Europe/Warsaw
payload:
invoice_no: ${trigger.item.invoice_no}
email: ${trigger.item.customer_email}
---
name: send-term-reminder
description: Business — the reminder itself, driven purely by the tick's payload.
trigger:
event: Fact.Schedule.Fired
filter:
name.startswith: "term-"
steps:
- id: remind
run: mail.send
with:
from_alias: crm-outbound
to:
- ${trigger.payload.email}
subject: "Payment due — invoice ${trigger.payload.invoice_no}"
body_text: |
Invoice ${trigger.payload.invoice_no} is due today.
Every moving part earns its keep. The refresh cadence keeps local time. The naive due_at timestamps from the database resolve in the same zone — no reformatting between the query and the scheduler. Because registration upserts by name, tomorrow's refresh re-registers the same term-<id> schedules: postponed terms move to their new moment, unchanged ones stay put, and the one-shots that already fired have deregistered themselves, so the registry never accumulates leftovers. Finally, the payload rides along into Fact.Schedule.Fired, so the reminder playbook needs no second database read.
jitter_secsGive a schedule jitter_secs (0–3600) and each firing becomes due a small offset past its grid instant — anywhere from 0 up to the jitter you set. The offset is not random: it is a deterministic hash of the schedule's name and the due instant, so it is stable across restarts and reproducible in tests, yet the many schedules of a fleet that all say 0 2 * * * no longer land on the same second. The shared database, mail relay or PLC behind them stops being stampeded at exactly 02:00:00.
Two properties make jitter safe to adopt. First, fire_id and scheduled_at stay on the grid: de-duplication behaves exactly as without jitter, and payloads still report the grid time — only the moment the fire becomes due shifts. Second, the effective granularity is the engine tick (tick_interval_secs, default 30 seconds): offsets are only as fine as the tick that notices them.
name: register-verified-setpoint-schedule
description: Provisioning — nightly setpoint sync at 02:00 local time, up to 120 s of jitter.
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: schedule
run: scheduler.register_schedule
with:
name: setpoint-sync
cron_expr: "0 2 * * *"
timezone: Europe/Warsaw
jitter_secs: 120
The business playbook reacting to setpoint-sync does not change at all: it still filters on the name, and it still receives a scheduled_at on the 02:00 grid.
Configuration lives in the daemon's application.toml. The [redis] block points at the dedicated Redis instance that holds the daemon's schedules and firing history; [healthcheck] exposes the local health and metrics server; and [engine] controls how often the daemon wakes to check whether anything is due. An optional [otel] block enables distributed tracing.
# /opt/binions/scheduler-service/config/application.toml
[redis]
port = 6400
password_file = "/etc/binions/redis-scheduler.pass"
[healthcheck]
listen_addr = "127.0.0.1:9110"
[engine]
# How often the scheduler wakes to check for due schedules.
# Default 30s is fine for minute-or-coarser cron expressions.
# Lower toward 1 for sub-minute (every-few-seconds) schedules.
# The tick is also the effective resolution of jitter offsets and
# the pace of overdue calendar catch-up (one overdue instant per tick).
tick_interval_secs = 30
# Optional: send traces to a collector for end-to-end visibility
# [otel]
# endpoint = "http://127.0.0.1:4317"
The tick_interval_secs setting is the one knob worth understanding. It is the resolution of the clock, not the schedule itself: the daemon wakes every interval and fires anything that has come due. That makes it the effective granularity for everything time-shaped — interval cadences are quantised to it, jitter offsets can be no finer than it, and a calendar catching up after downtime replays one overdue instant per tick. Leave it at 30 for ordinary schedules; lower it only when you genuinely need second-level firing, since a faster tick means the daemon wakes more often.
scheduler-service emits a family of Fact.Schedule.* events. The one your playbooks care about most is Fact.Schedule.Fired; the rest report the outcome of the operations above and are useful for observability and troubleshooting.
| Event | Meaning |
|---|---|
Fact.Schedule.Fired | A schedule has come due. This is the tick your business playbooks react to. |
Fact.Schedule.Registered | A schedule was created or updated (registration upserts by name). Echoes the schedule's timezone. |
Fact.Schedule.Deregistered | A schedule was removed — explicitly via deregister_schedule, or automatically once a schedule is exhausted (a fired one-shot, a finished calendar). Automatic deregistrations carry the final firing's fire_id as their source_event_id. |
Fact.Schedule.Paused | A schedule was paused. |
Fact.Schedule.Resumed | A paused schedule was resumed. |
Fact.Schedule.Listed | A list of schedules was produced (in response to list_schedules). |
Fact.Schedule.OperationFailed | An operation could not be completed — for example, an invalid cron expression. |
The Fact.Schedule.Fired payload gives a playbook everything it needs to act safely and precisely:
name — the schedule's name; this is what you filter on.scheduled_at — the exact grid moment the schedule was due. Jitter shifts when a fire becomes due, never this timestamp.fired_at — the moment the daemon actually emitted the tick (useful after downtime or with jitter, when these can differ).payload — whatever optional data you attached when registering the schedule, passed straight through.fire_id — a stable identifier for this one firing. Use it as a de-duplication key so a workflow runs at most once per tick.fire_count — how many times this schedule has fired, a running counter since registration.Because fire_id is stable for a given firing and the platform de-duplicates events by their idempotency claim, you can lean on it to guarantee a tick is acted on only once — even if the daemon restarts mid-flight. Jitter does not weaken this in any way: the identity of a firing lives on the grid.
The daemon runs as binions-scheduler.service alongside its dedicated redis-binions-scheduler.service, which holds its schedules and firing history. Bring both up together:
sudo systemctl enable --now redis-binions-scheduler binions-scheduler
systemctl status binions-scheduler
The service is a notify-type unit with a watchdog: it must report liveness within its watchdog window or systemd restarts it, so a wedged scheduler heals itself. Its memory footprint is capped well under the platform's lightweight budget. Check health directly over the local endpoint:
curl -s http://127.0.0.1:9110/health/ready
curl -s http://127.0.0.1:9110/health/live
curl -s http://127.0.0.1:9110/metrics
If schedules are registered but nothing fires, the usual culprits are a paused schedule, a cron expression that simply has not come due yet, a one-shot or calendar that has already exhausted itself (it deregisters itself and vanishes from the list — by design), or a sub-minute schedule competing with a 30-second tick interval — confirm the schedule is present and active with scheduler.list_schedules, then check tick_interval_secs. To prove the downstream workflow end to end without waiting, fire the schedule on demand with scheduler.trigger_now.