Cron and interval semantics

How Flux schedules interpret time — cron syntax, intervals, timezones, missed runs, DST surprises, and how the scheduler coordinates across server replicas.

You created a schedule. Now you want to know when it will fire — and, more usefully, when it won’t.

The scheduler runs inside the server process (flux/server.py::_scheduler_loop). It wakes every poll_interval seconds (default 30, configurable via [flux.scheduling] poll_interval), asks the database for schedules whose next_run_at is in the past, dispatches each one, and advances next_run_at forward. Everything below follows from that loop.

Two schedule kinds

Flux ships three schedule classes in flux/domain/schedule.py, but only two are recurring:

ScheduleTypeClassRepeats?
cronCronScheduleyes
intervalIntervalScheduleyes
onceOnceScheduleno — fires once and stops

The CLI exposes only the two recurring shapes (flux/cli.py::create_schedule); once-schedules are SDK-only. The rest of this page is about cron and interval.

Each schedules row (flux/models.py::ScheduleModel) stores a schedule_type enum, a serialised schedule_config blob, and a next_run_at timestamp. The dispatch query reads next_run_at on every tick.

Cron expression syntax

Flux parses cron expressions with croniter v3 (pyproject.toml, flux/domain/schedule.py:9). That means both 5-field and 6-field expressions are accepted:

FieldsLayout
5minute hour day-of-month month day-of-week
6second minute hour day-of-month month day-of-week
cron("0 9 * * MON-FRI")        # 09:00, weekdays
cron("*/15 * * * *")           # every 15 minutes
cron("0 0 1 * *")              # midnight on the 1st of each month
cron("0 0 9 * * MON-FRI")      # 6-field: 09:00:00, weekdays

croniter supports ranges (1-5), steps (*/15), lists (1,15,30), and day-of-week names (MON, TUE, …). It does not support the Quartz ? placeholder or the L/W modifiers; if you copy an expression from a Java scheduler, sanity-check it first.

Invalid expressions are rejected at create time, not at fire time (flux/domain/schedule.py:121) — the API returns 400 before the row is written.

Interval expression syntax

Interval schedules accept five integer fields — seconds, minutes, hours, days, weeks — summed into a single timedelta (IntervalSchedule.__init__):

interval(minutes=5)             # every 5 minutes
interval(hours=6)               # every 6 hours
interval(hours=1, minutes=30)   # every 90 minutes

The total must be positive. Over REST, the same shape arrives as a single interval_seconds integer ({ "type": "interval", "interval_seconds": 21600, "timezone": "UTC" }); the CLI does the multiplication for you (--interval-hours 6 becomes 21600, flux/cli.py:1179).

Intervals are anchored to the previous run, not to wall-clock midnight. An interval of 6 hours created at 09:13 fires at 15:13, 21:13, 03:13 — not at 06:00, 12:00, 18:00. For wall-clock alignment, use cron.

Timezones

Both schedule types carry a timezone field defaulting to "UTC". Schedule.__init__ (flux/domain/schedule.py:33) validates it as an IANA zone via zoneinfo.ZoneInfo and explicitly rejects "local" — the scheduler may be restarted on different hosts, so “local” would mean something different each time. Pick a zone name:

flux schedule create reports.daily_summary daily-9am \
  --cron "0 9 * * MON-FRI" \
  --timezone "America/New_York"

For intervals the timezone is mostly cosmetic; for cron it determines when the expression fires.

Recommendation: use UTC unless you have a hard requirement to track local time. UTC has no DST transitions and it’s what every dashboard you’ll plug Flux into already speaks.

Missed runs

What happens when the server is down and a schedule should have fired? Flux does not catch up — it fires each missed schedule at most once on the next tick after recovery, no matter how many slots were skipped.

The dispatch query (flux/schedule_manager.py::get_due_schedules) returns at most one row per schedule per poll. After dispatch, mark_run(current_time) sets last_run_at to now and recomputes next_run_at forward from now (flux/models.py::ScheduleModel.mark_run). The intermediate firings are gone.

A */5 * * * * cron whose server was down between 09:00 and 10:00 fires once at 10:00, not twelve times. An interval of 5 minutes with last_run_at an hour in the past behaves the same way — one catch-up dispatch, and the next slot is five minutes forward from that.

If you need every slot, even the missed ones, Flux is the wrong tool. If you need at-least-once-per-window with no stampede, this is the right shape.

DST surprises

Cron expressions in a local timezone produce zero or two firings on DST transition days. This is not a Flux quirk (every cron-style scheduler with timezone support has the same problem), but it bites in production.

Two mitigations:

  1. Use UTC. A 0 9 * * * UTC schedule fires every day at exactly 09:00 UTC, corresponding to slightly different local times across DST boundaries. For batch jobs without a human-facing time, this is almost always the right call.
  2. Pick an unambiguous local time. If you must run at “09:00 local time”, pick a slot well outside the 01:00–03:00 transition window — 05:00 or 09:00 are never ambiguous.

If the workflow cares about the local date, derive it from datetime.now(ZoneInfo("America/New_York")).date() inside the workflow rather than relying on the firing time.

Multi-replica coordination

schedule_manager runs inside every server process — each flux start server starts its own scheduler loop during the FastAPI startup hook, and there is no flag to disable it. What keeps N replicas from producing N executions per fire is per-cycle coordination in PostgreSQL: before dispatching, a replica takes a session-scoped pg_try_advisory_lock (ScheduleManager.dispatch_lock, flux/schedule_manager.py); a replica that fails to acquire it skips the cycle. The whole cycle — get_due_schedules through the record_run that persists the advanced next_run_at — happens under the lock, so no two replicas can both see the same schedule as due.

Two properties fall out of the persisted run state:

This means server replica count is an availability decision, not a scheduling-correctness one — see High availability for the deployment shape. On SQLite there is no cross-process lock, but SQLite is single-node only anyway; a single process is the only supported scheduler there.

What can go wrong

Schedule fires twice (or N times)

Symptom. Every cron firing produces two or more executions, all completing, with started_at timestamps a fraction of a second apart.

Cause. Not replica count — N replicas on one PostgreSQL database dispatch once per fire. Look instead for: a duplicate schedule row (auto-scheduling creates <workflow>_auto on registration; a manually created schedule for the same workflow doubles it), two separate Flux deployments pointing at the same database, or a leftover pre-0.5x server process that predates the advisory-lock coordination.

Fix. flux schedule list --all and delete the duplicate row, or upgrade/retire the old server process.

Schedule didn’t fire across DST

Symptom. A 0 2 * * * cron in America/New_York runs every day for months, then misses one Sunday in March. next_run_at jumps two days forward.

Cause. Spring-forward gap — no 02:00 on the transition day.

Fix. Switch to UTC, accept the once-a-year miss, or move the schedule out of the 01:00–03:00 window.

Schedule fires at the wrong time after a server restart

Symptom. After a restart, the schedule fires consistently early or late relative to its expected wall-clock time.

Cause. Clock skew between the server host and the database host. get_due_schedules compares next_run_at to datetime.now(timezone.utc) on the server. Skew of more than schedule_check_tolerance seconds (default 1.0, flux/config.py) shifts the firing.

Fix. Run chronyd or systemd-timesyncd on every host that touches Flux — server, workers, database.

Next