Skip to content

Commit a6c2459

Browse files
committed
fix:infinite loop on callback calling is_scheduled()
1 parent 6e2a78d commit a6c2459

File tree

6 files changed

+86
-49
lines changed

6 files changed

+86
-49
lines changed

poetry.lock

+15-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ coverage = "^7"
5757
fakeredis = { version = "^2.19", extras = ['lua'] }
5858
Flake8-pyproject = "^1.2"
5959
pyyaml = "^6.0"
60+
freezegun = "^1.2.2"
6061

6162
[tool.poetry.extras]
6263
yaml = ["pyyaml"]

scheduler/management/commands/import.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ def create_job_from_dict(job_dict: Dict[str, Any], update):
3333
del kwargs['callable_args']
3434
del kwargs['callable_kwargs']
3535
if kwargs.get('scheduled_time', None):
36-
kwargs['scheduled_time'] = timezone.datetime.fromisoformat(kwargs['scheduled_time'])
37-
if not settings.USE_TZ:
38-
kwargs['scheduled_time'] = timezone.make_naive(kwargs['scheduled_time'])
36+
target = timezone.datetime.fromisoformat(kwargs['scheduled_time'])
37+
if not settings.USE_TZ and not timezone.is_naive(target):
38+
target = timezone.make_naive(target)
39+
kwargs['scheduled_time'] = target
3940
model_fields = set(map(lambda field: field.attname, model._meta.get_fields()))
4041
keys_to_ignore = list(filter(lambda k: k not in model_fields, kwargs.keys()))
4142
for k in keys_to_ignore:

scheduler/models/scheduled_task.py

+51-36
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55

66
import croniter
77
from django.apps import apps
8+
from django.conf import settings as django_settings
89
from django.contrib import admin
910
from django.contrib.contenttypes.fields import GenericRelation
1011
from django.core.exceptions import ValidationError
12+
from django.core.mail import mail_admins
1113
from django.db import models
1214
from django.templatetags.tz import utc
1315
from django.urls import reverse
@@ -24,14 +26,27 @@
2426
SCHEDULER_INTERVAL = settings.SCHEDULER_CONFIG['SCHEDULER_INTERVAL']
2527

2628

27-
def callback_save_job(job, connection, result, *args, **kwargs):
29+
def failure_callback(job, connection, result, *args, **kwargs):
30+
model_name = job.meta.get('task_type', None)
31+
scheduled_task_id = job.meta.get('scheduled_task_id', None)
32+
if model_name is None or scheduled_task_id:
33+
return
34+
model = apps.get_model(app_label='scheduler', model_name=model_name)
35+
task = model.objects.filter(id=scheduled_task_id).first()
36+
mail_admins(f'Task {task.id}/{task.name} has failed',
37+
'See django-admin for logs', )
38+
pass
39+
40+
41+
def success_callback(job, connection, result, *args, **kwargs):
2842
model_name = job.meta.get('task_type', None)
2943
if model_name is None:
3044
return
3145
model = apps.get_model(app_label='scheduler', model_name=model_name)
3246
task = model.objects.filter(job_id=job.id).first()
33-
if task is not None:
34-
task.force_schedule()
47+
if task is None:
48+
return
49+
task.schedule()
3550

3651

3752
class BaseTask(models.Model):
@@ -107,12 +122,12 @@ def function_string(self) -> str:
107122
return self.callable + f"({', '.join(args_list + kwargs_list)})"
108123

109124
def parse_args(self):
110-
"""Parse args for running job"""
125+
"""Parse args for running the job"""
111126
args = self.callable_args.all()
112127
return [arg.value() for arg in args]
113128

114129
def parse_kwargs(self):
115-
"""Parse kwargs for running job"""
130+
"""Parse kwargs for running the job"""
116131
kwargs = self.callable_kwargs.all()
117132
return dict([kwarg.value() for kwarg in kwargs])
118133

@@ -122,12 +137,12 @@ def _next_job_id(self):
122137
return f'{self.queue}:{name}:{addition}'
123138

124139
def _enqueue_args(self) -> Dict:
125-
"""args for DjangoQueue.enqueue.
140+
"""Args for DjangoQueue.enqueue.
126141
Set all arguments for DjangoQueue.enqueue/enqueue_at.
127142
Particularly:
128143
- set job timeout and ttl
129-
- ensure a callback to reschedule job next iteration.
130-
- set job-id to proper format
144+
- ensure a callback to reschedule the job next iteration.
145+
- Set job-id to proper format
131146
- set job meta
132147
"""
133148
res = dict(
@@ -136,8 +151,8 @@ def _enqueue_args(self) -> Dict:
136151
task_type=self.TASK_TYPE,
137152
scheduled_task_id=self.id,
138153
),
139-
on_success=callback_save_job,
140-
on_failure=callback_save_job,
154+
on_success=success_callback,
155+
on_failure=failure_callback,
141156
job_id=self._next_job_id(),
142157
)
143158
if self.at_front:
@@ -150,37 +165,31 @@ def _enqueue_args(self) -> Dict:
150165

151166
@property
152167
def rqueue(self) -> DjangoQueue:
153-
"""Returns django-queue for job
154-
"""
168+
"""Returns redis-queue for job"""
155169
return get_queue(self.queue)
156170

157171
def ready_for_schedule(self) -> bool:
158-
"""Is task ready to be scheduled?
172+
"""Is the task ready to be scheduled?
159173
160-
If task is already scheduled or disabled, then it is not
174+
If the task is already scheduled or disabled, then it is not
161175
ready to be scheduled.
162176
163-
:returns: True if task is ready to be scheduled.
177+
:returns: True if the task is ready to be scheduled.
164178
"""
165179
if self.is_scheduled():
166-
logger.debug(f'Job {self.name} already scheduled')
180+
logger.debug(f'Task {self.name} already scheduled')
167181
return False
168182
if not self.enabled:
169-
logger.debug(f'Job {str(self)} disabled, enable job before scheduling')
183+
logger.debug(f'Task {str(self)} disabled, enable task before scheduling')
170184
return False
171185
return True
172186

173187
def schedule(self) -> bool:
174-
"""Schedule job to run.
175-
:returns: True if job was scheduled, False otherwise.
188+
"""Schedule the next execution for the task to run.
189+
:returns: True if a job was scheduled, False otherwise.
176190
"""
177191
if not self.ready_for_schedule():
178192
return False
179-
self.force_schedule()
180-
return True
181-
182-
def force_schedule(self):
183-
"""Schedule task regardless of its current status"""
184193
schedule_time = self._schedule_time()
185194
kwargs = self._enqueue_args()
186195
job = self.rqueue.enqueue_at(
@@ -190,6 +199,7 @@ def force_schedule(self):
190199
**kwargs, )
191200
self.job_id = job.id
192201
super(BaseTask, self).save()
202+
return True
193203

194204
def enqueue_to_run(self) -> bool:
195205
"""Enqueue job to run now."""
@@ -218,7 +228,7 @@ def unschedule(self) -> bool:
218228
return True
219229

220230
def _schedule_time(self):
221-
return utc(self.scheduled_time)
231+
return utc(self.scheduled_time) if django_settings.USE_TZ else self.scheduled_time
222232

223233
def to_dict(self) -> Dict:
224234
"""Export model to dictionary, so it can be saved as external file backup"""
@@ -258,10 +268,10 @@ def save(self, **kwargs):
258268
update_fields = kwargs.get('update_fields', None)
259269
if update_fields:
260270
kwargs['update_fields'] = set(update_fields).union({'modified'})
261-
super(BaseTask, self).save(**kwargs)
262271
if schedule_job:
263-
self.schedule()
264-
super(BaseTask, self).save()
272+
self.schedule() # schedule() already calls save()
273+
else:
274+
super(BaseTask, self).save(**kwargs)
265275

266276
def delete(self, **kwargs):
267277
self.unschedule()
@@ -371,16 +381,20 @@ def _enqueue_args(self):
371381
res['meta']['interval'] = self.interval_seconds()
372382
return res
373383

384+
def _schedule_time(self):
385+
_now = timezone.now()
386+
if self.scheduled_time >= _now:
387+
return super()._schedule_time()
388+
gap = math.ceil((_now.timestamp() - self.scheduled_time.timestamp()) / self.interval_seconds())
389+
if self.repeat is None or self.repeat >= gap:
390+
self.scheduled_time += timedelta(seconds=self.interval_seconds() * gap)
391+
self.repeat = (self.repeat - gap) if self.repeat is not None else None
392+
return super()._schedule_time()
393+
374394
def ready_for_schedule(self):
375395
if super(RepeatableTask, self).ready_for_schedule() is False:
376396
return False
377-
if self.scheduled_time < timezone.now():
378-
gap = math.ceil((timezone.now().timestamp() - self.scheduled_time.timestamp()) / self.interval_seconds())
379-
if self.repeat is None or self.repeat >= gap:
380-
self.scheduled_time += timedelta(seconds=self.interval_seconds() * gap)
381-
self.repeat = (self.repeat - gap) if self.repeat is not None else None
382-
383-
if self.scheduled_time < timezone.now():
397+
if self._schedule_time() < timezone.now():
384398
return False
385399
return True
386400

@@ -411,7 +425,8 @@ def clean_cron_string(self):
411425
raise ValidationError({'cron_string': ValidationError(_(str(e)), code='invalid')})
412426

413427
def _schedule_time(self):
414-
return tools.get_next_cron_time(self.cron_string)
428+
self.scheduled_time = tools.get_next_cron_time(self.cron_string)
429+
return super()._schedule_time()
415430

416431
class Meta:
417432
verbose_name = _('Cron Task')

scheduler/tests/test_models.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.test import override_settings
77
from django.urls import reverse
88
from django.utils import timezone
9+
from freezegun import freeze_time
910

1011
from scheduler import settings
1112
from scheduler.models import BaseTask, CronTask, TaskArg, TaskKwarg, RepeatableTask, ScheduledTask
@@ -440,14 +441,20 @@ class TestSchedulableJob(TestBaseJob):
440441
# Currently ScheduledJob and RepeatableJob
441442
TaskModelClass = ScheduledTask
442443

443-
def test_schedule_time_utc(self):
444+
@freeze_time("2016-12-25")
445+
@override_settings(USE_TZ=False)
446+
def test_schedule_time_no_tz(self):
447+
task = task_factory(self.TaskModelClass)
448+
task.scheduled_time = datetime(2016, 12, 25, 8, 0, 0, tzinfo=None)
449+
self.assertEqual("2016-12-25T08:00:00", task._schedule_time().isoformat())
450+
451+
@freeze_time("2016-12-25")
452+
@override_settings(USE_TZ=True)
453+
def test_schedule_time_with_tz(self):
444454
task = task_factory(self.TaskModelClass)
445455
est = zoneinfo.ZoneInfo('US/Eastern')
446-
scheduled_time = datetime(2016, 12, 25, 8, 0, 0, tzinfo=est)
447-
task.scheduled_time = scheduled_time
448-
utc = zoneinfo.ZoneInfo('UTC')
449-
expected = scheduled_time.astimezone(utc).isoformat()
450-
self.assertEqual(expected, task._schedule_time().isoformat())
456+
task.scheduled_time = datetime(2016, 12, 25, 8, 0, 0, tzinfo=est)
457+
self.assertEqual("2016-12-25T13:00:00+00:00", task._schedule_time().isoformat())
451458

452459
def test_result_ttl_passthrough(self):
453460
job = task_factory(self.TaskModelClass, result_ttl=500)

scheduler/tools.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
import croniter
55
from django.apps import apps
6-
from django.templatetags.tz import utc
76
from django.utils import timezone
87

98
from scheduler.queues import get_queues, logger, get_queue
@@ -25,8 +24,8 @@ def get_next_cron_time(cron_string) -> timezone.datetime:
2524
with a cron string"""
2625
now = timezone.now()
2726
itr = croniter.croniter(cron_string, now)
28-
return utc(itr.get_next(timezone.datetime))
29-
27+
next_itr = itr.get_next(timezone.datetime)
28+
return next_itr
3029

3130
def get_scheduled_task(task_model: str, task_id: int):
3231
if task_model not in MODEL_NAMES:

0 commit comments

Comments
 (0)