diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a470e9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Compiled python files +*.pyc + +# Vim files +*.swp +*.swo + +# Coverage files +.coverage + +# Setuptools distribution folder. +/dist/ + +# Python egg metadata, regenerated from source files by setuptools. +/*.egg-info +/*.egg diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4179019 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: python +python: + - '2.7' +env: + - DJANGO=1.6.1 DB=postgres +install: + - pip install -q Django==$DJANGO + - pip install -r requirements.txt +before_script: + - find . | grep .py$ | grep -v /migrations | xargs pep8 --max-line-length=120 + - find . | grep .py$ | grep -v /migrations | grep -v __init__.py | xargs pyflakes + - psql -c 'CREATE DATABASE db_mutex;' -U postgres +script: + - coverage run --source='db_mutex' --branch --omit 'db_mutex/migrations/*' manage.py test + - coverage report --fail-under=100 diff --git a/LICENSE b/LICENSE index a466289..9875435 100644 --- a/LICENSE +++ b/LICENSE @@ -2,20 +2,19 @@ The MIT License (MIT) Copyright (c) 2014 Ambition -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6d5d1d5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include db_mutex/VERSION +include README.md +include LICENSE diff --git a/README.md b/README.md index 48a9c42..2c6b519 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,79 @@ +[![Build Status](https://travis-ci.org/ambitioninc/django-db-mutex.png)](https://travis-ci.org/ambitioninc/django-db-mutex) django-db-mutex =============== -Acquire a mutex via the DB in Django +Provides the ability to acquire a mutex lock from the database in Django. + +## A Brief Overview +For critical pieces of code that cannot overlap with one another, it is often necessary to acquire a mutex lock of some sort. Many solutions use a memcache lock strategy, however, this strategy can be brittle in the case of memcache going down or when an unconsistent hashing function is used in a distributed memcache setup. + +If your application does not need a high performance mutex lock, Django DB Mutex does the trick. The common use case for Django DB Mutex is to provide the abilty to lock long-running periodic tasks that should not overlap with one another. Celery is the common backend for Django when scheduling periodic tasks. + +## How to Use Django DB Mutex +The Django DB Mutex app provides a context manager and function decorator for locking a critical section of code. The context manager is used in the following way: + + from db_mutex import db_mutex, DBMutexError, DBMutexTimeoutError + + # Lock a critical section of code + try: + with db_mutex('lock_id'): + # Run critical code here + pass + except DBMutexError: + print 'Could not obtain lock' + except DBMutexTimeoutError: + print 'Task completed but the lock timed out' + +You'll notice that two errors were caught from this context manager. The first one, DBMutexError, is thrown if the lock cannot be acquired. The second one, DBMutexTimeoutError, is thrown if the critical code completes but the lock timed out. More about lock timeout in the next section. + +The db_mutex decorator can also be used in a similar manner for locking a function. + + from db_mutex import db_mutex, DBMutexError, DBMutexTimeoutError + + @db_mutex('lock_id') + def critical_function(): + pass + + try: + critical_function() + except DBMutexError: + print 'Could not obtain lock' + except DBMutexTimeoutError: + print 'Task completed but the lock timed out' + +## Lock Timeout +Django DB Mutex comes with lock timeout baked in. This ensures that a lock cannot be held forever. This is especially important when working with segments of code that may run out of memory or produce errors that do not raise exceptions. + +In the default setup of this app, a lock is only valid for 30 minutes. As shown earlier in the example code, if the lock times out during the execution of a critical piece of code, a DBMutexTimeoutError will be thrown. This error basically says that a critical section of your code could have overlapped (but it doesn't necessarily say if a section of code overlapped or didn't). + +In order to change the duration of a lock, set the DB_MUTEX_TTL_SECONDS variable in your settings.py file to a number of seconds. If you want your locks to never expire (beware!), set the setting to None. + +## Usage with Celery +Django DB Mutex can be used with celery's tasks in the following manner. + + from celery import Task + from abc import ABCMeta, abstractmethod + + class NonOverlappingTask(Task): + __metaclass__ = ABCMeta + + @abstractmethod + def run_worker(self, *args, **kwargs): + """ + Run worker code here. + """ + pass + + def run(self, *args, **kwargs): + try: + with db_mutex(self.__class__.__name__): + self.run_worker(*args, **kwargs): + except DBMutexError: + # Ignore this task since the same one is already running + pass + except DBMutexTimeoutError: + # A task ran for a long time and another one may have overlapped with it. Report the error + pass + +## License +MIT License (see the LICENSE file included in the repository) diff --git a/db_mutex/VERSION b/db_mutex/VERSION new file mode 100644 index 0000000..49d5957 --- /dev/null +++ b/db_mutex/VERSION @@ -0,0 +1 @@ +0.1 diff --git a/db_mutex/__init__.py b/db_mutex/__init__.py new file mode 100644 index 0000000..f3d191f --- /dev/null +++ b/db_mutex/__init__.py @@ -0,0 +1 @@ +from .db_mutex import DBMutexError, DBMutexTimeoutError, db_mutex diff --git a/db_mutex/db_mutex.py b/db_mutex/db_mutex.py new file mode 100644 index 0000000..d2756b4 --- /dev/null +++ b/db_mutex/db_mutex.py @@ -0,0 +1,126 @@ +from datetime import datetime, timedelta +import functools + +from django.conf import settings +from django.db import transaction, IntegrityError + +from .models import DBMutex + + +class DBMutexError(Exception): + """ + Thrown when a lock cannot be acquired. + """ + pass + + +class DBMutexTimeoutError(Exception): + """ + Thrown when a lock times out before it is released. + """ + pass + + +class db_mutex(object): + """ + An object that acts as a context manager and a function decorator for acquiring a + DB mutex lock. + + Args: + lock_id: The ID of the lock one is trying to acquire + + Raises: + DBMutexError when the lock cannot be obtained + DBMutexTimeoutError when the lock was deleted during execution + + Examples: + This context manager/function decorator can be used in the following way + + from db_mutex import db_mutex + + # Lock a critical section of code + try: + with db_mutex('lock_id'): + # Run critical code here + pass + except DBMutexError: + print 'Could not obtain lock' + except DBMutexTimeoutError: + print 'Task completed but the lock timed out' + + # Lock a function + @db_mutex('lock_id'): + def critical_function(): + # Critical code goes here + pass + + try: + critical_function() + except DBMutexError: + print 'Could not obtain lock' + except DBMutexTimeoutError: + print 'Task completed but the lock timed out' + """ + mutex_ttl_seconds_settings_key = 'DB_MUTEX_TTL_SECONDS' + + def __init__(self, lock_id): + self.lock_id = lock_id + self.lock = None + + def get_mutex_ttl_seconds(self): + """ + Returns a TTL for mutex locks. It defaults to 30 minutes. If the user specifies None + as the TTL, locks never expire. + """ + return getattr(settings, self.mutex_ttl_seconds_settings_key, timedelta(minutes=30).total_seconds()) + + def delete_expired_locks(self): + """ + Deletes all expired mutex locks if a ttl is provided. + """ + ttl_seconds = self.get_mutex_ttl_seconds() + if ttl_seconds is not None: + DBMutex.objects.filter(creation_time__lte=datetime.utcnow() - timedelta(seconds=ttl_seconds)).delete() + + def __call__(self, func): + return self.decorate_callable(func) + + def __enter__(self): + self.start() + + def __exit__(self, *args): + self.stop() + + def start(self): + """ + Acquires the db mutex lock. Takes the necessary steps to delete any stale locks. + Throws a DBMutexError if it can't acquire the lock. + """ + # Delete any expired locks first + self.delete_expired_locks() + try: + with transaction.atomic(): + self.lock = DBMutex.objects.create(lock_id=self.lock_id) + except IntegrityError: + raise DBMutexError('Could not acquire lock: {0}'.format(self.lock_id)) + + def stop(self): + """ + Releases the db mutex lock. Throws an error if the lock was released before the function finished. + """ + if not DBMutex.objects.filter(id=self.lock.id).exists(): + raise DBMutexTimeoutError('Lock {0} expired before function completed'.format(self.lock_id)) + else: + self.lock.delete() + + def decorate_callable(self, func): + """ + Decorates a function with the db_mutex decorator by using this class as a context manager around + it. + """ + def wrapper(*args, **kwargs): + with self: + result = func(*args, **kwargs) + return result + functools.update_wrapper(wrapper, func) + return wrapper diff --git a/db_mutex/migrations/0001_initial.py b/db_mutex/migrations/0001_initial.py new file mode 100644 index 0000000..c7e64d5 --- /dev/null +++ b/db_mutex/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'DBMutex' + db.create_table(u'db_mutex_dbmutex', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('lock_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=256)), + ('creation_time', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal(u'db_mutex', ['DBMutex']) + + + def backwards(self, orm): + # Deleting model 'DBMutex' + db.delete_table(u'db_mutex_dbmutex') + + + models = { + u'db_mutex.dbmutex': { + 'Meta': {'object_name': 'DBMutex'}, + 'creation_time': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lock_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}) + } + } + + complete_apps = ['db_mutex'] \ No newline at end of file diff --git a/db_mutex/migrations/__init__.py b/db_mutex/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db_mutex/models.py b/db_mutex/models.py new file mode 100644 index 0000000..3183261 --- /dev/null +++ b/db_mutex/models.py @@ -0,0 +1,9 @@ +from django.db import models + + +class DBMutex(models.Model): + """ + Models a mutex lock with a lock ID and a creation time. + """ + lock_id = models.CharField(max_length=256, unique=True) + creation_time = models.DateTimeField(auto_now_add=True) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..1013983 --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd0071c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +coverage +django-dynamic-fixture==1.6.5 +django-nose==1.1 +pep8 +psycopg2==2.4.5 +pyflakes +south==0.7.6 +freezegun==0.1.13 +# Note that Django is a requirement, but it is installed in the .travis.yml file in order to test against different versions diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f584e59 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +import os +from setuptools import setup + + +setup( + name='django-db-mutex', + version=open(os.path.join(os.path.dirname(__file__), 'db_mutex', 'VERSION')).read().strip(), + description='Acquire a mutex via the DB in Django', + long_description=open('README.md').read(), + url='http://github.com/ambitioninc/django-db-mutex/', + author='Wes Kendall', + author_email='wesleykendall@gmail.com', + packages=[ + 'manager_utils', + ], + classifiers=[ + 'Programming Language :: Python', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Framework :: Django', + ], + install_requires=[ + 'django>=1.6', + ], + include_package_data=True, +) diff --git a/test_project/__init__.py b/test_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_project/settings.py b/test_project/settings.py new file mode 100644 index 0000000..788c637 --- /dev/null +++ b/test_project/settings.py @@ -0,0 +1,175 @@ +import os + + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +# Use the nose tests runner +TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' + +ADMINS = ( + # ('Your Name', 'your_email@example.com'), +) + +MANAGERS = ADMINS + +test_db = os.environ.get('DB', None) +if test_db is not None: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'USER': 'postgres', + 'NAME': 'db_mutex', + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'ambition_dev', + 'USER': 'ambition_dev', + 'PASSWORD': 'ambition_dev', + 'HOST': 'localhost' + } + } + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# On Unix systems, a value of None will cause Django to use the same +# timezone as the operating system. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = 'UTC' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale. +USE_L10N = True + +# If you set this to False, Django will not use timezone-aware datetimes. +USE_TZ = False + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/home/media/media.lawrence.com/media/" +MEDIA_ROOT = '' + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash. +# Examples: "http://media.lawrence.com/media/", "http://example.com/media/" +MEDIA_URL = '' + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/home/media/media.lawrence.com/static/" +STATIC_ROOT = '' + +# URL prefix for static files. +# Example: "http://media.lawrence.com/static/" +STATIC_URL = '/static/' + +# Additional locations of static files +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + # 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) + +# Make this unique, and don't share it with anybody. +SECRET_KEY = '(v9+!w*uv$%+0ozjkp%9%_^r#et3du+(v(t*w(j55fbzhi@e*7' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + # 'django.template.loaders.eggs.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + # Uncomment the next line for simple clickjacking protection: + # 'django.middleware.clickjacking.XFrameOptionsMiddleware', +) + +ROOT_URLCONF = 'test_project.urls' + +# Python dotted path to the WSGI application used by Django's runserver. +WSGI_APPLICATION = 'test_project.wsgi.application' + +TEMPLATE_DIRS = ( + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +INSTALLED_APPS = ( + 'db_mutex', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + # 'django.contrib.messages', + # 'django.contrib.staticfiles', + 'django.contrib.admin', + 'django_nose', + 'south', + 'test_project', +) + +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error when DEBUG=False. +# See http://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + } + }, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } +} + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'unique-snowflake' + } +} diff --git a/test_project/tests/__init__.py b/test_project/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_project/tests/db_mutex_tests.py b/test_project/tests/db_mutex_tests.py new file mode 100644 index 0000000..85587ab --- /dev/null +++ b/test_project/tests/db_mutex_tests.py @@ -0,0 +1,361 @@ +from datetime import datetime + +from db_mutex import db_mutex, DBMutexError, DBMutexTimeoutError +from db_mutex.models import DBMutex +from django.test import TestCase +from django.test.utils import override_settings +from freezegun import freeze_time + + +class ContextManagerTestCase(TestCase): + """ + Tests db_mutex as a context manager. + """ + @freeze_time('2014-02-01') + def test_no_lock_before(self): + """ + Tests that a lock is succesfully acquired. + """ + # There should be no locks before and after the context manager + self.assertEquals(DBMutex.objects.count(), 0) + with db_mutex('lock_id'): + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1)) + self.assertEquals(DBMutex.objects.count(), 0) + + @freeze_time('2014-02-01') + def test_lock_before(self): + """ + Tests when a lock already exists. + """ + # Create a lock + m = DBMutex.objects.create(lock_id='lock_id') + # Try to acquire the lock. It should raise an exception + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + # The lock should still exist + self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) + + @freeze_time('2014-02-01') + def test_lock_different_id(self): + """ + Tests that the lock still works even when another lock with a different id exists. + """ + # Create a lock + m = DBMutex.objects.create(lock_id='lock_id') + # Try to acquire the lock with a different ID + with db_mutex('lock_id2'): + self.assertEquals(DBMutex.objects.count(), 2) + m2 = DBMutex.objects.get(lock_id='lock_id2') + self.assertEquals(m2.creation_time, datetime(2014, 2, 1)) + # The original lock should still exist but the other one should be gone + self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) + self.assertEquals(DBMutex.objects.count(), 1) + + def test_lock_timeout_default(self): + """ + Tests that the lock timeout works with the default value of 30 minutes. + """ + with freeze_time('2014-02-01'): + # Create a lock + orig_lock = DBMutex.objects.create(lock_id='lock_id') + + # Try to acquire the lock one minute in the future. It should fail + with freeze_time('2014-02-01 00:01:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + # Try to acquire the lock 9 minutes in the future. It should fail + with freeze_time('2014-02-01 00:09:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + # Try to acquire the lock 30 minutes in the future. It should pass since the lock timed out + with freeze_time('2014-02-01 00:30:00'): + with db_mutex('lock_id'): + self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1, 0, 30)) + + @override_settings(DB_MUTEX_TTL_SECONDS=None) + def test_no_lock_timeout(self): + """ + Tests that the lock timeout works when None is configured as the timeout. + """ + with freeze_time('2014-02-01'): + # Create a lock + DBMutex.objects.create(lock_id='lock_id') + + # Try to acquire the lock one minute in the future. It should fail + with freeze_time('2014-02-01 00:01:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + # Try to acquire the lock 9 minutes in the future. It should fail + with freeze_time('2014-02-01 00:09:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + # Try to acquire the lock 30 minutes in the future. It should fail + with freeze_time('2014-02-01 00:30:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + # Try to acquire the lock years in the future. It should fail + with freeze_time('2016-02-01 00:30:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + @override_settings(DB_MUTEX_TTL_SECONDS=60 * 60) + def test_custom_lock_timeout(self): + """ + Tests that the custom lock timeout works when an hour is configured as the timeout. + """ + with freeze_time('2014-02-01'): + # Create a lock + orig_lock = DBMutex.objects.create(lock_id='lock_id') + + # Try to acquire the lock one minute in the future. It should fail + with freeze_time('2014-02-01 00:01:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + # Try to acquire the lock 31 minutes in the future. It should fail + with freeze_time('2014-02-01 00:31:00'): + with self.assertRaises(DBMutexError): + with db_mutex('lock_id'): + pass + + # Try to acquire the lock 60 minutes in the future. It should pass + with freeze_time('2014-02-01 01:00:00'): + with db_mutex('lock_id'): + self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1, 1)) + + def test_lock_timeout_error(self): + """ + Tests the case when a lock expires while the context manager is executing. + """ + with freeze_time('2014-02-01'): + # Acquire a lock at the given time and release it before it is finished. It + # should result in an error + with self.assertRaises(DBMutexTimeoutError): + with db_mutex('lock_id'): + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1)) + + # Release the lock before the context manager finishes + m.delete() + + +class FunctionDecoratorTestCase(TestCase): + """ + Tests db_mutex as a function decorator. + """ + @freeze_time('2014-02-01') + def test_no_lock_before(self): + """ + Tests that a lock is succesfully acquired. + """ + # There should be no locks before and after the context manager + self.assertEquals(DBMutex.objects.count(), 0) + + @db_mutex('lock_id') + def run_get_lock(): + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1)) + + run_get_lock() + self.assertEquals(DBMutex.objects.count(), 0) + + @freeze_time('2014-02-01') + def test_lock_before(self): + """ + Tests when a lock already exists. + """ + # Create a lock + m = DBMutex.objects.create(lock_id='lock_id') + + @db_mutex('lock_id') + def run_get_lock(): + pass + + # Try to acquire the lock. It should raise an exception + with self.assertRaises(DBMutexError): + run_get_lock() + + # The lock should still exist + self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) + + @freeze_time('2014-02-01') + def test_lock_different_id(self): + """ + Tests that the lock still works even when another lock with a different id exists. + """ + # Create a lock + m = DBMutex.objects.create(lock_id='lock_id') + + @db_mutex('lock_id2') + def run_get_lock2(): + self.assertEquals(DBMutex.objects.count(), 2) + m2 = DBMutex.objects.get(lock_id='lock_id2') + self.assertEquals(m2.creation_time, datetime(2014, 2, 1)) + + # Try to acquire the lock with a different ID + run_get_lock2() + # The original lock should still exist but the other one should be gone + self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) + self.assertEquals(DBMutex.objects.count(), 1) + + def test_lock_timeout_default(self): + """ + Tests that the lock timeout works with the default value of 30 minutes. + """ + with freeze_time('2014-02-01'): + # Create a lock + orig_lock = DBMutex.objects.create(lock_id='lock_id') + + # Try to acquire the lock one minute in the future. It should fail + @freeze_time('2014-02-01 00:01:00') + @db_mutex('lock_id') + def run_get_lock1(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock1() + + # Try to acquire the lock 9 minutes in the future. It should fail + @freeze_time('2014-02-01 00:09:00') + @db_mutex('lock_id') + def run_get_lock2(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock2() + + @freeze_time('2014-02-01 00:30:00') + @db_mutex('lock_id') + def run_get_lock3(): + self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1, 0, 30)) + + # Try to acquire the lock 30 minutes in the future. It should pass since the lock timed out + run_get_lock3() + + @override_settings(DB_MUTEX_TTL_SECONDS=None) + def test_no_lock_timeout(self): + """ + Tests that the lock timeout works when None is configured as the timeout. + """ + with freeze_time('2014-02-01'): + # Create a lock + DBMutex.objects.create(lock_id='lock_id') + + # Try to acquire the lock one minute in the future. It should fail + @freeze_time('2014-02-01 00:01:00') + @db_mutex('lock_id') + def run_get_lock1(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock1() + + # Try to acquire the lock 9 minutes in the future. It should fail + @freeze_time('2014-02-01 00:09:00') + @db_mutex('lock_id') + def run_get_lock2(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock2() + + # Try to acquire the lock 30 minutes in the future. It should fail + @freeze_time('2014-02-01 00:30:00') + @db_mutex('lock_id') + def run_get_lock3(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock3() + + # Try to acquire the lock years in the future. It should fail + @freeze_time('2016-02-01 00:30:00') + @db_mutex('lock_id') + def run_get_lock4(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock4() + + @override_settings(DB_MUTEX_TTL_SECONDS=60 * 60) + def test_custom_lock_timeout(self): + """ + Tests that the custom lock timeout works when an hour is configured as the timeout. + """ + with freeze_time('2014-02-01'): + # Create a lock + orig_lock = DBMutex.objects.create(lock_id='lock_id') + + # Try to acquire the lock one minute in the future. It should fail + @freeze_time('2014-02-01 00:01:00') + @db_mutex('lock_id') + def run_get_lock1(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock1() + + # Try to acquire the lock 31 minutes in the future. It should fail + @freeze_time('2014-02-01 00:31:00') + @db_mutex('lock_id') + def run_get_lock2(): + pass + + with self.assertRaises(DBMutexError): + run_get_lock2() + + # Try to acquire the lock 60 minutes in the future. It should pass + @freeze_time('2014-02-01 01:00:00') + @db_mutex('lock_id') + def run_get_lock3(): + self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1, 1)) + + run_get_lock3() + + def test_lock_timeout_error(self): + """ + Tests the case when a lock expires while the context manager is executing. + """ + @freeze_time('2014-02-01') + @db_mutex('lock_id') + def run_get_lock1(): + # Acquire a lock at the given time and release it before it is finished. It + # should result in an error + self.assertEquals(DBMutex.objects.count(), 1) + m = DBMutex.objects.get(lock_id='lock_id') + self.assertEquals(m.creation_time, datetime(2014, 2, 1)) + + # Release the lock before the context manager finishes + m.delete() + + with self.assertRaises(DBMutexTimeoutError): + run_get_lock1()