8000
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions django_celery_beat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from . import querysets, validators
from .clockedschedule import clocked
from .tzcrontab import TzAwareCrontab
from .utils import make_aware, now
from .utils import clocked_due_after_next_sync, make_aware, now

_CRON_DESCRIPTOR_OPTIONS = CronDescriptorOptions()
_CRON_DESCRIPTOR_OPTIONS.use_24hour_time_format = False
Expand Down Expand Up @@ -432,6 +432,14 @@ class Meta:

@classmethod
def changed(cls, instance, **kwargs):
if (
kwargs.get("created")
and instance.clocked
and clocked_due_after_next_sync(instance.clocked.clocked_time)
):
# No forced reload needed: a regular sync will happen
# before this task is due
return
Comment thread
phanky1 marked this conversation as resolved.
if not instance.no_changes:
cls.update_changed()

Expand Down Expand Up @@ -643,8 +651,9 @@ def save(self, *args, **kwargs):
self.last_run_at = None
self._clean_expires()
self.validate_unique()
created = self._state.adding
super().save(*args, **kwargs)
PeriodicTasks.changed(self)
PeriodicTasks.changed(self, created=created)

def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
Expand Down
14 changes: 4 additions & 10 deletions django_celery_beat/schedulers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,9 @@
from .clockedschedule import clocked
from .models import (ClockedSchedule, CrontabSchedule, IntervalSchedule,
PeriodicTask, PeriodicTasks, SolarSchedule)
from .utils import NEVER_CHECK_TIMEOUT, aware_now, now

# This scheduler must wake up more frequently than the
# regular of 5 minutes because it needs to take external
# changes to the schedule into account.
DEFAULT_MAX_INTERVAL = 5 # seconds
SCHEDULE_SYNC_MAX_INTERVAL = 300 # 5 minutes
from .utils import (DEFAULT_MAX_INTERVAL, NEVER_CHECK_TIMEOUT,
SCHEDULE_SYNC_MAX_INTERVAL, aware_now,
next_schedule_sync_at)

ADD_ENTRY_ERROR = """\
Cannot add entry %r to database schedule: %r. Contents: %r
Expand Down Expand Up @@ -277,9 +273,7 @@ def enabled_models(self):
return list(self.enabled_models_qs())

def enabled_models_qs(self):
next_schedule_sync = now() + datetime.timedelta(
seconds=SCHEDULE_SYNC_MAX_INTERVAL
)
next_schedule_sync = next_schedule_sync_at()
exclude_clock_tasks_query = Q(
clocked__isnull=False,
clocked__clocked_time__gt=next_schedule_sync
Expand Down
14 changes: 10 additions & 4 deletions django_celery_beat/signals.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Django Application signals."""
from .utils import clocked_due_after_next_sync


def signals_connect():
Expand All @@ -9,9 +10,6 @@ def signals_connect():
IntervalSchedule, PeriodicTask, PeriodicTasks,
SolarSchedule)

signals.pre_save.connect(
PeriodicTasks.changed, sender=PeriodicTask
)
signals.pre_delete.connect(
PeriodicTasks.changed, sender=PeriodicTask
)
Expand All @@ -38,8 +36,16 @@ def signals_connect():
)

signals.post_save.connect(
PeriodicTasks.update_changed, sender=ClockedSchedule
clocked_schedule_post_save, sender=ClockedSchedule
)
signals.post_delete.connect(
PeriodicTasks.update_changed, sender=ClockedSchedule
)


def clocked_schedule_post_save(sender, instance, created, **kwargs):
if created and clocked_due_after_next_sync(instance.clocked_time):
# No forced reload needed: a regular sync will happen before this task is due
return
from .models import PeriodicTasks # noqa: PLC0415
PeriodicTasks.update_changed()
33 changes: 33 additions & 0 deletions django_celery_beat/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
except ImportError:
from backports.zoneinfo import ZoneInfo # Python 3.8

from celery import current_app
from django.conf import settings
from django.utils import timezone

is_aware = timezone.is_aware
# celery schedstate return None will make it not work
NEVER_CHECK_TIMEOUT = 100000000
# This scheduler must wake up more frequently than the
# regular interval of 5 minutes because it needs to take
# external changes to the schedule into account.
DEFAULT_MAX_INTERVAL = 5 # seconds
SCHEDULE_SYNC_MAX_INTERVAL = 300 # 5 minutes

# see Issue #222
now_localtime = getattr(timezone, 'template_localtime', timezone.localtime)
Expand Down Expand Up @@ -64,3 +70,30 @@ def is_database_scheduler(scheduler):
scheduler == 'django'
or issubclass(symbol_by_name(scheduler), DatabaseScheduler)
)


def next_schedule_sync_at(now_func=now):
"""Scheduled time of the next full schedule sync."""
return now_func() + datetime.timedelta(seconds=SCHEDULE_SYNC_MAX_INTERVAL)


def next_schedule_sync_by(now_func=now):
"""Latest time by which the next full schedule sync must have run."""
max_interval = (
current_app.conf.beat_max_loop_interval or DEFAULT_MAX_INTERVAL)
return next_schedule_sync_at(now_func) + datetime.timedelta(seconds=max_interval)


def clocked_due_after_next_sync(clocked_time):
"""True if the clocked task is due after the next full schedule sync."""
use_tz = getattr(settings, 'USE_TZ', False)
clocked_aware = timezone.is_aware(clocked_time)
now_func = now
# keep clocked_time and the deadline both aware or both naive
if clocked_aware and not use_tz:
now_func = aware_now
elif use_tz and not clocked_aware:
clocked_time = timezone.make_aware(
clocked_time, timezone.get_default_timezone()
)
return clocked_time > next_schedule_sync_by(now_func)
96 changes: 94 additions & 2 deletions t/unit/test_schedulers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@
from django.test import RequestFactory, override_settings
from django.utils import timezone

from django_celery_beat import schedulers
from django_celery_beat import schedulers, utils
from django_celery_beat.admin import PeriodicTaskAdmin
from django_celery_beat.clockedschedule import clocked
from django_celery_beat.models import (DAYS, ClockedSchedule, CrontabSchedule,
IntervalSchedule, PeriodicTask,
PeriodicTasks, SolarSchedule)
from django_celery_beat.tzcrontab import TzAwareCrontab
from django_celery_beat.utils import NEVER_CHECK_TIMEOUT, make_aware
from django_celery_beat.utils import (NEVER_CHECK_TIMEOUT,
SCHEDULE_SYNC_MAX_INTERVAL, make_aware)

_ids = count(0)

Expand Down Expand Up @@ -1583,6 +1584,97 @@ def test_track_changes(self):
assert y
assert y > x

def test_clocked_create_in_window_tracks_change(self):
assert PeriodicTasks.last_change() is None
ClockedSchedule.objects.create(
clocked_time=make_aware(datetime.now() + timedelta(minutes=2))
)
assert PeriodicTasks.last_change() is not None

def test_clocked_create_just_past_window_tracks_change(self):
# Just past the sync window but within one beat loop of it: must
# still track, since the next sync may not run before it is due.
assert PeriodicTasks.last_change() is None
soon = datetime.now() + timedelta(seconds=SCHEDULE_SYNC_MAX_INTERVAL + 2)
ClockedSchedule.objects.create(clocked_time=make_aware(soon))
assert PeriodicTasks.last_change() is not None

def test_clocked_create_within_configured_loop_tracks_change(
self, monkeypatch):
# A larger configured beat loop widens the next-sync deadline, so a
# task that would skip under the default loop must still track.
monkeypatch.setattr(
utils.current_app.conf, "beat_max_loop_interval", 600)
assert PeriodicTasks.last_change() is None
soon = datetime.now() + timedelta(seconds=SCHEDULE_SYNC_MAX_INTERVAL + 60)
ClockedSchedule.objects.create(clocked_time=make_aware(soon))
assert PeriodicTasks.last_change() is not None

def test_clocked_create_out_of_window_skips_change(self):
assert PeriodicTasks.last_change() is None
ClockedSchedule.objects.create(
clocked_time=make_aware(datetime.now() + timedelta(minutes=10))
)
assert PeriodicTasks.last_change() is None

@override_settings(USE_TZ=True)
def test_clocked_create_out_of_window_tz_mismatch_skips_change(self):
# Far future + tz mismatch: a NAIVE clocked_time vs the aware
# next-sync deadline (USE_TZ=True) must skip the change
assert PeriodicTasks.last_change() is None
ClockedSchedule.objects.create(
clocked_time=datetime.now() + timedelta(minutes=10)
)
assert PeriodicTasks.last_change() is None

def test_clocked_update_out_of_window_tracks_change(self):
cs = ClockedSchedule.objects.create(
clocked_time=make_aware(datetime.now() + timedelta(minutes=10))
)
assert PeriodicTasks.last_change() is None
cs.clocked_time = make_aware(datetime.now() + timedelta(minutes=11))
cs.save()
assert PeriodicTasks.last_change() is not None

def test_task_clocked_create_in_window_tracks_change(self):
m = self.create_model_clocked(
clocked(make_aware(datetime.now() + timedelta(minutes=2)))
)
before = PeriodicTasks.last_change()
m.save()
assert PeriodicTasks.last_change() > before

def test_task_clocked_create_out_of_window_skips_change(self):
assert PeriodicTasks.last_change() is None
m = self.create_model_clocked(
clocked(make_aware(datetime.now() + timedelta(minutes=10)))
)
m.save()
assert PeriodicTasks.last_change() is None

@override_settings(USE_TZ=True)
def test_task_clocked_create_out_of_window_tz_mismatch_skips_change(self):
# Far future + tz mismatch: PeriodicTasks.changed compares the in-memory
# clocked.clocked_time (forced naive here) with the aware
# next-sync deadline (USE_TZ=True) and must skip
assert PeriodicTasks.last_change() is None
m = self.create_model_clocked(
clocked(datetime.now() + timedelta(minutes=10))
)
m.clocked.clocked_time = datetime.now() + timedelta(minutes=10) # naive
m.save()
assert PeriodicTasks.last_change() is None

def test_task_clocked_update_out_of_window_tracks_change(self):
m = self.create_model_clocked(
clocked(make_aware(datetime.now() + timedelta(minutes=10)))
)
m.save()
assert PeriodicTasks.last_change() is None
m.args = '[16, 16]'
m.save()
assert PeriodicTasks.last_change() is not None


@pytest.mark.django_db
class test_modeladmin_PeriodicTaskAdmin(SchedulerCase):
Expand Down
9 changes: 8 additions & 1 deletion
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from datetime import timedelta

import pytest
from django.test import TestCase, override_settings
from django.utils import timezone

from django_celery_beat.utils import aware_now
from django_celery_beat.utils import aware_now, clocked_due_after_next_sync


@pytest.mark.django_db
Expand All @@ -28,3 +30,8 @@ def test_aware_now_with_use_tz_false_default_timezone(self):
result = aware_now()
assert timezone.is_aware(result)
assert str(result.tzinfo) == "UTC"

@override_settings(USE_TZ=False)
def test_aware_clocked_use_tz_false(self):
assert clocked_due_after_next_sync(aware_now() + timedelta(days=1))
assert not clocked_due_after_next_sync(aware_now())
Loading
0