From 9b8f553cd47e1832a4a71f01233525c7717c6d6c Mon Sep 17 00:00:00 2001 From: Shanewas Ahmed Date: Wed, 20 May 2026 06:30:15 +0900 Subject: [PATCH 1/3] fix(cron): Normalize timestamps to UTC to prevent double-firing after timezone migration - Changed _ensure_aware() to always use UTC instead of system local time - Legacy naive timestamps are now treated as UTC (safe migration) - This fixes issue #28934 where cron jobs would double-fire after DST or timezone changes Refs: #28934 --- cron/jobs.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 6d7845c496c2..c34142fd19f8 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,22 +274,17 @@ 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 _recoverable_oneshot_run_at( From 74c8a87d3510897f3f6fc467f3212973276de6ef Mon Sep 17 00:00:00 2001 From: Shanewas Ahmed Date: Wed, 20 May 2026 06:31:36 +0900 Subject: [PATCH 2/3] test(cron): Add regression tests for UTC timestamp normalization (#28934) --- .../cron/test_compute_next_run_last_run_at.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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 0585aab09a13..42319c5b2bbe 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 From 956185cdb83396583c91abd46231e6e900b6cc39 Mon Sep 17 00:00:00 2001 From: Shanewas Ahmed Date: Wed, 20 May 2026 06:33:24 +0900 Subject: [PATCH 3/3] refactor(cron): Add safe UTC migration helper for legacy timestamps (#28934) --- cron/jobs.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cron/jobs.py b/cron/jobs.py index c34142fd19f8..13c71f682efc 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -287,6 +287,30 @@ def _ensure_aware(dt: datetime) -> datetime: 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,