Schedules and Timezones - ptweezy/cronstable GitHub Wiki

Schedules and timezones

Every job's schedule determines when it runs; utc and timezone determine in which time zone the schedule is evaluated. This page documents the three accepted schedule forms, how the daemon wakes and tests them, and how the effective time zone is resolved.

The schedule option

schedule is required on every job. The strictyaml schema accepts two YAML types for it (cronstable/config.py):

"schedule": Str()
| Map({
      Opt("second"): Str(),
      Opt("minute"): Str(),
      Opt("hour"): Str(),
      Opt("dayOfMonth"): Str(),
      Opt("month"): Str(),
      Opt("year"): Str(),
      Opt("dayOfWeek"): Str(),
  })

so schedule is either a string or an object. JobConfig._parse_schedule turns that raw value into one of:

  • a cronstable.cronexpr.CronTab instance (a parsed crontab expression), or
  • the literal string "@reboot".

Any other value raises ConfigError("invalid schedule: ...").

The crontab dialect is implemented by cronstable's own built-in engine (cronstable/cronexpr.py, no third-party dependency). Field syntax is the dialect cronstable has always accepted, not the system cron(5) man page. It takes ranges 1-5, steps */5 (and 5/15: start plus step, running to the field's end), lists 1,15,30, and case-insensitive names like mon/jan. The notable rules:

  • Day-of-week accepts 0–7, with both 0 and 7 meaning Sunday. A range ending in 0 wraps its end to Sunday-as-7, so sat-sun and 6-0 mean Saturday+Sunday.
  • L alone in day-of-month is the month's last day (0 0 L * *); L<n> (or a numeric range L<a>-<b>) in day-of-week is the month's last such weekday (L5 = last Friday). No other field takes an L.
  • Business-day forms: in day-of-month, L-<n> counts back from the month's final day (L-3 = three days before it), <n>W is the weekday nearest day n within the same month, and LW is the month's last weekday. In day-of-week, <d>#<n> is the month's n-th such weekday (5#3 = third Friday, mon#1 = first Monday). Exact edge rules, examples, and Quartz porting notes: business-day schedules.
  • ? standing alone in day-of-month or day-of-week reads as * (the Quartz spelling of "unrestricted"), so a seven-field Quartz expression (whose column layout matches cronstable's) parses verbatim. ? anywhere else is an error.
  • Day-of-month AND day-of-week: when both are restricted, a day must satisfy both (0 0 13 * 5 fires only on Friday the 13th). Vixie cron fires when either matches; cronstable deliberately keeps the AND rule its schedules have always had. The schedule linter warns whenever a schedule uses the combination.
  • year accepts 1970–2099, and next() never searches past 2099: a schedule with no remaining occurrence (year: "2020", or an impossible date like Feb 30) never fires. It is legal (a fixed past year is a working idiom for parking a job), and it is reported: config load logs a never-fires warning, the scheduler warns once at seed time, and /status and /jobs report never_fires (see schedule linting).

The built-in engine is behavior-compatible with the third-party parse-crontab library, vector-by-vector. See tests/gen_cron_golden.py and tests/data/cron_golden.json for the recorded compatibility corpus.

Expressions in other dialects fail with a hint instead of a bare field error:

  • # or W outside its one valid field: the hint names the right field. # is valid in day-of-week and W in day-of-month.
  • Quartz's trailing-L (5L): the hint points at this dialect's L5 spelling.
  • A six-field seconds-first Quartz layout (0 */5 * * * ?): the hint explains that cronstable's sixth field is a year, and how to convert it (append a trailing *).

Schedules do not have to live in YAML at all: cronstable also loads whole classic crontab files (*.crontab, *.cron, or a file named crontab), whose entries use this same field dialect plus the @ nicknames and default to UTC like every other cronstable schedule. See classic crontabs.

Form 1: crontab string (5, 6 or 7 fields)

A standard five-field crontab expression: minute hour day-of-month month day-of-week.

jobs:
  - name: every-five-minutes
    command: echo hello
    schedule: "*/5 * * * *"

The string is passed verbatim to CronTab(...). A malformed expression is caught and re-raised as ConfigError("invalid schedule '...': ...") at config-load time (naming the offending expression), so a bad field fails the reload cleanly rather than as an anonymous traceback. Quote the value in YAML: a bare */5 * * * * is not valid YAML scalar syntax in all positions.

The engine reads extra columns from the ends, so the field count selects the dialect:

Fields Layout Meaning
5 minute hour dayOfMonth month dayOfWeek the classic form; implicit second 0, any year
6 ... dayOfWeek year adds a trailing year column (still second 0)
7 second minute hour dayOfMonth month dayOfWeek year adds a leading second column too

So a seven-field string schedules at second granularity (see second-level schedules later), whereas a six-field string pins a year. A six-field string is not seconds: the extra column is the year.

Any field except the year also takes the Jenkins-style H hash form (H, H(a-b), H/n, H(a-b)/n), which resolves to a slot hashed from the job's name: stable across restarts, reloads, and replicas. So a fleet of H * * * * jobs spreads across the hour instead of firing together at :00, whereas each job keeps one predictable fire time. See hashed schedules for the forms, the guarantees, and the rename caveat.

Second-level schedules

The daemon can run jobs at second granularity. Give the schedule a second (with the seven-field string shown earlier, or the second: object key in Form 3). Both of the following jobs run every 15 seconds:

jobs:
  - name: every-15s-string
    command: echo tick
    schedule: "*/15 * * * * * *"   # 7 fields; leading field is the second
  - name: every-15s-object
    command: echo tick
    schedule:
      second: "*/15"

The second field takes the same syntax as any other (*, */5, 0,30, 10-20), and second: "*" fires every second. While any enabled job specifies seconds, the scheduler wakes at each second boundary a schedule pins instead of once a minute (see how the scheduler ticks). When none do, it keeps the minute cadence and its zero overhead. Minute-granular jobs are unaffected either way: they still fire exactly once in their scheduled minute. Second-level scheduling is a YAML-only feature: classic crontab files stay five-field and minute-granular.

Form 2: @reboot

The exact string "@reboot" is stored as-is (not parsed into a CronTab). A @reboot job runs only once, at daemon startup, and never on a recurring schedule. With a state section it runs once per host boot: restarting the daemon without rebooting the host does not rerun it. See durable state.

jobs:
  - name: warm-cache
    command: /usr/local/bin/warm-cache
    schedule: "@reboot"

Behavior comes from Cron.job_should_run (cronstable/cron.py). On the first scheduler pass the startup flag is True and a @reboot job returns True. On every subsequent pass startup is False, so @reboot jobs return False. Conversely, CronTab-scheduled jobs return False during the startup pass and are only evaluated on later passes. There is no recurring @reboot. With a state section, a durable boot marker also skips the startup run when this host boot already had one.

To keep a long-running process alive, README.md recommends a @reboot schedule combined with onFailure.retry.maximumRetries: -1 (retry forever), so cronstable relaunches the process whenever it exits or fails.

"@reboot" is the only @-keyword recognized by cronstable itself. _parse_schedule does not intercept other shorthands. The cron engine accepts @yearly, @annually, @monthly, @weekly, @daily and @hourly (their classic five-field expansions). @midnight is accepted only in classic crontab files, whose loader rewrites it to @daily.

Form 3: schedule object

An object lets you name fields individually. Each omitted key defaults to "*".

jobs:
  - name: noon-on-weekdays
    command: echo hello
    schedule:
      minute: "0"
      hour: "12"
      dayOfWeek: "mon-fri"

schedule_object_to_crontab (in cronstable/config.py) builds a crontab string from exactly these keys and _parse_schedule feeds it to CronTab:

Object key Crontab field Default if omitted
second second (omitted)
minute minute *
hour hour *
dayOfMonth day-of-month *
month month *
dayOfWeek day-of-week *
year year (omitted)

Only the columns you use are emitted, matching the engine's end-column rule from Form 1:

  • neither second nor year → a five-field line (f"{minute} {hour} {day} {month} {dow}");
  • year only → a six-field line with the trailing year column;
  • second present → a full seven-field line (year defaults to * if unset).

So {minute: "*/5"} is byte-for-byte the five-field string "*/5 * * * *" (and the two spellings share a job-set fingerprint), whereas {second: "*/15"} is the seven-field "*/15 * * * * * *".

All values are typed Str() in the schema, so write minute: "0", not minute: 0. Although strictyaml coerces an unquoted scalar to a string here, quoting is the documented convention and avoids surprises with values like "7".

The year key

year restricts the schedule to specific years (the optional trailing column, 1970–2099). For example, this runs only during 2017:

schedule:
  minute: "*/5"
  dayOfMonth: "19"
  month: "7"
  year: "2017"

A past year means the job never fires again. To run the schedule every year, omit the year key.

Timezone resolution

JobConfig._resolve_timezone resolves the clock used to evaluate a schedule. Two job options drive it:

Option Type Default Description
utc Bool true When no timezone is set, true evaluates the schedule in UTC and false uses the host's naive local time.
timezone Str (unset; None) IANA time zone name (for example, America/Los_Angeles). When set, it overrides utc.

Resolution order (timezone wins):

  1. If timezone is set, the job uses ZoneInfo(timezone). An unknown name raises ConfigError("unknown timezone: ..."). The utc value is ignored in this case.
  2. Else if utc is true (the default), the job uses datetime.timezone.utc.
  3. Else (utc: false, no timezone) the resolved tzinfo is None: the job runs on the host's local clock. The scheduler and every schedule view frame that clock as LOCAL_ZONE (cronstable/cronexpr.py), a tzinfo that reads each offset from the C library. The job therefore follows the host's DST rules exactly as a job with an explicit timezone follows its zone's: a wall time the clocks skip fires once at the shifted time, and a wall time they repeat fires on its first occurrence only.

The resolved value is a datetime.tzinfo (or None) stored on the job and passed to get_now(job.timezone) when the schedule is tested. Because utc is true by default, schedules are interpreted in UTC unless you opt out.

Time zone names are resolved through the standard-library zoneinfo, with the tzdata package providing the database. Because cronstable depends on tzdata>=2026.5, resolution works on minimal and distroless images that lack a system zoneinfo database. An invalid time zone name raises ConfigError.

Local time:

jobs:
  - name: nightly-local
    command: /usr/local/bin/nightly
    schedule: "27 19 * * *"   # 19:27 host local time
    utc: false

Explicit time zone:

jobs:
  - name: nightly-la
    command: /usr/local/bin/nightly
    schedule: "27 19 * * *"   # 19:27 America/Los_Angeles
    timezone: America/Los_Angeles

utc and timezone are ordinary job options and can be set per job or in a defaults block. See the configuration reference for where they sit among all options.

How the scheduler ticks

The scheduler does not run a per-job timer, and it does not scan every job on a fixed tick either. It keeps a next-fire index and sleeps until the soonest job is due (cronstable/cron.py):

  • The next-fire index. Every enabled CronTab job carries the instant it next fires: an aware UTC datetime in Cron._next_fire, mirrored into the _fire_heap min-heap. Each instant is computed by crontab.next() in the job's own frame (its timezone, or LOCAL_ZONE, the host's local clock, when it has none) and stored back in UTC. So a job's DST offset is handled where it applies, and the heap still orders everything on one absolute timeline. @reboot and disabled jobs are not in the index.
  • Sleep until the soonest fire. Each iteration sleeps until the earliest instant in the heap, capped at the next whole UTC minute so housekeeping (described later) still runs about once a minute. On wake, _due_names pops only the jobs whose instant has arrived, and nothing else is touched. An idle wake over a large fleet is an O(1) heap peek, and a wake with a due cohort does crontab work only for that cohort. Cost scales with jobs due, not jobs configured.
  • Structural, forward-only de-duplication. A fired slot cannot fire twice because advancing the index moves the job's next fire strictly past the slot it just fired (_advance → _set_next_fire). There is no per-tick already-fired check: _last_run_slot is retained only for status and introspection, and does not gate launching. So a minute-level job fires exactly once in its minute, and a second-level job exactly once per matching second, however often the loop wakes.
  • Immune to clock steps. The sleep length is derived from the wall clock but realized against the event loop's monotonic clock (asyncio.wait_for), and firing compares the wall clock against the fixed, forward-only instants in the heap. So a wall-clock or NTP step is absorbed on the next wake. A step backward defers the pending fire (it is not re-fired), and a step forward does not cause a catch-up storm (see the next item).
  • Bounded catch-up. If a job's due instant is less than CATCHUP_LIMIT (10 s) behind, as happens on a slow pass such as many simultaneous launches or the once-a-minute config reload, _advance replays each missed occurrence in the window. A gap of at least 10 s counts as a stall, suspend, or forward clock jump: the job resumes at the current slot (firing once only if now itself matches) and resyncs in O(1) with a warning. Stateful jobs with onMissed: run-once or run-all also queue background recovery for skipped slots, using the durable catch-up safeguards. The scheduling pass never enumerates a long missed window. This applies to minute- and second-level jobs.
  • No spurious run for the period in progress at startup. At startup the index is seeded strictly-future (the first boundary after the start instant) for every scheduled job, so a job whose minute or second is already under way does not fire immediately. It first fires at the next matching boundary. @reboot jobs are unaffected and still fire once at startup.
  • Reload keeps the index in step. On a config reload, a job whose schedule is unchanged keeps its existing next-fire, so a reload landing on that job's own boundary minute cannot recompute a strictly-future fire and skip it. A job whose schedule changed, or a newly added one, is reseeded strictly-future. A removed or now-disabled job is dropped.

Implications:

  • Second-level schedules fire on time. With a second field the loop wakes exactly at each second boundary the schedule pins, so */15 * * * * * * fires at seconds 0/15/30/45.
  • No cost when unused. With no second-level job the effective cadence is still about once a minute. Housekeeping (config reload, cluster and web upkeep, and logging) is gated to run at most once per wall-clock minute (Cron._wakes_subminute() is True while a second-level job or a pending DAG wake shortens the sleep). So a second-level job that wakes the loop many times a minute does not reread and reparse the config on every wake.
  • A job whose schedule matches a given slot fires at most once for that slot. If multiple instances would overlap, concurrencyPolicy governs the outcome.
  • A job that is disabled (enabled: false) returns False from job_should_run regardless of schedule and never fires, including at @reboot. A disabled second-level job is also absent from the next-fire index, so it adds no scheduling cost.

Inspecting the next run

The HTTP control API /status endpoint reports, per job, either running, disabled, or scheduled with a scheduled_in value. For CronTab jobs that value is the engine's next occurrence evaluated in the job's frame: its timezone, UTC, or LOCAL_ZONE for the host clock. For @reboot jobs it is the literal string @reboot. This is the recommended way to verify that a schedule resolves to the instant you expect.

See also: Commands and Environment, Concurrency and Timeouts, Troubleshooting and FAQ.

⚠️ **GitHub.com Fallback** ⚠️