-
Notifications
You must be signed in to change notification settings - Fork 27
Django DB Mutex #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f074079
8d8c8e7
91aaa58
d77b464
718d8b2
119c82a
9513fb3
358fbed
d44ab56
0b69b92
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| include db_mutex/VERSION | ||
| include README.md | ||
| include LICENSE |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,79 @@ | ||
| [](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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| 0.1 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from .db_mutex import DBMutexError, DBMutexTimeoutError, db_mutex |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can simplify this code slightly by providing getattr with a default value. Or, what I would prefer: Pick your favorite color.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah i didn't know about that. thanks
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but im not changing it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. jk
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 😄 |
||
| """ | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| from django.db import models | ||
|
|
||
|
|
||
| class DBMutex(models.Model): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We discussed having some sort of optional charfield to identify the locking process. Perhaps something like: Thoughts? We could then store the process ID or whatever we want in it, but not require it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm fine with doing that in another pull request/version as I wasn't planning on doing anything too advanced in this first version. Is that fine?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, that's fine. |
||
| """ | ||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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='[email protected]', | ||
| 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, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: in practice and in example, we should decorate this with @abc.abstract method. The advantage is that we will get an error even trying to instantiate subclasses that do not override this; the NotImplementedError will only throw an exception when we try to call run_worker (which would not occur if we accidentally override run).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah i agree with josh, I would try to use the abstract class. Also with this could we just put the decorator on the run?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea I was aware of the abc thing, but I just used this as a quick example. I'll update it to use abc here in the docs though