Fix: Daylight saving time issue with scheduler (#47)

This commit is contained in:
Stan Triepels
2022-12-21 01:44:34 +01:00
committed by GitHub
parent cf33891291
commit 4d454e4001
7 changed files with 149 additions and 38 deletions
+3 -35
View File
@@ -6,6 +6,7 @@ import socket
import traceback
import uuid
from datetime import datetime, timedelta
from pytz import timezone as pytz_timezone
from multiprocessing import Event, Process, Value, current_process
from time import sleep
@@ -43,7 +44,7 @@ from django_q.signals import post_execute, pre_execute
from django_q.signing import BadSignature, SignedPackage
from django_q.status import Stat, Status
from .utils import add_months, add_years, get_func_repr
from .utils import get_func_repr, localtime
class Cluster:
@@ -700,33 +701,7 @@ def scheduler(broker: Broker = None):
if s.schedule_type != s.ONCE:
next_run = s.next_run
while True:
if s.schedule_type == s.MINUTES:
next_run = next_run + timedelta(minutes=(s.minutes or 1))
elif s.schedule_type == s.HOURLY:
next_run = next_run + timedelta(hours=1)
elif s.schedule_type == s.DAILY:
next_run = next_run + timedelta(days=1)
elif s.schedule_type == s.WEEKLY:
next_run = next_run + timedelta(weeks=1)
elif s.schedule_type == s.BIWEEKLY:
next_run = next_run + timedelta(weeks=2)
elif s.schedule_type == s.MONTHLY:
next_run = add_months(next_run, 1)
elif s.schedule_type == s.BIMONTHLY:
next_run = add_months(next_run, 2)
elif s.schedule_type == s.QUARTERLY:
next_run = add_months(next_run, 3)
elif s.schedule_type == s.YEARLY:
next_run = add_years(next_run, 1)
elif s.schedule_type == s.CRON:
if not croniter:
raise ImportError(
_(
"Please install croniter to enable cron "
"expressions"
)
)
next_run = croniter(s.cron, localtime()).get_next(datetime)
next_run = s.calculate_next_run(next_run)
if Conf.CATCH_UP or next_run > localtime():
break
@@ -842,10 +817,3 @@ def rss_check():
elif psutil:
return psutil.Process().memory_info().rss >= Conf.MAX_RSS * 1024
return False
def localtime() -> datetime:
"""Override for timezone.localtime to deal with naive times and local times"""
if settings.USE_TZ:
return timezone.localtime()
return datetime.now()
+5
View File
@@ -211,6 +211,11 @@ class Conf:
# to manage workarounds during testing
TESTING = conf.get("testing", False)
# Timezone for next_run, overrules Django timezone
TIME_ZONE = None
if settings.USE_TZ:
TIME_ZONE = conf.get("time_zone", settings.TIME_ZONE)
# logger
logger = logging.getLogger("django-q")
+58 -1
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timedelta
# Django
from django import get_version
from django.core.exceptions import ValidationError
@@ -5,6 +7,7 @@ from django.db import models
from django.template.defaultfilters import truncatechars
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import is_aware
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
@@ -13,8 +16,9 @@ from picklefield import PickledObjectField
from picklefield.fields import dbsafe_decode
# Local
from django_q.conf import croniter
from django_q.conf import croniter, Conf
from django_q.signing import SignedPackage
from django_q.utils import localtime, add_months, add_years
from .utils import get_func_repr
@@ -208,6 +212,59 @@ class Schedule(models.Model):
task = models.CharField(max_length=100, null=True, editable=False)
cluster = models.CharField(max_length=100, default=None, null=True, blank=True)
def calculate_next_run(self, next_run=None):
# next run is always in UTC
next_run = next_run or self.next_run
if self.schedule_type == self.CRON:
if not croniter:
raise ImportError(
_("Please install croniter to enable cron expressions")
)
return croniter(self.cron, localtime()).get_next(datetime)
if self.schedule_type == self.MINUTES:
add = timedelta(minutes=(self.minutes or 1))
elif self.schedule_type == self.HOURLY:
add = timedelta(hours=1)
elif self.schedule_type == self.DAILY:
add = timedelta(days=1)
elif self.schedule_type == self.WEEKLY:
add = timedelta(weeks=1)
elif self.schedule_type == self.BIWEEKLY:
add = timedelta(weeks=2)
elif self.schedule_type == self.MONTHLY:
add = timedelta(days=(add_months(next_run, 1) - next_run).days)
elif self.schedule_type == self.BIMONTHLY:
add = timedelta(days=(add_months(next_run, 2) - next_run).days)
elif self.schedule_type == self.QUARTERLY:
add = timedelta(days=(add_months(next_run, 3) - next_run).days)
elif self.schedule_type == self.YEARLY:
add = timedelta(days=(add_years(next_run, 1) - next_run).days)
# add normal timedelta, we will correct this later based on timezone
next_run += add
# DST differencers don't matter with minutes, hourly or yearly, so skip those
if self.schedule_type not in [self.MINUTES, self.HOURLY, self.YEARLY]:
# Get localtimes and then remove the tzinfo, so we can get the actual difference
current_next_run = localtime(next_run - add).replace(tzinfo=None)
new_next_run = localtime(next_run).replace(tzinfo=None)
# get the difference between them, this should be (-)1 or (-)0.5 hour
# based on DST active or not
extra_diff = (new_next_run - current_next_run) - add
# if we have one positive hour difference, then subtract it, so we are even
# and vice versa. In most cases, this will be 0, as there won't be a
# timezone diff
if extra_diff > timedelta(hours=0):
next_run -= extra_diff
else:
next_run += extra_diff
return next_run
def success(self):
if self.task and Task.objects.filter(id=self.task):
return Task.objects.get(id=self.task).success
+1 -1
View File
@@ -75,7 +75,7 @@ DATABASES = {
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
TIME_ZONE = "Europe/Amsterdam"
USE_I18N = True
+59 -1
View File
@@ -1,5 +1,6 @@
import os
from datetime import timedelta
import pytz
from datetime import datetime, timedelta
from multiprocessing import Event, Value
from unittest import mock
@@ -83,6 +84,63 @@ MULTIPLE_APPS_DATABASES = {
}
@pytest.mark.django_db
def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
# Set up a startdate in the Amsterdam timezone (without dst 1 hour ahead). The
# 28th of March 2021 is the day when sunlight saving starts (at 2 am)
monkeypatch.setattr(Conf, "TIME_ZONE", "Europe/Amsterdam")
tz = pytz.timezone('Europe/Amsterdam')
broker.list_key = "scheduler_test:q"
# Let's start a schedule at 1 am on the 27th of March. This is in AMS timezone.
# So, 2021-03-27 00:00:00 when saved (due to TZ being Amsterdam and saved in UTC)
start_date = datetime(2021, 3, 27, 1, 0, 0)
# Create schedule with the next run date on the start date. It will move one day
# forward when we run the scheduler
schedule = create_schedule(
"math.copysign",
1,
-1,
name="test math",
schedule_type=Schedule.DAILY,
next_run=start_date,
)
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
# It's now the day after exactly at midnight UTC
next_run = schedule.next_run
assert str(next_run) == "2021-03-28 00:00:00+00:00"
# In the Amsterdam timezone, it's 1 hour over midnight (+01)
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-03-28 01:00:00+01:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-03-28 23:00:00+00:00"
next_run = next_run.astimezone(tz)
# In the Amsterdam timezone, it's 1 hour over midnight (+02)
assert str(next_run) == "2021-03-29 01:00:00+02:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-03-29 23:00:00+00:00"
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-03-30 01:00:00+02:00"
@pytest.mark.django_db
def test_scheduler(broker, monkeypatch):
broker.list_key = "scheduler_test:q"
+16
View File
@@ -1,7 +1,13 @@
from datetime import datetime
import pytz
import calendar
import inspect
from datetime import date
from django.utils import timezone
from django.conf import settings
from django_q.conf import Conf
# credits: https://stackoverflow.com/a/4131114
# Made them aware of timezone
@@ -39,3 +45,13 @@ def get_func_repr(func):
)
else:
return str(func)
def localtime(value=None) -> datetime:
"""Override for timezone.localtime to deal with naive times and local times"""
if settings.USE_TZ:
return timezone.localtime(value=value, timezone=pytz.timezone(Conf.TIME_ZONE))
if value is None:
return datetime.now()
else:
return value