Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user