fix(cron): anchor croniter to the configured IANA timezone

croniter 6.x ignores tzinfo on its start time and instead uses the start's
UTC *offset* as its working offset. compute_next_run passed a tz-aware
last_run_at straight into croniter(expr, base_time), which produced two bugs:

- a last_run_at stored in UTC (+00:00) shifted the next fire to the cron
  hour in UTC rather than local time (09:00 UTC = 05:00 America/Toronto)
- on DST transition days the wall-clock hour drifted one hour off (08:00 on
  spring-forward, 10:00 on fall-back)

Render the base as the configured zone's naive wall clock for croniter,
then re-attach the zone to the result, so the wall-clock hour stays correct
every calendar day including DST boundaries. Fall back to the base's own
zone only when no timezone is configured.

Adds regression tests covering a UTC-stored last_run_at, spring-forward,
fall-back, and a full-year walk across both DST transitions.
This commit is contained in:
JF Lemieux
2026-08-30 11:26:45 +00:00
committed by Teknium
parent 85e32fcd69
commit d6d29b0010
2 changed files with 102 additions and 1 deletions

View File

@@ -34,6 +34,7 @@ from typing import Optional, Dict, List, Any, Callable, Set, Tuple, Union, Colle
logger = logging.getLogger(__name__)
from hermes_time import now as _hermes_now
from hermes_time import get_timezone
from utils import atomic_replace, atomic_write_text
# croniter is imported lazily (slow import, only needed for cron exprs). HAS_CRONITER stays a
@@ -1120,7 +1121,21 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
"reinstall hermes-agent or run 'pip install croniter' in your runtime env.",
expr)
return None
return croniter(expr, base_time).get_next(datetime).isoformat()
# Anchor cron matching to the CONFIGURED IANA timezone's WALL CLOCK,
# not to the UTC offset carried by ``base_time``. croniter ignores
# the tzinfo on its start time and uses the start's UTC offset as its
# working offset, so a ``last_run_at`` stored in UTC (+00:00) would
# push the next fire to 09:00 UTC instead of 09:00 local, and DST
# transition days (spring-forward / fall-back) would land one hour off
# (08:00 or 10:00). Render the base as the configured zone's naive
# wall clock for croniter, then re-attach the zone to the result, so
# the wall-clock hour stays correct every calendar day, including DST
# boundaries (morning-routine 09:00 America/Toronto).
# Fall back to the base's own zone only when nothing is configured.
zone = get_timezone() or base_time.tzinfo
base_wall = base_time.astimezone(zone).replace(tzinfo=None)
return (croniter(expr, base_wall).get_next(datetime)
.replace(tzinfo=zone).isoformat())
return None

View File

@@ -0,0 +1,86 @@
"""Regression test: compute_next_run must honor the configured IANA timezone.
Background (task t_392f55e9 -- "morning routine at 9:00 America/Toronto"):
croniter 6.0.0 ignores the tzinfo on its start time and uses the start's
UTC *offset* as its working offset. The original code passed a tz-aware
``last_run_at`` straight into ``croniter(expr, base_time)``, which caused two
distinct bugs:
1. When ``last_run_at`` is stored in UTC (e.g. ``+00:00`` -- what the gateway
wrote while it was running without ``timezone`` configured), the next
occurrence was computed 24h later in UTC terms, so a ``0 9 * * *`` job
fired at 09:00 UTC = 05:00 Toronto (four hours early).
2. On DST transition days the wall-clock hour drifted: 08:00 on the
spring-forward day and 10:00 on the fall-back day, instead of 09:00.
The fix renders the base as the *configured* IANA zone's naive wall clock for
croniter, then re-attaches the zone, so the wall-clock hour is correct on
every calendar day including DST boundaries.
"""
import pytest
from datetime import datetime
from zoneinfo import ZoneInfo
pytest.importorskip("croniter")
from cron.jobs import compute_next_run
# The configured IANA zone the fix must anchor to.
TORONTO = ZoneInfo("America/Toronto")
class TestCronComputeNextRunHonorsConfiguredTz:
"""``compute_next_run`` must keep the wall-clock hour at the cron hour in
the configured IANA timezone, regardless of the offset carried by
``last_run_at`` and across DST transitions."""
def test_toronto_utc_stored_last_run_stays_at_9am_local(self, monkeypatch):
"""A last_run_at stored in UTC must NOT push the next fire to 09:00 UTC."""
monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO)
# Pretend the last fire was recorded at 09:00 UTC (the buggy history).
last_run = "2026-08-03T09:00:39+00:00"
result = compute_next_run(
{"kind": "cron", "expr": "0 9 * * *"}, last_run_at=last_run
)
nxt = datetime.fromisoformat(result)
wall = nxt.astimezone(TORONTO)
assert (wall.hour, wall.minute) == (9, 0), f"expected 09:00 Toronto, got {wall}"
assert nxt.utcoffset() is not None, "result must carry a concrete zone offset"
def test_spring_forward_keeps_9am(self, monkeypatch):
monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO)
# Base just before the spring-forward (Mar 8 2026, 02:00 EST -> 03:00 EDT).
last_run = "2026-03-07T09:00:00-05:00"
result = compute_next_run(
{"kind": "cron", "expr": "0 9 * * *"}, last_run_at=last_run
)
wall = datetime.fromisoformat(result).astimezone(TORONTO)
assert (wall.hour, wall.minute) == (9, 0), f"got {wall}"
def test_fall_back_keeps_9am(self, monkeypatch):
monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO)
# Base just before the fall-back (Nov 1 2026, 02:00 EDT -> 01:00 EST).
last_run = "2026-10-31T09:00:00-04:00"
result = compute_next_run(
{"kind": "cron", "expr": "0 9 * * *"}, last_run_at=last_run
)
wall = datetime.fromisoformat(result).astimezone(TORONTO)
assert (wall.hour, wall.minute) == (9, 0), f"got {wall}"
def test_full_year_stable_at_9am(self, monkeypatch):
"""Walking a full year (both DST transitions) must never drift off 09:00."""
monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO)
last = datetime(2026, 1, 1, 9, 0, 0, tzinfo=TORONTO)
for _ in range(365):
nxt = datetime.fromisoformat(
compute_next_run(
{"kind": "cron", "expr": "0 9 * * *"},
last_run_at=last.isoformat(),
)
)
wall = nxt.astimezone(TORONTO)
assert (wall.hour, wall.minute) == (9, 0), f"drift at {last}: {wall}"
last = nxt