From c0bbe431dc17c059fc53cb9ca986b802426f4ee3 Mon Sep 17 00:00:00 2001 From: Jan Belkner <41588342+nerjan@users.noreply.github.com> Date: Mon, 9 May 2022 08:23:43 +0200 Subject: [PATCH] Locking backend with database (#185) * Locking backend with database * documentation of database lock --- demo/demo/settings.py | 2 ++ django_cron/admin.py | 3 ++- django_cron/backends/lock/database.py | 29 ++++++++++++++++++++++ django_cron/migrations/0003_cronjoblock.py | 22 ++++++++++++++++ django_cron/models.py | 5 ++++ django_cron/tests.py | 13 +++++++++- docs/locking_backend.rst | 7 +++++- 7 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 django_cron/backends/lock/database.py create mode 100644 django_cron/migrations/0003_cronjoblock.py diff --git a/demo/demo/settings.py b/demo/demo/settings.py index eda6c6f..baba7cc 100644 --- a/demo/demo/settings.py +++ b/demo/demo/settings.py @@ -127,3 +127,5 @@ CRON_CLASSES = [ "demo.cron.EmailUsercountCronJob", ] +# If you want to test django locking with database +# DJANGO_CRON_LOCK_BACKEND = "django_cron.backends.lock.database.DatabaseLock" diff --git a/django_cron/admin.py b/django_cron/admin.py index 3b6767f..b8c903b 100644 --- a/django_cron/admin.py +++ b/django_cron/admin.py @@ -4,7 +4,7 @@ from django.db.models import F from django.utils.translation import gettext_lazy as _ -from django_cron.models import CronJobLog +from django_cron.models import CronJobLog, CronJobLock from django_cron.helpers import humanize_duration @@ -54,3 +54,4 @@ def humanize_duration(self, obj): admin.site.register(CronJobLog, CronJobLogAdmin) +admin.site.register(CronJobLock) diff --git a/django_cron/backends/lock/database.py b/django_cron/backends/lock/database.py new file mode 100644 index 0000000..7c63a2a --- /dev/null +++ b/django_cron/backends/lock/database.py @@ -0,0 +1,29 @@ +from django_cron.backends.lock.base import DjangoCronJobLock +from django_cron.models import CronJobLock +from django.db import transaction + + +class DatabaseLock(DjangoCronJobLock): + """ + Locking cron jobs with database. Its good when you have not parallel run and want to make sure 2 jobs won't be + fired at the same time - which may happened when job execution is longer that job interval. + """ + + @transaction.atomic + def lock(self): + lock, created = CronJobLock.objects.get_or_create(job_name=self.job_name) + if lock.locked: + return False + else: + lock.locked = True + lock.save() + return True + + @transaction.atomic + def release(self): + lock = CronJobLock.objects.filter( + job_name=self.job_name, + locked=True + ).first() + lock.locked = False + lock.save() diff --git a/django_cron/migrations/0003_cronjoblock.py b/django_cron/migrations/0003_cronjoblock.py new file mode 100644 index 0000000..0734417 --- /dev/null +++ b/django_cron/migrations/0003_cronjoblock.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_cron', '0002_remove_max_length_from_CronJobLog_message'), + ] + + operations = [ + migrations.CreateModel( + name='CronJobLock', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('job_name', models.CharField(max_length=200, unique=True)), + ('locked', models.BooleanField(default=False)), + ], + ), + ] diff --git a/django_cron/models.py b/django_cron/models.py index ea3f0e7..4ecc02f 100644 --- a/django_cron/models.py +++ b/django_cron/models.py @@ -29,3 +29,8 @@ class Meta: ('code', 'start_time') # useful when finding latest run (order by start_time) of cron ] app_label = 'django_cron' + + +class CronJobLock(models.Model): + job_name = models.CharField(max_length=200, unique=True) + locked = models.BooleanField(default=False) diff --git a/django_cron/tests.py b/django_cron/tests.py index 67c61e4..4bc1f39 100644 --- a/django_cron/tests.py +++ b/django_cron/tests.py @@ -14,7 +14,7 @@ from django.contrib.auth.models import User from django_cron.helpers import humanize_duration -from django_cron.models import CronJobLog +from django_cron.models import CronJobLog, CronJobLock import test_crons @@ -105,6 +105,17 @@ def test_file_locking_backend(self): self._call(self.success_cron, force=True) self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) + @override_settings(DJANGO_CRON_LOCK_BACKEND='django_cron.backends.lock.database.DatabaseLock') + def test_database_locking_backend(self): + # TODO: to test it properly we would need to run multiple jobs at the same time + logs_count = CronJobLog.objects.all().count() + cron_job_locks = CronJobLock.objects.all().count() + for _ in range(3): + call(self.success_cron, force=True) + self.assertEqual(CronJobLog.objects.all().count(), logs_count + 3) + self.assertEqual(CronJobLock.objects.all().count(), cron_job_locks + 1) + self.assertEqual(CronJobLock.objects.first().locked, False) + @patch.object(test_crons.TestSuccessCronJob, 'do') def test_dry_run_does_not_perform_task(self, mock_do): response = self._call(self.success_cron, dry_run=True) diff --git a/docs/locking_backend.rst b/docs/locking_backend.rst index bcee33e..670b2a8 100644 --- a/docs/locking_backend.rst +++ b/docs/locking_backend.rst @@ -1,10 +1,11 @@ Locking Backend =============== -You can use one of two built-in locking backends by setting ``DJANGO_CRON_LOCK_BACKEND`` with one of: +You can use one of three built-in locking backends by setting ``DJANGO_CRON_LOCK_BACKEND`` with one of: - ``django_cron.backends.lock.cache.CacheLock`` (default) - ``django_cron.backends.lock.file.FileLock`` + - ``django_cron.backends.lock.database.DatabaseLock`` Cache Lock @@ -16,6 +17,10 @@ File Lock --------- This backend creates a file to mark current job as "already running", and delete it when lock is released. +Database Lock +--------- +This backend creates new model for jobs, saving their state as locked when they starts, and setting it to unlocked when +job is finished. It may help preventing multiple instances of the same job running. Custom Lock -----------