diff --git a/cron/jobs.py b/cron/jobs.py index 6d7845c496c25..13c71f682efc9 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -14,7 +14,7 @@ import os import re import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from hermes_constants import get_hermes_home from typing import Optional, Dict, List, Any, Union @@ -274,24 +274,43 @@ def parse_schedule(schedule: str) -> Dict[str, Any]: def _ensure_aware(dt: datetime) -> datetime: - """Return a timezone-aware datetime in Hermes configured timezone. + """Return a timezone-aware datetime in UTC. - Backward compatibility: - - Older stored timestamps may be naive. - - Naive values are interpreted as *system-local wall time* (the timezone - `datetime.now()` used when they were created), then converted to the - configured Hermes timezone. + All cron timestamps are now normalized to UTC to prevent double-firing + after system timezone changes, DST transitions, or config migrations. - This preserves relative ordering for legacy naive timestamps across - timezone changes and avoids false not-due results. + Naive timestamps from older versions are treated as UTC for safety. """ - target_tz = _hermes_now().tzinfo if dt.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo - return dt.replace(tzinfo=local_tz).astimezone(target_tz) - return dt.astimezone(target_tz) + # Treat legacy naive timestamps as UTC (migration safety) + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) +def _migrate_timestamps_to_utc(jobs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """One-time migration: convert legacy naive timestamps to UTC. + + Prevents double-firing after timezone changes, DST, or config migrations. + This is a non-destructive, idempotent migration. + """ + migrated = False + for job in jobs: + for field in ("last_run_at", "next_run_at"): + value = job.get(field) + if value and isinstance(value, str): + try: + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + job[field] = dt.replace(tzinfo=timezone.utc).isoformat() + migrated = True + except Exception: + pass + + if migrated: + logger.info("Cron: migrated legacy naive timestamps to UTC (issue #28934)") + + return jobs + def _recoverable_oneshot_run_at( schedule: Dict[str, Any], now: datetime, diff --git a/tests/cron/test_compute_next_run_last_run_at.py b/tests/cron/test_compute_next_run_last_run_at.py index 0585aab09a13b..42319c5b2bbed 100644 --- a/tests/cron/test_compute_next_run_last_run_at.py +++ b/tests/cron/test_compute_next_run_last_run_at.py @@ -85,3 +85,36 @@ def test_cron_weekly_consistent_with_interval(self, monkeypatch): interval_dt = datetime.fromisoformat(interval_result) assert cron_dt > last_run, f"Cron next {cron_dt} should be after last_run {last_run}" assert interval_dt > last_run, f"Interval next {interval_dt} should be after last_run {last_run}" + + +class TestCronTimezoneMigration: + """Regression tests for issue #28934: Cron jobs double-firing after timezone changes.""" + + def test_naive_timestamp_treated_as_utc(self): + """Legacy naive timestamps should be treated as UTC, not local time.""" + from cron.jobs import _ensure_aware + from datetime import datetime, timezone + + # Simulate a legacy naive timestamp + naive_dt = datetime(2026, 5, 10, 14, 30, 0) # no tzinfo + + result = _ensure_aware(naive_dt) + + assert result.tzinfo is not None + assert result.tzinfo == timezone.utc + assert result.hour == 14 # Should preserve the wall time as UTC + + def test_aware_timestamp_converted_to_utc(self): + """Timezone-aware timestamps should be converted to UTC.""" + from cron.jobs import _ensure_aware + from datetime import datetime, timezone + from zoneinfo import ZoneInfo + + tokyo = ZoneInfo("Asia/Tokyo") + tokyo_dt = datetime(2026, 5, 10, 23, 0, 0, tzinfo=tokyo) # 23:00 JST + + result = _ensure_aware(tokyo_dt) + + assert result.tzinfo == timezone.utc + # 23:00 JST = 14:00 UTC + assert result.hour == 14