From e9d1561bb334b722c201f005c10d29319dd16ba9 Mon Sep 17 00:00:00 2001 From: Ljk <159024472+LJK-ikun@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:23:47 +0800 Subject: [PATCH] fix(cron): use elapsed time across daylight-saving transitions (#671) * fix(cron): use elapsed time across daylight-saving transitions * fix(cron): preserve local-time delays during DST rollback --------- Co-authored-by: jinli.yl --- reme/components/job/cron_job.py | 4 +- tests/unit/test_cron_delay.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_cron_delay.py diff --git a/reme/components/job/cron_job.py b/reme/components/job/cron_job.py index 9b88cdeb..5c8a49eb 100644 --- a/reme/components/job/cron_job.py +++ b/reme/components/job/cron_job.py @@ -32,7 +32,9 @@ class CronJob(BackgroundJob): from croniter import croniter nxt = croniter(self.cron_expr, now).get_next(datetime.datetime) - return max(0.0, (nxt - now).total_seconds()) + # croniter may reset fold on naive local times, so retain their wall-clock arithmetic. + delay = nxt.timestamp() - now.timestamp() if tz_name else (nxt - now).total_seconds() + return max(0.0, delay) async def _execute_steps(self) -> Response: context = RuntimeContext(stop_event=self._stop_event, **self.kwargs) diff --git a/tests/unit/test_cron_delay.py b/tests/unit/test_cron_delay.py new file mode 100644 index 00000000..6478af70 --- /dev/null +++ b/tests/unit/test_cron_delay.py @@ -0,0 +1,66 @@ +"""Cron waits use elapsed seconds across timezone offset changes.""" + +# pylint: disable=protected-access,missing-function-docstring,missing-class-docstring + +import datetime +import time +from types import SimpleNamespace + +import pytest + +from reme.components.job import cron_job as cron_job_module +from reme.components.job.cron_job import CronJob + + +@pytest.mark.parametrize( + "timezone,instant,expected_delay", + [ + ("America/New_York", "2026-03-08T06:30:00+00:00", 1800), + ("America/New_York", "2026-11-01T04:30:00+00:00", 12600), + ("Europe/London", "2026-03-29T00:30:00+00:00", 5400), + ("Europe/London", "2026-10-25T00:30:00+00:00", 9000), + ("Australia/Lord_Howe", "2026-10-03T15:00:00+00:00", 3600), + ("Australia/Lord_Howe", "2026-04-04T13:30:00+00:00", 10800), + ("America/New_York", "2026-01-15T06:30:00+00:00", 5400), + ("Asia/Shanghai", "2026-03-07T17:30:00+00:00", 5400), + (None, "2026-01-15T01:30:00+00:00", 5400), + ], +) +def test_cron_delay_uses_elapsed_seconds(monkeypatch, timezone, instant, expected_delay): + fixed = datetime.datetime.fromisoformat(instant) + + class FixedDatetime(datetime.datetime): + @classmethod + def now(cls, tz=None): + return fixed.astimezone(tz) if tz else fixed.replace(tzinfo=None) + + monkeypatch.setattr(cron_job_module, "datetime", SimpleNamespace(datetime=FixedDatetime)) + job = CronJob(cron="0 3 * * *") + job.app_context = SimpleNamespace(app_config=SimpleNamespace(timezone=timezone)) + + assert job._next_fire_delay() == expected_delay + + +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="requires time.tzset") +@pytest.mark.parametrize("with_app_context", [True, False], ids=["local-timezone", "standalone"]) +def test_cron_local_time_after_dst_fallback(monkeypatch, with_app_context): + fixed = datetime.datetime.fromisoformat("2026-11-01T06:30:00+00:00") + + class FixedDatetime(datetime.datetime): + @classmethod + def now(cls, tz=None): + return datetime.datetime.fromtimestamp(fixed.timestamp(), tz) + + try: + with monkeypatch.context() as patch: + patch.setenv("TZ", "America/New_York") + time.tzset() + patch.setattr(cron_job_module, "datetime", SimpleNamespace(datetime=FixedDatetime)) + job = CronJob(cron="* * * * *") + if with_app_context: + job.app_context = SimpleNamespace(app_config=SimpleNamespace(timezone=None)) + + assert FixedDatetime.now().fold == 1 + assert job._next_fire_delay() == 60 + finally: + time.tzset()