Skip to content

Commit bab79d9

Browse files
authored
Do not blindly delete duplicate schedules (#389)
* Do not blindly delete duplicate schedules Because there are no unique constraints on schedule models (except for SolarSchedule), schedules tables can contain duplicates, those duplicate schedules should not be blindly deleted when encountered because they can be linked to existing tasks and would cause the tasks to be also deleted (on delete cascade). Instead we now just return the first duplicate found, just to avoid creating further duplicates. This fixes issue #322. * Fixed flake8 warnings * More robust tests * Removed MultipleObjectsReturned block - removed MultipleObjectsReturned code block for solar schedules as unique_togther constraint safely prevents duplicates - removed related test method * DRY
1 parent 4b9021d commit bab79d9

File tree

2 files changed

+54
-10
lines changed

2 files changed

+54
-10
lines changed

django_celery_beat/models.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,13 @@ def from_schedule(cls, schedule):
117117
spec = {'event': schedule.event,
118118
'latitude': schedule.lat,
119119
'longitude': schedule.lon}
120+
121+
# we do not check for MultipleObjectsReturned exception here because
122+
# the unique_together constraint safely prevents from duplicates
120123
try:
121124
return cls.objects.get(**spec)
122125
except cls.DoesNotExist:
123126
return cls(**spec)
124-
except MultipleObjectsReturned:
125-
cls.objects.filter(**spec).delete()
126-
return cls(**spec)
127127

128128
def __str__(self):
129129
return '{0} ({1}, {2})'.format(
@@ -183,8 +183,7 @@ def from_schedule(cls, schedule, period=SECONDS):
183183
except cls.DoesNotExist:
184184
return cls(every=every, period=period)
185185
except MultipleObjectsReturned:
186-
cls.objects.filter(every=every, period=period).delete()
187-
return cls(every=every, period=period)
186+
return cls.objects.filter(every=every, period=period).first()
188187

189188
def __str__(self):
190189
readable_period = None
@@ -236,8 +235,7 @@ def from_schedule(cls, schedule):
236235
except cls.DoesNotExist:
237236
return cls(**spec)
238237
except MultipleObjectsReturned:
239-
cls.objects.filter(**spec).delete()
240-
return cls(**spec)
238+
return cls.objects.filter(**spec).first()
241239

242240

243241
class CrontabSchedule(models.Model):
@@ -350,8 +348,7 @@ def from_schedule(cls, schedule):
350348
except cls.DoesNotExist:
351349
return cls(**spec)
352350
except MultipleObjectsReturned:
353-
cls.objects.filter(**spec).delete()
354-
return cls(**spec)
351+
return cls.objects.filter(**spec).first()
355352

356353

357354
class PeriodicTasks(models.Model):

t/unit/test_models.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77
from django.db.migrations.autodetector import MigrationAutodetector
88
from django.db.migrations.loader import MigrationLoader
99
from django.db.migrations.questioner import NonInteractiveMigrationQuestioner
10+
from django.utils import timezone
1011

1112
import timezone_field
1213

1314
from django_celery_beat import migrations as beat_migrations
1415
from django_celery_beat.models import (
1516
crontab_schedule_celery_timezone,
1617
SolarSchedule,
18+
CrontabSchedule,
19+
ClockedSchedule,
20+
IntervalSchedule,
1721
)
1822

1923

@@ -63,7 +67,22 @@ def test_models_match_migrations(self):
6367
msg='Model changes exist that do not have a migration')
6468

6569

66-
class CrontabScheduleTestCase(TestCase):
70+
class TestDuplicatesMixin:
71+
def _test_duplicate_schedules(self, cls, kwargs, schedule=None):
72+
sched1 = cls.objects.create(**kwargs)
73+
cls.objects.create(**kwargs)
74+
self.assertEqual(cls.objects.filter(**kwargs).count(), 2)
75+
# try to create a duplicate schedule from a celery schedule
76+
if schedule is None:
77+
schedule = sched1.schedule
78+
sched3 = cls.from_schedule(schedule)
79+
# the schedule should be the first of the 2 previous duplicates
80+
self.assertEqual(sched3, sched1)
81+
# and the duplicates should not be deleted !
82+
self.assertEqual(cls.objects.filter(**kwargs).count(), 2)
83+
84+
85+
class CrontabScheduleTestCase(TestCase, TestDuplicatesMixin):
6786
FIRST_VALID_TIMEZONE = timezone_field.\
6887
TimeZoneField.default_choices[0][0].zone
6988

@@ -74,6 +93,19 @@ def test_default_timezone_without_settings_config(self):
7493
def test_default_timezone_with_settings_config(self):
7594
assert crontab_schedule_celery_timezone() == self.FIRST_VALID_TIMEZONE
7695

96+
def test_duplicate_schedules(self):
97+
# See: https://github.com/celery/django-celery-beat/issues/322
98+
kwargs = {
99+
"minute": "*",
100+
"hour": "4",
101+
"day_of_week": "*",
102+
"day_of_month": "*",
103+
"month_of_year": "*",
104+
"day_of_week": "*",
105+
}
106+
schedule = schedules.crontab(hour="4")
107+
self._test_duplicate_schedules(CrontabSchedule, kwargs, schedule)
108+
77109

78110
class SolarScheduleTestCase(TestCase):
79111
EVENT_CHOICES = SolarSchedule._meta.get_field("event").choices
@@ -99,3 +131,18 @@ def test_celery_solar_schedules_included_as_event_choices(self):
99131

100132
for event_choice in event_choices_values:
101133
assert event_choice in schedules.solar._all_events
134+
135+
136+
class IntervalScheduleTestCase(TestCase, TestDuplicatesMixin):
137+
138+
def test_duplicate_schedules(self):
139+
kwargs = {'every': 1, 'period': IntervalSchedule.SECONDS}
140+
schedule = schedules.schedule(run_every=1.0)
141+
self._test_duplicate_schedules(IntervalSchedule, kwargs, schedule)
142+
143+
144+
class ClockedScheduleTestCase(TestCase, TestDuplicatesMixin):
145+
146+
def test_duplicate_schedules(self):
147+
kwargs = {'clocked_time': timezone.now()}
148+
self._test_duplicate_schedules(ClockedSchedule, kwargs)

0 commit comments

Comments
 (0)