Files
aiturk-hermes-ide/tests/cron/test_cron_timezone_migration_catchup.py
T

214 lines
8.3 KiB
Python

"""Timezone-migration silent misfire on the cron fire path.
Production incident: after upgrading from a build that scheduled in UTC to
one that honours the profile timezone (Europe/Brussels), daily cron jobs
stopped running. Their ``jobs.json`` rows still held pre-migration instants
like ``2026-09-02T04:00:00+00:00`` for expr ``0 4 * * *``. ``_ensure_aware``
normalizes that to ``06:00+02``, which ``0 4 * * *`` excludes, so the
stale-expression guard (#93049) classified it as a direct ``jobs.json`` edit,
logged exactly that, and re-anchored to tomorrow WITHOUT firing — the due
occurrence vanished with no failure anywhere.
The fix classifies the mismatch instead of assuming an edit: an instant whose
own wall clock is a legal occurrence, and which only left the lattice because
normalization changed its offset, is a representation migration and fires.
These exercise the real store against a temp ``HERMES_HOME`` (no mocks) per
the E2E-over-mocks discipline for file-touching code.
"""
from __future__ import annotations
from datetime import datetime
import pytest
@pytest.fixture
def temp_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME so jobs.json doesn't touch the real store."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
yield tmp_path
@pytest.fixture(autouse=True)
def _reset_migration_counters(monkeypatch):
"""Module-level telemetry counters must not leak between tests."""
from cron import jobs as J
monkeypatch.setattr(J, "_timezone_migration_catchups", 0)
monkeypatch.setattr(J, "_timezone_migration_catchups_recent", [])
yield
# Europe/Brussels is +02:00 on this date; the legacy row was written by a
# build that scheduled everything at the UTC offset.
_BRUSSELS_NOW = datetime.fromisoformat("2026-09-02T06:05:00+02:00")
_LEGACY_UTC_NEXT_RUN = "2026-09-02T04:00:00+00:00"
_DAILY_0400 = "0 4 * * *"
def _write_cron_job(expr: str, next_run_at: str, name: str = "t") -> str:
"""Persist a cron job with a pinned next_run_at (the legacy-row shape)."""
from cron.jobs import create_job, load_jobs, save_jobs
job = create_job(prompt="x", schedule="every 5m", name=name)
jobs = load_jobs()
for j in jobs:
if j["id"] == job["id"]:
j["schedule"] = {"kind": "cron", "expr": expr}
j["next_run_at"] = next_run_at
save_jobs(jobs)
return job["id"]
def test_legacy_utc_offset_next_run_still_fires(temp_home, monkeypatch):
"""The incident case: a pre-migration +00:00 instant for a Brussels
``0 4 * * *`` job must fire its due occurrence, not be re-anchored away."""
from cron.jobs import get_due_jobs, get_timezone_migration_catchup_stats
monkeypatch.setattr("cron.jobs._hermes_now", lambda: _BRUSSELS_NOW)
jid = _write_cron_job(_DAILY_0400, _LEGACY_UTC_NEXT_RUN)
due = get_due_jobs()
assert jid in [j["id"] for j in due]
stats = get_timezone_migration_catchup_stats()
assert stats["timezone_migration_catchups"] == 1
record = stats["recent"][0]
assert record["job_id"] == jid
assert record["expr"] == _DAILY_0400
assert record["stored_next_run_at"] == _LEGACY_UTC_NEXT_RUN
assert record["normalized_next_run_at"] == "2026-09-02T06:00:00+02:00"
def test_legacy_offset_catchup_fires_at_most_once(temp_home, monkeypatch):
"""The catch-up run is a single fire: once the scheduler advances the
job, the legacy instant is gone and a second scan finds nothing due."""
from cron.jobs import advance_next_run, get_due_jobs, get_job
monkeypatch.setattr("cron.jobs._hermes_now", lambda: _BRUSSELS_NOW)
jid = _write_cron_job(_DAILY_0400, _LEGACY_UTC_NEXT_RUN)
assert jid in [j["id"] for j in get_due_jobs()]
assert advance_next_run(jid) is True
# Re-anchored to tomorrow's occurrence, expressed in the configured zone.
assert get_job(jid)["next_run_at"] == "2026-09-03T04:00:00+02:00"
assert [j["id"] for j in get_due_jobs() if j["id"] == jid] == []
def test_genuine_expr_edit_still_reanchors_without_firing(temp_home, monkeypatch):
"""#93049 protection intact: a stale instant in the CURRENT offset (no
representation change) is still treated as an edit and does not fire."""
from cron.jobs import get_due_jobs, get_job, get_timezone_migration_catchup_stats
monkeypatch.setattr("cron.jobs._hermes_now", lambda: _BRUSSELS_NOW)
# Stored at the configured offset, but the expr was edited to 09:00.
jid = _write_cron_job("0 9 * * *", "2026-09-02T04:00:00+02:00")
due = get_due_jobs()
assert [j["id"] for j in due if j["id"] == jid] == []
assert get_job(jid)["next_run_at"] == "2026-09-02T09:00:00+02:00"
assert (
get_timezone_migration_catchup_stats()["timezone_migration_catchups"] == 0
)
def test_expr_edit_on_a_legacy_offset_row_still_does_not_fire(temp_home, monkeypatch):
"""A legacy +00:00 row whose expr was ALSO edited must not fire: the
stored wall clock is not an occurrence of the new expression either, so
the migration escape hatch does not open."""
from cron.jobs import get_due_jobs, get_job, get_timezone_migration_catchup_stats
monkeypatch.setattr("cron.jobs._hermes_now", lambda: _BRUSSELS_NOW)
jid = _write_cron_job("0 9 * * *", _LEGACY_UTC_NEXT_RUN)
due = get_due_jobs()
assert [j["id"] for j in due if j["id"] == jid] == []
assert get_job(jid)["next_run_at"] == "2026-09-02T09:00:00+02:00"
assert (
get_timezone_migration_catchup_stats()["timezone_migration_catchups"] == 0
)
def test_future_local_wall_clock_is_left_scheduled(temp_home, monkeypatch):
"""A legacy row whose normalized instant has not arrived yet is simply
not due — no catch-up, no re-anchor, no telemetry."""
from cron.jobs import get_due_jobs, get_job, get_timezone_migration_catchup_stats
before_due = datetime.fromisoformat("2026-09-02T05:00:00+02:00")
monkeypatch.setattr("cron.jobs._hermes_now", lambda: before_due)
jid = _write_cron_job(_DAILY_0400, _LEGACY_UTC_NEXT_RUN)
due = get_due_jobs()
assert [j["id"] for j in due if j["id"] == jid] == []
assert get_job(jid)["next_run_at"] == _LEGACY_UTC_NEXT_RUN
assert (
get_timezone_migration_catchup_stats()["timezone_migration_catchups"] == 0
)
def test_future_stored_wall_clock_still_takes_the_offset_repair_path(
temp_home, monkeypatch
):
"""#28934 regression: a westward TZ move (+10 -> +02) that makes a still-
future wall clock look due recomputes rather than firing early, and is
NOT reclassified as a migration catch-up."""
from cron.jobs import get_due_jobs, get_job, get_timezone_migration_catchup_stats
scan_time = datetime.fromisoformat("2026-09-02T14:00:00+02:00")
monkeypatch.setattr("cron.jobs._hermes_now", lambda: scan_time)
jid = _write_cron_job("0 21 * * *", "2026-09-02T21:00:00+10:00")
due = get_due_jobs()
assert [j["id"] for j in due if j["id"] == jid] == []
assert get_job(jid)["next_run_at"] == "2026-09-02T21:00:00+02:00"
assert (
get_timezone_migration_catchup_stats()["timezone_migration_catchups"] == 0
)
def test_classifier_separates_migration_from_edit(temp_home):
"""Unit-level: the three classifications the fire path branches on."""
from cron.jobs import (
STALE_CRON_EXPR_EDIT,
STALE_CRON_MATCH,
STALE_CRON_TIMEZONE_MIGRATION,
_classify_stale_cron_next_run,
)
daily = {"kind": "cron", "expr": _DAILY_0400}
raw_legacy = datetime.fromisoformat(_LEGACY_UTC_NEXT_RUN)
normalized = datetime.fromisoformat("2026-09-02T06:00:00+02:00")
on_lattice = datetime.fromisoformat("2026-09-02T04:00:00+02:00")
# Stored instant already occurs under the current expression.
assert (
_classify_stale_cron_next_run(daily, on_lattice, on_lattice)
== STALE_CRON_MATCH
)
# Only the offset representation changed.
assert (
_classify_stale_cron_next_run(daily, raw_legacy, normalized)
== STALE_CRON_TIMEZONE_MIGRATION
)
# Wall clock never moved, so a mismatch can only be a schedule edit.
assert (
_classify_stale_cron_next_run(
{"kind": "cron", "expr": "0 9 * * *"}, on_lattice, on_lattice
)
== STALE_CRON_EXPR_EDIT
)
# Wall clock moved, but the stored wall clock is not an occurrence either.
assert (
_classify_stale_cron_next_run(
{"kind": "cron", "expr": "0 9 * * *"}, raw_legacy, normalized
)
== STALE_CRON_EXPR_EDIT
)