Skip to content

Commit ad6b2dc

Browse files
authored
Added fix for get_next_fire_time not advancing through fold with unfolded previous_fire_time (#1094)
1 parent f4df139 commit ad6b2dc

7 files changed

Lines changed: 130 additions & 22 deletions

File tree

docs/versionhistory.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ Version history
44
To find out how to migrate your application from a previous version of
55
APScheduler, see the :doc:`migration section <migration>`.
66

7+
**UNRELEASED**
8+
9+
- Fixed an issue where a job using a ``CronTrigger`` scheduled in a repeated time
10+
interval during DST transitions could cause the scheduler to get stuck in an infinite
11+
loop
12+
(#1021 <https://github.com/agronholm/apscheduler/issues/1021>_; PR by @soulofakuma)
13+
714
**3.11.1**
815

916
- Fixed ``scheduler.shutdown()`` not raising ``SchedulerNotRunning`` (or raising the

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ zookeeper = ["kazoo"]
5151
test = [
5252
"APScheduler[mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper,etcd]",
5353
"pytest",
54+
"pytest-timeout",
5455
"anyio >= 4.5.2",
5556
"PySide6; python_implementation == 'CPython' and python_version < '3.14'",
5657
"gevent; python_version < '3.14'",

src/apscheduler/job.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import Iterable, Mapping
2+
from datetime import timezone
23
from inspect import isclass, ismethod
34
from uuid import uuid4
45

@@ -12,6 +13,8 @@
1213
ref_to_obj,
1314
)
1415

16+
UTC = timezone.utc
17+
1518

1619
class Job:
1720
"""
@@ -145,7 +148,7 @@ def _get_run_times(self, now):
145148
"""
146149
run_times = []
147150
next_run_time = self.next_run_time
148-
while next_run_time and next_run_time <= now:
151+
while next_run_time and next_run_time.astimezone(UTC) <= now.astimezone(UTC):
149152
run_times.append(next_run_time)
150153
next_run_time = self.trigger.get_next_fire_time(next_run_time, now)
151154

src/apscheduler/triggers/cron/__init__.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timedelta
1+
from datetime import datetime, timedelta, timezone
22

33
from tzlocal import get_localzone
44

@@ -16,8 +16,11 @@
1616
convert_to_datetime,
1717
datetime_ceil,
1818
datetime_repr,
19+
datetime_utc_add,
1920
)
2021

22+
UTC = timezone.utc
23+
2124

2225
class CronTrigger(BaseTrigger):
2326
"""
@@ -183,9 +186,7 @@ def _increment_field_value(self, dateval, fieldnum):
183186
i += 1
184187

185188
difference = datetime(**values) - dateval.replace(tzinfo=None)
186-
dateval = datetime.fromtimestamp(
187-
dateval.timestamp() + difference.total_seconds(), self.timezone
188-
)
189+
dateval = datetime_utc_add(dateval, difference)
189190
return dateval, fieldnum
190191

191192
def _set_field_value(self, dateval, fieldnum, new_value):
@@ -202,19 +203,23 @@ def _set_field_value(self, dateval, fieldnum, new_value):
202203
return datetime(**values, tzinfo=self.timezone, fold=dateval.fold)
203204

204205
def get_next_fire_time(self, previous_fire_time, now):
205-
# If datetime is folded, cast in ISO format to ensure they advance correctly
206-
if previous_fire_time and previous_fire_time.fold == 1:
207-
previous_fire_time = datetime.fromisoformat(previous_fire_time.isoformat())
208-
209-
if now.fold == 1:
210-
now = datetime.fromisoformat(now.isoformat()) + timedelta(microseconds=1)
211-
212206
if previous_fire_time:
213-
start_date = min(now, previous_fire_time + timedelta(microseconds=1))
207+
start_date = min(
208+
now.astimezone(UTC),
209+
datetime_utc_add(
210+
previous_fire_time, timedelta(microseconds=1)
211+
).astimezone(UTC),
212+
).astimezone(self.timezone)
214213
if start_date == previous_fire_time:
215-
start_date += timedelta(microseconds=1)
214+
start_date = datetime_utc_add(start_date, timedelta(microseconds=1))
216215
else:
217-
start_date = max(now, self.start_date) if self.start_date else now
216+
start_date = (
217+
max(now.astimezone(UTC), self.start_date.astimezone(UTC)).astimezone(
218+
self.timezone
219+
)
220+
if self.start_date
221+
else now
222+
)
218223

219224
fieldnum = 0
220225
next_date = datetime_ceil(start_date).astimezone(self.timezone)

src/apscheduler/util.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
else:
3636
from zoneinfo import ZoneInfo
3737

38+
UTC = timezone.utc
39+
3840

3941
class _Undefined:
4042
def __nonzero__(self):
@@ -236,10 +238,32 @@ def datetime_ceil(dateval):
236238
237239
"""
238240
if dateval.microsecond > 0:
239-
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
241+
return datetime_utc_add(
242+
dateval, timedelta(seconds=1, microseconds=-dateval.microsecond)
243+
)
244+
240245
return dateval
241246

242247

248+
def datetime_utc_add(dateval: datetime, tdelta: timedelta) -> datetime:
249+
"""
250+
Adds an timedelta to a datetime in UTC for correct datetime arithmetic across
251+
Daylight Saving Time changes
252+
253+
:param dateval: The date to add to
254+
:type dateval: datetime
255+
:param operand: The timedelta to add to the datetime
256+
:type operand: timedelta
257+
:return: The sum of the datetime and the timedelta
258+
:rtype: datetime
259+
"""
260+
original_tz = dateval.tzinfo
261+
if original_tz is None:
262+
return dateval + tdelta
263+
264+
return (dateval.astimezone(UTC) + tdelta).astimezone(original_tz)
265+
266+
243267
def datetime_repr(dateval):
244268
return dateval.strftime("%Y-%m-%d %H:%M:%S %Z") if dateval else "None"
245269

tests/test_job.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import gc
2+
import sys
23
import weakref
34
from datetime import datetime, timedelta
45
from functools import partial
@@ -11,6 +12,11 @@
1112
from apscheduler.triggers.date import DateTrigger
1213
from apscheduler.util import localize
1314

15+
if sys.version_info < (3, 9):
16+
from backports.zoneinfo import ZoneInfo
17+
else:
18+
from zoneinfo import ZoneInfo
19+
1420

1521
def dummyfunc():
1622
pass
@@ -103,6 +109,32 @@ def test_get_run_times(create_job, timezone):
103109
assert run_times == expected_times
104110

105111

112+
@pytest.mark.timeout(5)
113+
def test_get_run_times_dst_transition(create_job):
114+
"""Tests that Job._get_run_times does not run into an endless loop due to datetime comparison"""
115+
timezone = ZoneInfo("US/Eastern")
116+
next_run_time = datetime(2025, 11, 2, 1, 0, 10, tzinfo=timezone)
117+
now = datetime(2025, 11, 2, 1, 0, 10, 10, tzinfo=timezone)
118+
next_next_run_time = datetime(2025, 11, 2, 1, 0, 10, fold=1, tzinfo=timezone)
119+
job = create_job(
120+
trigger="cron",
121+
trigger_args={"timezone": timezone, "hour": 1, "minute": 0, "second": 10},
122+
next_run_time=next_next_run_time,
123+
func=dummyfunc,
124+
)
125+
job.next_run_time = next_run_time
126+
127+
run_times = job._get_run_times(now)
128+
assert len(run_times) == 1
129+
assert str(run_times[0]) == str(next_run_time)
130+
131+
run_times = job._get_run_times(now.replace(fold=1))
132+
assert len(run_times) == 2
133+
assert list(map(str, run_times)) == list(
134+
map(str, [next_run_time, next_next_run_time])
135+
)
136+
137+
106138
def test_private_modify_bad_id(job):
107139
"""Tests that only strings are accepted for job IDs."""
108140
del job.id

tests/triggers/test_cron.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -265,31 +265,59 @@ def test_different_tz(timezone):
265265

266266

267267
@pytest.mark.parametrize(
268-
"trigger_args, start_date, start_date_fold, correct_next_date",
268+
"trigger_args, start_date, start_date_fold, correct_next_date, correct_next_date_fold",
269269
[
270-
({"hour": 8}, datetime(2013, 3, 9, 12), False, datetime(2013, 3, 10, 8)),
271-
({"hour": 8}, datetime(2013, 11, 2, 12), True, datetime(2013, 11, 3, 8)),
270+
({"hour": 8}, datetime(2013, 3, 9, 12), 0, datetime(2013, 3, 10, 8), 0),
271+
({"hour": 8}, datetime(2013, 11, 2, 12), 1, datetime(2013, 11, 3, 8), 0),
272+
(
273+
{"hour": 1, "minute": 30},
274+
datetime(2013, 11, 3, 0, 30),
275+
0,
276+
datetime(2013, 11, 3, 1, 30),
277+
0,
278+
),
279+
(
280+
{"hour": 1, "minute": 30},
281+
datetime(2013, 11, 3, 1, 30, 5),
282+
0,
283+
datetime(2013, 11, 3, 1, 30),
284+
1,
285+
),
286+
(
287+
{"hour": 1, "minute": 30},
288+
datetime(2013, 11, 3, 1, 30, 5),
289+
1,
290+
datetime(2013, 11, 4, 1, 30),
291+
0,
292+
),
272293
(
273294
{"minute": "*/30"},
274295
datetime(2013, 3, 10, 1, 35),
275296
1,
276297
datetime(2013, 3, 10, 3),
298+
0,
277299
),
278300
(
279301
{"minute": "*/30"},
280302
datetime(2013, 11, 3, 1, 35),
281303
0,
282304
datetime(2013, 11, 3, 1),
305+
1,
283306
),
284307
],
285308
ids=[
286309
"absolute_spring",
287310
"absolute_autumn",
311+
"absolute_autumn_from_before_into_repeated_interval",
312+
"absolute_autumn_from_repeated_into_repeated_interval",
313+
"absolute_autumn_from_repeated_interval_to_after",
288314
"interval_spring",
289315
"interval_autumn",
290316
],
291317
)
292-
def test_dst_change(trigger_args, start_date, start_date_fold, correct_next_date):
318+
def test_dst_change(
319+
trigger_args, start_date, start_date_fold, correct_next_date, correct_next_date_fold
320+
):
293321
"""
294322
Making sure that CronTrigger works correctly when crossing the DST switch threshold.
295323
Note that you should explicitly compare datetimes as strings to avoid the internal datetime
@@ -299,8 +327,16 @@ def test_dst_change(trigger_args, start_date, start_date_fold, correct_next_date
299327
timezone = ZoneInfo("US/Eastern")
300328
trigger = CronTrigger(timezone=timezone, **trigger_args)
301329
start_date = start_date.replace(tzinfo=timezone, fold=start_date_fold)
302-
correct_next_date = correct_next_date.replace(tzinfo=timezone, fold=1)
330+
correct_next_date = correct_next_date.replace(
331+
tzinfo=timezone, fold=correct_next_date_fold
332+
)
303333
assert str(trigger.get_next_fire_time(None, start_date)) == str(correct_next_date)
334+
assert str(trigger.get_next_fire_time(start_date, start_date)) == str(
335+
correct_next_date
336+
)
337+
assert str(trigger.get_next_fire_time(start_date, correct_next_date)) == str(
338+
correct_next_date
339+
)
304340

305341

306342
def test_dst_change_2(timezone):
@@ -310,7 +346,7 @@ def test_dst_change_2(timezone):
310346
"""
311347
timezone = ZoneInfo("Europe/Helsinki")
312348
trigger = CronTrigger(minute=30, timezone=timezone)
313-
start_date = datetime(2017, 10, 29, 3, 30, tzinfo=timezone, fold=1)
349+
start_date = datetime(2017, 10, 29, 3, 30, 0, 5, tzinfo=timezone, fold=1)
314350
correct_next_date = datetime(2017, 10, 29, 4, 30, tzinfo=timezone, fold=0)
315351
assert str(trigger.get_next_fire_time(None, start_date)) == str(correct_next_date)
316352
assert str(trigger.get_next_fire_time(start_date, start_date)) == str(

0 commit comments

Comments
 (0)