Skip to content

Commit 606ccf1

Browse files
committed
feat:add counters
1 parent 706dfac commit 606ccf1

8 files changed

+163
-30
lines changed

Diff for: docs/changelog.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Changelog
22

3-
## v1.2.5 🌈
3+
## v1.3.0 🌈
4+
5+
### 🚀 Features
6+
7+
- Add to CronTask and RepeatableTask counters for successful/failed runs.
48

59
### 🧰 Maintenance
610

Diff for: pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ name = "django-tasks-scheduler"
77
packages = [
88
{ include = "scheduler" },
99
]
10-
version = "1.2.5"
10+
version = "1.3.0"
1111
description = "An async job scheduler for django using redis"
1212
readme = "README.md"
1313
keywords = ["redis", "django", "background-jobs", "job-queue", "task-queue", "redis-queue", "scheduled-jobs"]

Diff for: scheduler/admin/task_models.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,26 @@ class JobKwargInline(HiddenMixin, GenericStackedInline):
3535

3636

3737
_LIST_DISPLAY_EXTRA = dict(
38-
CronTask=('cron_string', 'next_run',),
38+
CronTask=('cron_string', 'next_run', 'successful_runs', 'last_successful_run', 'failed_runs', 'last_failed_run',),
3939
ScheduledTask=('scheduled_time',),
40-
RepeatableTask=('scheduled_time', 'interval_display',),
40+
RepeatableTask=(
41+
'scheduled_time', 'interval_display', 'successful_runs', 'last_successful_run', 'failed_runs',
42+
'last_failed_run',),
4143
)
4244
_FIELDSET_EXTRA = dict(
43-
CronTask=('cron_string', 'repeat', 'timeout', 'result_ttl',),
45+
CronTask=(
46+
'cron_string', 'timeout', 'result_ttl',
47+
('successful_runs', 'last_successful_run',),
48+
('failed_runs', 'last_failed_run',),
49+
),
4450
ScheduledTask=('scheduled_time', 'timeout', 'result_ttl'),
45-
RepeatableTask=('scheduled_time', ('interval', 'interval_unit',), 'repeat', 'timeout', 'result_ttl',),
51+
RepeatableTask=(
52+
'scheduled_time',
53+
('interval', 'interval_unit',),
54+
'repeat', 'timeout', 'result_ttl',
55+
('successful_runs', 'last_successful_run',),
56+
('failed_runs', 'last_failed_run',),
57+
),
4658
)
4759

4860

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Generated by Django 5.0.1 on 2024-01-10 17:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('scheduler', '0016_rename_jobarg_taskarg_rename_jobkwarg_taskkwarg_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name='crontask',
15+
name='repeat',
16+
),
17+
migrations.AddField(
18+
model_name='crontask',
19+
name='failed_runs',
20+
field=models.PositiveIntegerField(default=0, help_text='Number of times the task has failed', verbose_name='failed runs'),
21+
),
22+
migrations.AddField(
23+
model_name='crontask',
24+
name='last_failed_run',
25+
field=models.DateTimeField(blank=True, help_text='Last time the task has failed', null=True, verbose_name='last failed run'),
26+
),
27+
migrations.AddField(
28+
model_name='crontask',
29+
name='last_successful_run',
30+
field=models.DateTimeField(blank=True, help_text='Last time the task has succeeded', null=True, verbose_name='last successful run'),
31+
),
32+
migrations.AddField(
33+
model_name='crontask',
34+
name='successful_runs',
35+
field=models.PositiveIntegerField(default=0, help_text='Number of times the task has succeeded', verbose_name='successful runs'),
36+
),
37+
migrations.AddField(
38+
model_name='repeatabletask',
39+
name='failed_runs',
40+
field=models.PositiveIntegerField(default=0, help_text='Number of times the task has failed', verbose_name='failed runs'),
41+
),
42+
migrations.AddField(
43+
model_name='repeatabletask',
44+
name='last_failed_run',
45+
field=models.DateTimeField(blank=True, help_text='Last time the task has failed', null=True, verbose_name='last failed run'),
46+
),
47+
migrations.AddField(
48+
model_name='repeatabletask',
49+
name='last_successful_run',
50+
field=models.DateTimeField(blank=True, help_text='Last time the task has succeeded', null=True, verbose_name='last successful run'),
51+
),
52+
migrations.AddField(
53+
model_name='repeatabletask',
54+
name='successful_runs',
55+
field=models.PositiveIntegerField(default=0, help_text='Number of times the task has succeeded', verbose_name='successful runs'),
56+
),
57+
]

Diff for: scheduler/models/scheduled_task.py

+37-10
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ def failure_callback(job, connection, result, *args, **kwargs):
3939
mail_admins(f'Task {task.id}/{task.name} has failed',
4040
'See django-admin for logs', )
4141
task.job_id = None
42+
if isinstance(task, (CronTask, RepeatableTask)):
43+
task.failed_runs += 1
44+
task.last_failed_run = timezone.now()
4245
task.save(schedule_job=True)
4346

4447

@@ -51,6 +54,9 @@ def success_callback(job, connection, result, *args, **kwargs):
5154
if task is None:
5255
return
5356
task.job_id = None
57+
if isinstance(task, (CronTask, RepeatableTask)):
58+
task.successful_runs += 1
59+
task.last_successful_run = timezone.now()
5460
task.save(schedule_job=True)
5561

5662

@@ -76,9 +82,6 @@ class BaseTask(models.Model):
7682
job_id = models.CharField(
7783
_('job id'), max_length=128, editable=False, blank=True, null=True,
7884
help_text=_('Current job_id on queue'))
79-
repeat = models.PositiveIntegerField(
80-
_('repeat'), blank=True, null=True,
81-
help_text=_('Number of times to run the job. Leaving this blank means it will run forever.'), )
8285
at_front = models.BooleanField(
8386
_('At front'), default=False, blank=True, null=True,
8487
help_text=_('When queuing the job, add it in the front of the queue'), )
@@ -104,14 +107,14 @@ def is_scheduled(self) -> bool:
104107
"""Check whether a next job for this task is queued/scheduled to be executed"""
105108
if self.job_id is None: # no job_id => is not scheduled
106109
return False
107-
# check whether job_id is in scheduled/enqueued/active jobs
110+
# check whether job_id is in scheduled/queued/active jobs
108111
scheduled_jobs = self.rqueue.scheduled_job_registry.get_job_ids()
109112
enqueued_jobs = self.rqueue.get_job_ids()
110113
active_jobs = self.rqueue.started_job_registry.get_job_ids()
111114
res = ((self.job_id in scheduled_jobs)
112115
or (self.job_id in enqueued_jobs)
113116
or (self.job_id in active_jobs))
114-
# If the job_id is not scheduled/enqueued/started,
117+
# If the job_id is not scheduled/queued/started,
115118
# update the job_id to None. (The job_id belongs to a previous run which is completed)
116119
if not res:
117120
self.job_id = None
@@ -152,7 +155,6 @@ def _enqueue_args(self) -> Dict:
152155
"""
153156
res = dict(
154157
meta=dict(
155-
repeat=self.repeat,
156158
task_type=self.TASK_TYPE,
157159
scheduled_task_id=self.id,
158160
),
@@ -249,14 +251,18 @@ def to_dict(self) -> Dict:
249251
for arg in self.callable_kwargs.all()],
250252
enabled=self.enabled,
251253
queue=self.queue,
252-
repeat=self.repeat,
254+
repeat=getattr(self, 'repeat', None),
253255
at_front=self.at_front,
254256
timeout=self.timeout,
255257
result_ttl=self.result_ttl,
256258
cron_string=getattr(self, 'cron_string', None),
257259
scheduled_time=self._schedule_time().isoformat(),
258260
interval=getattr(self, 'interval', None),
259261
interval_unit=getattr(self, 'interval_unit', None),
262+
successful_runs=getattr(self, 'successful_runs', None),
263+
failed_runs=getattr(self, 'failed_runs', None),
264+
last_successful_run=getattr(self, 'last_successful_run', None),
265+
last_failed_run=getattr(self, 'last_failed_run', None),
260266
)
261267
return res
262268

@@ -315,8 +321,25 @@ class Meta:
315321
abstract = True
316322

317323

324+
class RepeatableMixin(models.Model):
325+
failed_runs = models.PositiveIntegerField(
326+
_('failed runs'), default=0,
327+
help_text=_('Number of times the task has failed'), )
328+
successful_runs = models.PositiveIntegerField(
329+
_('successful runs'), default=0,
330+
help_text=_('Number of times the task has succeeded'), )
331+
last_successful_run = models.DateTimeField(
332+
_('last successful run'), blank=True, null=True,
333+
help_text=_('Last time the task has succeeded'), )
334+
last_failed_run = models.DateTimeField(
335+
_('last failed run'), blank=True, null=True,
336+
help_text=_('Last time the task has failed'), )
337+
338+
class Meta:
339+
abstract = True
340+
341+
318342
class ScheduledTask(ScheduledTimeMixin, BaseTask):
319-
repeat = None
320343
TASK_TYPE = 'ScheduledTask'
321344

322345
def ready_for_schedule(self) -> bool:
@@ -330,7 +353,7 @@ class Meta:
330353
ordering = ('name',)
331354

332355

333-
class RepeatableTask(ScheduledTimeMixin, BaseTask):
356+
class RepeatableTask(RepeatableMixin, ScheduledTimeMixin, BaseTask):
334357
class TimeUnits(models.TextChoices):
335358
SECONDS = 'seconds', _('seconds')
336359
MINUTES = 'minutes', _('minutes')
@@ -342,6 +365,9 @@ class TimeUnits(models.TextChoices):
342365
interval_unit = models.CharField(
343366
_('interval unit'), max_length=12, choices=TimeUnits.choices, default=TimeUnits.HOURS
344367
)
368+
repeat = models.PositiveIntegerField(
369+
_('repeat'), blank=True, null=True,
370+
help_text=_('Number of times to run the job. Leaving this blank means it will run forever.'), )
345371
TASK_TYPE = 'RepeatableTask'
346372

347373
def clean(self):
@@ -384,6 +410,7 @@ def interval_seconds(self):
384410
def _enqueue_args(self):
385411
res = super(RepeatableTask, self)._enqueue_args()
386412
res['meta']['interval'] = self.interval_seconds()
413+
res['meta']['repeat'] = self.repeat
387414
return res
388415

389416
def _schedule_time(self):
@@ -409,7 +436,7 @@ class Meta:
409436
ordering = ('name',)
410437

411438

412-
class CronTask(BaseTask):
439+
class CronTask(RepeatableMixin, BaseTask):
413440
TASK_TYPE = 'CronTask'
414441

415442
cron_string = models.CharField(

Diff for: scheduler/tests/test_cron_task.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -27,28 +27,34 @@ def test_clean_cron_string_invalid(self):
2727
with self.assertRaises(ValidationError):
2828
task.clean_cron_string()
2929

30-
def test_repeat(self):
31-
task = task_factory(CronTask, repeat=10)
32-
entry = _get_job_from_scheduled_registry(task)
33-
self.assertEqual(entry.meta['repeat'], 10)
34-
3530
def test_check_rescheduled_after_execution(self):
3631
task = task_factory(CronTask, )
3732
queue = task.rqueue
3833
first_run_id = task.job_id
3934
entry = queue.fetch_job(first_run_id)
4035
queue.run_sync(entry)
4136
task.refresh_from_db()
37+
self.assertEqual(task.failed_runs, 0)
38+
self.assertIsNone(task.last_failed_run)
39+
self.assertEqual(task.successful_runs, 1)
40+
self.assertIsNotNone(task.last_successful_run)
4241
self.assertTrue(task.is_scheduled())
4342
self.assertNotEqual(task.job_id, first_run_id)
4443

4544
def test_check_rescheduled_after_failed_execution(self):
46-
task = task_factory(CronTask, callable_name="scheduler.tests.jobs.scheduler.tests.jobs.test_job", )
45+
task = task_factory(
46+
CronTask,
47+
callable_name="scheduler.tests.jobs.scheduler.tests.jobs.test_job",
48+
)
4749
queue = task.rqueue
4850
first_run_id = task.job_id
4951
entry = queue.fetch_job(first_run_id)
5052
queue.run_sync(entry)
5153
task.refresh_from_db()
54+
self.assertEqual(task.failed_runs, 1)
55+
self.assertIsNotNone(task.last_failed_run)
56+
self.assertEqual(task.successful_runs, 0)
57+
self.assertIsNone(task.last_successful_run)
5258
self.assertTrue(task.is_scheduled())
5359
self.assertNotEqual(task.job_id, first_run_id)
5460

Diff for: scheduler/tests/test_repeatable_task.py

+30-3
Original file line numberDiff line numberDiff line change
@@ -153,22 +153,49 @@ def test_repeat_none_interval_2_min(self):
153153
self.assertTrue(job.is_scheduled())
154154

155155
def test_check_rescheduled_after_execution(self):
156-
task = task_factory(self.TaskModelClass, scheduled_time=timezone.now() + timedelta(seconds=1))
156+
task = task_factory(self.TaskModelClass, scheduled_time=timezone.now() + timedelta(seconds=1), repeat=10)
157157
queue = task.rqueue
158158
first_run_id = task.job_id
159159
entry = queue.fetch_job(first_run_id)
160160
queue.run_sync(entry)
161161
task.refresh_from_db()
162+
self.assertEqual(task.failed_runs, 0)
163+
self.assertIsNone(task.last_failed_run)
164+
self.assertEqual(task.successful_runs, 1)
165+
self.assertIsNotNone(task.last_successful_run)
162166
self.assertTrue(task.is_scheduled())
163167
self.assertNotEqual(task.job_id, first_run_id)
164168

165169
def test_check_rescheduled_after_execution_failed_job(self):
166-
task = task_factory(self.TaskModelClass, callable_name='scheduler.tests.jobs.failing_job',
167-
scheduled_time=timezone.now() + timedelta(seconds=1))
170+
task = task_factory(
171+
self.TaskModelClass, callable_name='scheduler.tests.jobs.failing_job',
172+
scheduled_time=timezone.now() + timedelta(seconds=1),
173+
repeat=10, )
168174
queue = task.rqueue
169175
first_run_id = task.job_id
170176
entry = queue.fetch_job(first_run_id)
171177
queue.run_sync(entry)
172178
task.refresh_from_db()
179+
self.assertEqual(task.failed_runs, 1)
180+
self.assertIsNotNone(task.last_failed_run)
181+
self.assertEqual(task.successful_runs, 0)
182+
self.assertIsNone(task.last_successful_run)
173183
self.assertTrue(task.is_scheduled())
174184
self.assertNotEqual(task.job_id, first_run_id)
185+
186+
def test_check_not_rescheduled_after_last_repeat(self):
187+
task = task_factory(
188+
self.TaskModelClass,
189+
scheduled_time=timezone.now() + timedelta(seconds=1),
190+
repeat=1,
191+
)
192+
queue = task.rqueue
193+
first_run_id = task.job_id
194+
entry = queue.fetch_job(first_run_id)
195+
queue.run_sync(entry)
196+
task.refresh_from_db()
197+
self.assertEqual(task.failed_runs, 0)
198+
self.assertIsNone(task.last_failed_run)
199+
self.assertEqual(task.successful_runs, 1)
200+
self.assertIsNotNone(task.last_successful_run)
201+
self.assertNotEqual(task.job_id, first_run_id)

Diff for: scheduler/tests/testtools.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def task_factory(cls, callable_name: str = 'scheduler.tests.jobs.test_job', inst
4646
repeat=None,
4747
scheduled_time=timezone.now() + timedelta(days=1), ))
4848
elif cls == CronTask:
49-
values.update(dict(cron_string="0 0 * * *", repeat=None, ))
49+
values.update(dict(cron_string="0 0 * * *", ))
5050
values.update(kwargs)
5151
if instance_only:
5252
instance = cls(**values)
@@ -73,10 +73,10 @@ def taskarg_factory(cls, **kwargs):
7373
return instance
7474

7575

76-
def _get_job_from_scheduled_registry(django_job: BaseTask):
77-
jobs_to_schedule = django_job.rqueue.scheduled_job_registry.get_job_ids()
78-
entry = next(i for i in jobs_to_schedule if i == django_job.job_id)
79-
return django_job.rqueue.fetch_job(entry)
76+
def _get_job_from_scheduled_registry(django_task: BaseTask):
77+
jobs_to_schedule = django_task.rqueue.scheduled_job_registry.get_job_ids()
78+
entry = next(i for i in jobs_to_schedule if i == django_task.job_id)
79+
return django_task.rqueue.fetch_job(entry)
8080

8181

8282
def _get_executions(django_job: BaseTask):

0 commit comments

Comments
 (0)