Skip to content

fix(cron): record the timezone a cron schedule is evaluated in - #88581

Open
JoaoMarcos44 wants to merge 2 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/cron-schedule-timezone-88220
Open

fix(cron): record the timezone a cron schedule is evaluated in#88581
JoaoMarcos44 wants to merge 2 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/cron-schedule-timezone-88220

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

A cron expression is local wall-clock intent ("run at 14:30"), but next_run_at is persisted as an absolute instant. Which zone that wall clock belonged to was never recorded — every process re-resolved it from HERMES_TIMEZONE / config.yaml at the moment it read or wrote the job.

That makes a persisted next_run_at ambiguous the instant two readers disagree. hermes_time cached the resolved zone for the whole process lifetime, so a gateway that booted before timezone: Asia/Shanghai was added kept running on server-local time, while every freshly spawned CLI / web worker picked the new zone up immediately. The same string then meant two different instants, and the due check silently flipped between "absolute instant" and "naive wall clock" semantics depending on an offset comparison — moving the job by the whole UTC offset (8h) at the next gateway restart, firing it early and swallowing the genuine run.

Fixes #88220.

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#8b0000', 'mainBkg': '#0a0204', 'primaryTextColor': '#ffccd5', 'primaryBorderColor': '#ff0038', 'lineColor': '#ff0038'}}}%%
graph TD
    subgraph BEFORE["Before - the zone is implicit"]
        A1["Gateway process<br/>booted before timezone: was set<br/>resolves server-local"] -->|writes / reads| S1[("jobs.json<br/>next_run_at: 2026-08-17T14:30+08:00")]
        A2["CLI / web worker<br/>resolves Asia/Shanghai"] -->|writes / reads| S1
        S1 --> D1{"Due check<br/>offset label == my offset?"}
        D1 -->|"no - reinterpret as wall clock"| X1["Fires 8h early<br/>real run swallowed"]
        D1 -->|"yes - absolute instant"| X2["Different answer<br/>same file, other process"]
    end

    subgraph AFTER["After - the schedule carries its own zone"]
        B1["Gateway process"] --> S2[("jobs.json<br/>schedule.tz: Asia/Shanghai<br/>next_run_at: 2026-08-17T14:30+08:00")]
        B2["CLI / web worker"] --> S2
        S2 --> C1["croniter evaluated on the<br/>naive wall clock of schedule.tz"]
        C1 --> R1["One instant, every reader"]
        S2 --> Z{"configured zone<br/>!= schedule.tz ?"}
        Z -->|"yes - operator changed timezone:"| Z1["Re-anchor once to the same<br/>wall clock in the new zone, re-stamp"]
        Z -->|"no - DST only moved the offset"| R1
    end
Loading

Is it really a bug?

Yes, and it is two bugs. Reproduced with a virtual-clock ticker simulation that drives the real get_due_jobs() / mark_job_run() pair minute by minute and compares the fire log against the cron expression evaluated independently in the job's own zone.

Scenario group Cases Fail on main Fail with this PR
Daily job, reader in the same zone 13 0 0
Daily job, reader with no configured zone (the #88220 gateway) 13 13 0
5 cron expressions x 3 zones, zone-less reader 15 12 0
DST boundaries, north + south, no timezone change at all 6 5 0
Restarts that flip the reader's resolved zone 3 3 0
Total 47 31 0

Sample from main, Asia/Shanghai, 30 14 * * *: fired at 14:30Z for five days running; the correct instants (06:30Z) were all missed — the exact 8-hour shift in the report.

Second bug this surfaced

croniter walks a fixed UTC offset taken from its base datetime, so handing it a zone-aware base drifts by the DST delta across a transition:

>>> croniter("0 9 * * *", datetime(2026, 3, 28, 9, 0, tzinfo=ZoneInfo("Europe/Berlin"))).get_next(datetime)
datetime.datetime(2026, 3, 29, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin'))   # an hour early

In the simulation this costs a daily job an extra fire on every spring-forward day and a lost fire on every fall-back day, with no timezone divergence involved. It affects every DST-observing install today.

Root cause fix

1. The schedule records the zone it is evaluated in. parse_schedule stamps schedule["tz"] with the configured IANA zone; compute_next_run anchors croniter to it, evaluating the expression on that zone's naive wall clock and localizing the result. Gateway, CLI and web UI now compute the same instant regardless of what each of them resolves — and 09:00 stays 09:00 across a DST boundary.

2. Zone-identity rebase replaces the offset-delta heuristic. When timezone: genuinely changes, the job is re-anchored once to the same wall clock in the new zone and re-stamped (the #28934 intent, now triggered exactly). Zone identity does not change across DST, so DST no longer masquerades as a migration and no longer skips a pending occurrence.

3. hermes_time stops pinning the zone for the process lifetime. It re-resolves at most once a minute, so a config edit no longer needs a gateway restart to take effect and divergent processes converge instead of drifting apart.

Why not the alternatives

f1f36b3bae (#28934) already rejected normalize-to-UTC (#28951) and rebase-and-match (#28985), and shipped the offset-delta heuristic with an explicit trade-off comment: "this cannot distinguish a config/host TZ migration from a legitimate DST offset change". Both rejected approaches, and the heuristic that won, share one blind spot — none of them record the zone, so all three have to guess from an offset. This PR removes the guess instead of tuning it; the heuristic's four regression tests still pass unchanged because unstamped jobs still take it.

Backward compatibility

  • schedule.tz is additive and optional; parse_schedule is the only place that builds a schedule dict, and nothing validates its keys strictly.
  • Installs with no timezone: set store no tz and behave byte for byte as before.
  • Legacy jobs adopt the configured zone the first time the scheduler sees them; the old heuristic still repairs a foreign offset on that same pass, so no job can slip through unrepaired.
  • Interval and one-shot schedules are untouched — they are already absolute instants.
  • An unknown/removed IANA name degrades to the previous behaviour with a warning instead of wedging the ticker.

Performance

Measurement main This PR
hermes_time.now() 0.238 us/call 0.357 us/call
now() forced to re-resolve every call (TTL = 0) 0.618 us/call
get_due_jobs(), 1000 stamped cron jobs 11.19 ms 10.27 ms

The configured zone is resolved once per due scan, not per job, so the ticker's per-job cost is a string comparison.

Test plan

  • tests/cron/test_cron_schedule_timezone_88220.py — 48 new tests: zone stamping, cross-process determinism, the Cron jobs fire 8 hours early after gateway restart (next_run_at persisted with +08:00 label on UTC wall-clock) #88220 reproducer end to end, zone-change rebase + idempotence, DST tick simulations (Berlin / Auckland / Los Angeles, both directions), a 13-zone i18n matrix including +05:30, +05:45, +08:45, +14:00 and -11:00, and legacy-job adoption. 43 of 48 fail on main.
  • tests/cron + tests/test_timezone.py: 790 passed, 34 skipped. The 7 failures are the pre-existing Windows-only POSIX-permission / tilde-expansion tests — identical on a clean checkout.
  • tests/tools: 36 pre-existing Windows-only failures on both main and this branch (unchanged).
  • ruff check clean on every changed file.

Infographic :

cron_tz_infographic

A cron expression is local wall-clock intent ("run at 14:30"), but
`next_run_at` is persisted as an absolute instant. Which zone that wall
clock belonged to was implicit: every process re-resolved it from
HERMES_TIMEZONE / config.yaml at the moment it read or wrote the job.

That makes a persisted `next_run_at` ambiguous as soon as two readers
disagree. `hermes_time` cached the zone for the whole process lifetime,
so a gateway that booted before `timezone: Asia/Shanghai` was added kept
running on server-local time while every freshly spawned CLI / web worker
resolved Asia/Shanghai immediately. The same string then meant two
different instants, and the due check silently switched between
"absolute instant" and "naive wall clock" semantics depending on an
offset comparison — moving the job by the whole UTC offset (8h) at the
next gateway restart, firing it early and swallowing the real run
(NousResearch#88220).

Root cause fix, in two parts:

1. `schedule["tz"]` records the IANA zone a cron expression is evaluated
   in, stamped at parse time. `compute_next_run` anchors croniter to that
   zone, so gateway, CLI and web UI all compute the same instant no
   matter what each of them resolves. Legacy jobs are adopted by the
   configured zone on first sight; installs with no `timezone:` set store
   no `tz` and keep the previous server-local behaviour byte for byte.

2. The offset-delta migration repair from NousResearch#28934 is replaced, for stamped
   jobs, by an explicit zone-identity rebase: when `timezone:` actually
   changes, the job is re-anchored once to the same wall clock in the new
   zone and re-stamped. Because zone identity does not change across a
   DST boundary, DST no longer masquerades as a migration and no longer
   skips a pending occurrence. Unstamped jobs still take the old
   heuristic, so its four existing regression tests keep passing.

Also fixes a second, independent bug this surfaced: croniter walks a
*fixed* UTC offset taken from its base datetime, so handing it a
zone-aware base drifts by the DST delta across a transition
(`0 9 * * *` based on 2026-03-28T09:00+01:00 returns
2026-03-29T08:00+02:00 — an hour early). Stamped schedules are evaluated
on the naive local wall clock and localized afterwards, which keeps 09:00
meaning 09:00 on both sides of the boundary. In a virtual-clock
simulation this cost a daily job an extra fire on every spring-forward
day and a lost fire on every fall-back day, with no timezone change
involved at all.

`hermes_time` now re-resolves the configured zone at most once a minute
instead of pinning it for the process lifetime, so a config edit no
longer needs a gateway restart to take effect — and processes converge
instead of diverging. Measured cost: `now()` 0.238us -> 0.357us per call;
`get_due_jobs()` over 1000 jobs 11.19ms -> 10.27ms.

Verified with a virtual-clock ticker simulation over 47 scenarios
(13 IANA zones incl. +05:45/+08:45/+14:00 offsets, 5 cron expressions,
6 DST boundaries north and south, and restarts that flip the reader's
resolved zone): 31 scenarios fail before this change, 0 after.

Fixes NousResearch#88220
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cron Cron scheduler and job management area/config Config system, migrations, profiles sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 17, 2026
… tests

_configured_tz_name() swallowed every exception with no trace, and the
new bounded-TTL zone cache in hermes_time.py had zero direct test
coverage (existing cron tests bypass it via monkeypatch).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/cron Cron scheduler and job management P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cron jobs fire 8 hours early after gateway restart (next_run_at persisted with +08:00 label on UTC wall-clock)

2 participants