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 <jinli.yl@alibaba-inc.com>
This commit is contained in:
Ljk 2026-11-05 17:23:47 +08:00 • committed by GitHub
parent b784468a10
commit e9d1561bb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 69 additions and 1 deletions

View file

@ -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)

View file

@ -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()