Skip to content

Commit 489581b

Browse files
Use the date's own UTC offset for naive datetimes passed to enqueue_at() (#2548)
A naive datetime given to enqueue_at(), schedule_job() or `rq enqueue --schedule-at` is documented as local time. ScheduledJobRegistry.schedule() and schedule_unique_job() turned it into UTC with time.altzone whenever time.daylight was set, but time.daylight only says the zone has DST rules, not that DST applies on that date. On a server in a DST zone, every naive datetime that falls in standard time was stored an hour early. Convert with datetime.astimezone(), which uses the offset in effect on that date, and replace the mocked time.daylight test with real timezone checks.
1 parent b6907c9 commit 489581b

3 files changed

Lines changed: 25 additions & 58 deletions

File tree

‎rq/registry.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22

33
import calendar
44
import logging
5-
import time
65
import warnings
76
from collections.abc import Sequence
8-
from datetime import datetime, timedelta, timezone
7+
from datetime import datetime, timezone
98
from typing import TYPE_CHECKING, Any, cast
109

1110
from redis.exceptions import WatchError
@@ -644,10 +643,9 @@ def schedule(self, job: Job, scheduled_datetime, pipeline: Pipeline | None = Non
644643
Adds job to registry, scored by its execution time (in UTC).
645644
If datetime has no tzinfo, it will assume local timezone.
646645
"""
647-
# If datetime has no timezone, assume server's local timezone
646+
# If datetime has no timezone, assume server's local timezone (with that date's DST offset)
648647
if not scheduled_datetime.tzinfo:
649-
tz = timezone(timedelta(seconds=-(time.timezone if time.daylight == 0 else time.altzone)))
650-
scheduled_datetime = scheduled_datetime.replace(tzinfo=tz)
648+
scheduled_datetime = scheduled_datetime.astimezone()
651649

652650
timestamp = calendar.timegm(scheduled_datetime.utctimetuple())
653651
connection = pipeline if pipeline is not None else self.connection

‎rq/scripts.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import calendar
22
import logging
3-
import time
4-
from datetime import timedelta, timezone
53
from typing import Any, Literal
64

75
from .defaults import RQ_KEY_PREFIX
@@ -187,8 +185,7 @@ def schedule_unique_job(connection, queue_key, registry_key, job, scheduled_date
187185

188186
# Convert datetime to UTC timestamp (same logic as ScheduledJobRegistry.schedule)
189187
if not scheduled_datetime.tzinfo:
190-
tz = timezone(timedelta(seconds=-(time.timezone if time.daylight == 0 else time.altzone)))
191-
scheduled_datetime = scheduled_datetime.replace(tzinfo=tz)
188+
scheduled_datetime = scheduled_datetime.astimezone()
192189
timestamp = calendar.timegm(scheduled_datetime.utctimetuple())
193190

194191
queues_key = RQ_KEY_PREFIX + ':queues'

‎tests/test_scheduler.py‎

Lines changed: 21 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from rq.serializers import JSONSerializer
1616
from rq.utils import current_timestamp, utcformat
1717
from rq.worker import Worker
18-
from tests import RQTestCase, find_empty_redis_database, ssl_test
18+
from tests import RQTestCase, find_empty_redis_database, ssl_test, system_timezone
1919

2020
from .fixtures import kill_worker, say_hello
2121

@@ -82,54 +82,26 @@ def test_schedule(self):
8282
job.save()
8383
registry = ScheduledJobRegistry(queue=queue)
8484

85-
from datetime import timezone
86-
87-
# If we pass in a datetime with no timezone, `schedule()`
88-
# assumes local timezone so depending on your local timezone,
89-
# the timestamp maybe different
90-
#
91-
# we need to account for the difference between a timezone
92-
# with DST active and without DST active. The time.timezone
93-
# property isn't accurate when time.daylight is non-zero,
94-
# we'll test both.
95-
#
96-
# first, time.daylight == 0 (not in DST).
97-
# mock the sitatuoin for American/New_York not in DST (UTC - 5)
98-
# time.timezone = 18000
99-
# time.daylight = 0
100-
# time.altzone = 14400
101-
102-
mock_day = mock.patch('time.daylight', 0)
103-
mock_tz = mock.patch('time.timezone', 18000)
104-
mock_atz = mock.patch('time.altzone', 14400)
105-
with mock_tz, mock_day, mock_atz:
106-
registry.schedule(job, datetime(2019, 1, 1))
107-
self.assertEqual(
108-
self.connection.zscore(registry.key, job.id), 1546300800 + 18000
109-
) # 2019-01-01 UTC in Unix timestamp
110-
111-
# second, time.daylight != 0 (in DST)
112-
# mock the sitatuoin for American/New_York not in DST (UTC - 4)
113-
# time.timezone = 18000
114-
# time.daylight = 1
115-
# time.altzone = 14400
116-
mock_day = mock.patch('time.daylight', 1)
117-
mock_tz = mock.patch('time.timezone', 18000)
118-
mock_atz = mock.patch('time.altzone', 14400)
119-
with mock_tz, mock_day, mock_atz:
120-
registry.schedule(job, datetime(2019, 1, 1))
121-
self.assertEqual(
122-
self.connection.zscore(registry.key, job.id), 1546300800 + 14400
123-
) # 2019-01-01 UTC in Unix timestamp
124-
125-
# Score is always stored in UTC even if datetime is in a different tz
126-
tz = timezone(timedelta(hours=7))
127-
job = Job.create('myfunc', connection=self.connection)
128-
job.save()
129-
registry.schedule(job, datetime(2019, 1, 1, 7, tzinfo=tz))
130-
self.assertEqual(
131-
self.connection.zscore(registry.key, job.id), 1546300800
132-
) # 2019-01-01 UTC in Unix timestamp
85+
# A naive datetime is local time, at the UTC offset in effect on that date:
86+
# New York is UTC-5 in January and UTC-4 in July. Unique scheduling converts it the same way.
87+
with system_timezone('America/New_York'):
88+
for scheduled_time, timestamp in [
89+
(datetime(2019, 1, 1), 1546300800 + 5 * 3600),
90+
(datetime(2019, 7, 1), 1561939200 + 4 * 3600),
91+
]:
92+
registry.schedule(job, scheduled_time)
93+
self.assertEqual(self.connection.zscore(registry.key, job.id), timestamp)
94+
95+
unique_job = Job.create('myfunc', id=f'unique-{scheduled_time.month}', connection=self.connection)
96+
queue.schedule_job(unique_job, scheduled_time, unique=True)
97+
self.assertEqual(self.connection.zscore(registry.key, unique_job.id), timestamp)
98+
99+
# Score is always stored in UTC even if datetime is in a different tz
100+
tz = timezone(timedelta(hours=7))
101+
job = Job.create('myfunc', connection=self.connection)
102+
job.save()
103+
registry.schedule(job, datetime(2019, 1, 1, 7, tzinfo=tz))
104+
self.assertEqual(self.connection.zscore(registry.key, job.id), 1546300800) # 2019-01-01 UTC in Unix timestamp
133105

134106
def test_remove_jobs(self):
135107
"""Removing job ids from ScheduledJobRegistry. Will be deprecated in the future."""

0 commit comments

Comments
 (0)