Fix rrule fast forwarding across DST boundaries (#6815)

Fixes an issue where schedules were not running at
the correct time.

Details:

DST is Daylights Saving Time

If the rrule dtstart is "in" a DST period (i.e., March to November)
and the current date is outside of the DST, then the fast forwarding
is not correct.

This is because datetime timedeltas do not honor DST boundaries

The Fix:

Convert the rrule dtstart to UTC before doing operations. Then,
convert back to the original timezone at the end.

Signed-off-by: Seth Foster <fosterbseth@gmail.com>
This commit is contained in:
Seth Foster 2025-02-04 15:27:21 -05:00 committed by GitHub
parent b7b15584af
commit 386f85c59f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 50 additions and 4 deletions

View File

@ -59,8 +59,13 @@ def _fast_forward_rrule(rrule, ref_dt=None):
The operation ensures that the original occurrences (based on the original dtstart)
will match the occurrences after changing the dtstart.
All datetime operations (subtracting dates and adding timedeltas) should be
in UTC to avoid DST issues. As such, the rrule dtstart is converted to UTC
then back to the original timezone at the end.
Returns a new rrule with a new dtstart
'''
if rrule._freq not in {dateutil.rrule.HOURLY, dateutil.rrule.MINUTELY}:
return rrule
@ -70,7 +75,10 @@ def _fast_forward_rrule(rrule, ref_dt=None):
if ref_dt is None:
ref_dt = now()
if rrule._dtstart > ref_dt:
ref_dt = ref_dt.astimezone(datetime.timezone.utc)
rrule_dtstart_utc = rrule._dtstart.astimezone(datetime.timezone.utc)
if rrule_dtstart_utc > ref_dt:
return rrule
interval = rrule._interval if rrule._interval else 1
@ -84,15 +92,15 @@ def _fast_forward_rrule(rrule, ref_dt=None):
if isinstance(interval, float) and not interval.is_integer():
return rrule
seconds_since_dtstart = (ref_dt - rrule._dtstart).total_seconds()
seconds_since_dtstart = (ref_dt - rrule_dtstart_utc).total_seconds()
# it is important to fast forward by a number that is divisible by
# interval. For example, if interval is 7 hours, we fast forward by 7, 14, 21, etc. hours.
# Otherwise, the occurrences after the fast forward might not match the ones before.
# x // y is integer division, lopping off any remainder, so that we get the outcome we want.
interval_aligned_offset = datetime.timedelta(seconds=(seconds_since_dtstart // interval) * interval)
new_start = rrule._dtstart + interval_aligned_offset
new_rrule = rrule.replace(dtstart=new_start)
new_start = rrule_dtstart_utc + interval_aligned_offset
new_rrule = rrule.replace(dtstart=new_start.astimezone(rrule._dtstart.tzinfo))
return new_rrule

View File

@ -43,6 +43,44 @@ def test_fast_forwarded_rrule_matches_original_occurrence(rrulestr):
assert occurrences == orig_occurrences
@pytest.mark.parametrize(
'ref_dt',
[
pytest.param(datetime.datetime(2024, 12, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-out-of-dst'),
pytest.param(datetime.datetime(2024, 6, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-in-dst'),
],
)
@pytest.mark.parametrize(
'rrulestr',
[
pytest.param('DTSTART;TZID=America/New_York:20240118T200000 RRULE:FREQ=MINUTELY;INTERVAL=10', id='rrule-out-of-dst'),
pytest.param('DTSTART;TZID=America/New_York:20240318T000000 RRULE:FREQ=MINUTELY;INTERVAL=10', id='rrule-in-dst'),
pytest.param(
'DTSTART;TZID=Europe/Lisbon:20230703T005800 RRULE:INTERVAL=10;FREQ=MINUTELY;BYHOUR=9,10,11,12,13,14,15,16,17,18,19,20,21', id='rrule-in-dst-by-hour'
),
],
)
def test_fast_forward_across_dst(rrulestr, ref_dt):
'''
Ensure fast forward works across daylight savings boundaries
"in dst" means between March and November
"out of dst" means between November and March the following year
Assert that the resulting fast forwarded date is included in the original rrule
occurrence list
'''
rruleset = Schedule.rrulestr(rrulestr, ref_dt=ref_dt)
gen = rruleset.xafter(ref_dt, count=200)
occurrences = [i for i in gen]
orig_rruleset = dateutil.rrule.rrulestr(rrulestr, forceset=True)
gen = orig_rruleset.xafter(ref_dt, count=200)
orig_occurrences = [i for i in gen]
assert occurrences == orig_occurrences
def test_fast_forward_rrule_hours():
'''
Generate an rrule for each hour of the day