Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normalizing an old-offset aware timestamp to UTC does not preserve the cron wall-clock intent from #28934. In the reported case, 21:00+10 still becomes due at 13:02+02 after both sides are converted to UTC, so this should be handled in the due-job migration/repair path instead.



def _migrate_timestamps_to_utc(jobs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is not called anywhere in the PR diff, so it never migrates persisted last_run_at or next_run_at values at runtime. Either wire it into a real load/repair path with tests, or remove it.

"""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,
Expand Down
33 changes: 33 additions & 0 deletions tests/cron/test_compute_next_run_last_run_at.py
Original file line number Diff line number Diff line change
Expand Up @@ -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