├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── CONTRIBUTORS ├── LICENSE ├── MANIFEST.in ├── README.rst ├── db_mutex ├── __init__.py ├── apps.py ├── db_mutex.py ├── exceptions.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── tests │ ├── __init__.py │ └── db_mutex_tests.py └── version.py ├── docs ├── Makefile ├── conf.py ├── contributing.rst ├── examples.rst ├── index.rst ├── installation.rst ├── ref │ └── code.rst ├── release_notes.rst └── toc.rst ├── manage.py ├── publish.py ├── requirements ├── docs.txt ├── requirements-testing.txt └── requirements.txt ├── run_tests.py ├── settings.py ├── setup.cfg └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | db_mutex/migrations/* 5 | db_mutex/version.py 6 | source = db_mutex 7 | [report] 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain if tests don't hit defensive assertion code: 13 | raise NotImplementedError 14 | fail_under = 100 15 | show_missing = 1 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # copied from django-cte 2 | name: db_mutex tests 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master,develop] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python: ['3.7', '3.8', '3.9'] 16 | # Time to switch to pytest or nose2?? 17 | # nosetests is broken on 3.10 18 | # AttributeError: module 'collections' has no attribute 'Callable' 19 | # https://github.com/nose-devs/nose/issues/1099 20 | django: 21 | - 'Django~=3.2.0' 22 | - 'Django~=4.0.0' 23 | - 'Django~=4.1.0' 24 | - 'Django~=4.2.0' 25 | experimental: [false] 26 | # include: 27 | # - python: '3.9' 28 | # django: 'https://github.com/django/django/archive/refs/heads/main.zip#egg=Django' 29 | # experimental: true 30 | # # NOTE this job will appear to pass even when it fails because of 31 | # # `continue-on-error: true`. Github Actions apparently does not 32 | # # have this feature, similar to Travis' allow-failure, yet. 33 | # # https://github.com/actions/toolkit/issues/399 34 | psycopg: 35 | - 'psycopg2==2.9.6' 36 | - 'psycopg==3.1.10' 37 | exclude: 38 | - python: '3.7' 39 | django: 'Django~=4.0.0' 40 | - python: '3.7' 41 | django: 'Django~=4.1.0' 42 | - python: '3.7' 43 | django: 'Django~=4.2.0' 44 | - psycopg: 'psycopg==3.1.10' 45 | django: 'Django~=3.2.0' 46 | - psycopg: 'psycopg==3.1.10' 47 | django: 'Django~=4.0.0' 48 | - psycopg: 'psycopg==3.1.10' 49 | django: 'Django~=4.1.0' 50 | services: 51 | postgres: 52 | image: postgres:latest 53 | env: 54 | POSTGRES_DB: postgres 55 | POSTGRES_PASSWORD: postgres 56 | POSTGRES_USER: postgres 57 | ports: 58 | - 5432:5432 59 | options: >- 60 | --health-cmd pg_isready 61 | --health-interval 10s 62 | --health-timeout 5s 63 | --health-retries 5 64 | steps: 65 | - uses: actions/checkout@v3 66 | - uses: actions/setup-python@v3 67 | with: 68 | python-version: ${{ matrix.python }} 69 | - name: Setup 70 | run: | 71 | python --version 72 | pip install --upgrade pip wheel setuptools 73 | pip install -r requirements/requirements.txt 74 | pip install -r requirements/requirements-testing.txt 75 | pip install "${{ matrix.django }}" 76 | pip freeze 77 | - name: Run tests 78 | env: 79 | DB_SETTINGS: >- 80 | { 81 | "ENGINE":"django.db.backends.postgresql", 82 | "NAME":"db_mutex", 83 | "USER":"postgres", 84 | "PASSWORD":"postgres", 85 | "HOST":"localhost", 86 | "PORT":"5432" 87 | } 88 | run: | 89 | coverage run manage.py test db_mutex 90 | coverage report --fail-under=99 91 | continue-on-error: ${{ matrix.experimental }} 92 | - name: Check style 93 | run: flake8 db_mutex 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python files 2 | *.pyc 3 | .eggs/ 4 | 5 | # Vim files 6 | *.swp 7 | *.swo 8 | 9 | # Coverage files 10 | .coverage 11 | htmlcov/ 12 | 13 | # Setuptools distribution folder. 14 | /dist/ 15 | /build/ 16 | 17 | # Python egg metadata, regenerated from source files by setuptools. 18 | /*.egg-info 19 | /*.egg 20 | 21 | # Virtualenv 22 | env/ 23 | venv/ 24 | 25 | # OSX 26 | .DS_Store 27 | 28 | # Pycharm 29 | .idea/ 30 | 31 | # Documentation artifacts 32 | docs/_build/ 33 | 34 | # IPython Notebook 35 | .ipynb_checkpoints/ 36 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.8" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: requirements/docs.txt 14 | - requirements: requirements/requirements.txt -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Wes Kendall (opensource@ambition.com) 2 | Erik Swanson (erik.swanson@ambition.com) 3 | Micah Hausler (micah.hausler@ambition.com) 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ambition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | recursive-include requirements * 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/ambitioninc/django-db-mutex.png 2 | :target: https://travis-ci.org/ambitioninc/django-db-mutex 3 | 4 | django-db-mutex 5 | =============== 6 | Provides the ability to acquire a mutex lock from the database in Django. 7 | 8 | 9 | Installation 10 | ------------ 11 | To install the latest release, type:: 12 | 13 | pip install django-db-mutex 14 | 15 | To install the latest code directly from source, type:: 16 | 17 | pip install git+git://github.com/ambitioninc/django-db-mutex.git 18 | 19 | Documentation 20 | ------------- 21 | 22 | Full documentation is available at http://django-db-mutex.readthedocs.org 23 | 24 | License 25 | ------- 26 | MIT License (see LICENSE) 27 | -------------------------------------------------------------------------------- /db_mutex/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .exceptions import DBMutexError, DBMutexTimeoutError 3 | from .version import __version__ 4 | -------------------------------------------------------------------------------- /db_mutex/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DBMutexConfig(AppConfig): 5 | name = 'db_mutex' 6 | verbose_name = 'Django DB Mutex' 7 | -------------------------------------------------------------------------------- /db_mutex/db_mutex.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | import functools 3 | import logging 4 | 5 | from django.conf import settings 6 | from django.db import transaction, IntegrityError 7 | from django.utils import timezone 8 | 9 | from .exceptions import DBMutexError, DBMutexTimeoutError 10 | from .models import DBMutex 11 | 12 | 13 | LOG = logging.getLogger(__name__) 14 | 15 | 16 | class db_mutex(object): 17 | """ 18 | An object that acts as a context manager and a function decorator for acquiring a 19 | DB mutex lock. 20 | """ 21 | mutex_ttl_seconds_settings_key = 'DB_MUTEX_TTL_SECONDS' 22 | 23 | def __init__(self, lock_id, suppress_acquisition_exceptions=False): 24 | """ 25 | This context manager/function decorator can be used in the following way 26 | 27 | .. code-block:: python 28 | 29 | from db_mutex.db_mutex import db_mutex 30 | 31 | # Lock a critical section of code 32 | try: 33 | with db_mutex('lock_id'): 34 | # Run critical code here 35 | pass 36 | except DBMutexError: 37 | print('Could not obtain lock') 38 | except DBMutexTimeoutError: 39 | print('Task completed but the lock timed out') 40 | 41 | # Lock a function 42 | @db_mutex('lock_id'): 43 | def critical_function(): 44 | # Critical code goes here 45 | pass 46 | 47 | try: 48 | critical_function() 49 | except DBMutexError: 50 | print('Could not obtain lock') 51 | except DBMutexTimeoutError: 52 | print('Task completed but the lock timed out') 53 | 54 | :type lock_id: str 55 | :param lock_id: The ID of the lock one is trying to acquire 56 | :type suppress_acquisition_exceptions: bool 57 | :param suppress_acquisition_exceptions: Suppress exceptions when acquiring the lock and instead 58 | log an error message. Note that this is only applicable when using this as a decorator and 59 | not a context manager. 60 | 61 | :raises: 62 | * :class:`DBMutexError ` when the lock cannot be obtained 63 | * :class:`DBMutexTimeoutError ` when the 64 | lock was deleted during execution 65 | 66 | """ 67 | self.lock_id = lock_id 68 | self.lock = None 69 | self.suppress_acquisition_exceptions = suppress_acquisition_exceptions 70 | 71 | def get_mutex_ttl_seconds(self): 72 | """ 73 | Returns a TTL for mutex locks. It defaults to 30 minutes. If the user specifies None 74 | as the TTL, locks never expire. 75 | 76 | :rtype: int 77 | :returns: the mutex's ttl in seconds 78 | """ 79 | return getattr(settings, self.mutex_ttl_seconds_settings_key, timedelta(minutes=30).total_seconds()) 80 | 81 | def delete_expired_locks(self): 82 | """ 83 | Deletes all expired mutex locks if a ttl is provided. 84 | """ 85 | ttl_seconds = self.get_mutex_ttl_seconds() 86 | if ttl_seconds is not None: 87 | DBMutex.objects.filter(creation_time__lte=timezone.now() - timedelta(seconds=ttl_seconds)).delete() 88 | 89 | def __call__(self, func): 90 | return self.decorate_callable(func) 91 | 92 | def __enter__(self): 93 | self.start() 94 | 95 | def __exit__(self, *args): 96 | self.stop() 97 | 98 | def start(self): 99 | """ 100 | Acquires the db mutex lock. Takes the necessary steps to delete any stale locks. 101 | Throws a DBMutexError if it can't acquire the lock. 102 | """ 103 | # Delete any expired locks first 104 | self.delete_expired_locks() 105 | try: 106 | with transaction.atomic(): 107 | self.lock = DBMutex.objects.create(lock_id=self.lock_id) 108 | except IntegrityError: 109 | raise DBMutexError('Could not acquire lock: {0}'.format(self.lock_id)) 110 | 111 | def stop(self): 112 | """ 113 | Releases the db mutex lock. Throws an error if the lock was released before the function finished. 114 | """ 115 | if not DBMutex.objects.filter(id=self.lock.id).exists(): 116 | raise DBMutexTimeoutError('Lock {0} expired before function completed'.format(self.lock_id)) 117 | else: 118 | self.lock.delete() 119 | 120 | def decorate_callable(self, func): 121 | """ 122 | Decorates a function with the db_mutex decorator by using this class as a context manager around 123 | it. 124 | """ 125 | def wrapper(*args, **kwargs): 126 | try: 127 | with self: 128 | result = func(*args, **kwargs) 129 | return result 130 | except DBMutexError as e: 131 | if self.suppress_acquisition_exceptions: 132 | LOG.error(e) 133 | else: 134 | raise e 135 | functools.update_wrapper(wrapper, func) 136 | return wrapper 137 | -------------------------------------------------------------------------------- /db_mutex/exceptions.py: -------------------------------------------------------------------------------- 1 | class DBMutexError(Exception): 2 | """ 3 | Thrown when a lock cannot be acquired. 4 | """ 5 | pass 6 | 7 | 8 | class DBMutexTimeoutError(Exception): 9 | """ 10 | Thrown when a lock times out before it is released. 11 | """ 12 | pass 13 | -------------------------------------------------------------------------------- /db_mutex/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import models, migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name='DBMutex', 13 | fields=[ 14 | ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), 15 | ('lock_id', models.CharField(unique=True, max_length=256)), 16 | ('creation_time', models.DateTimeField(auto_now_add=True)), 17 | ], 18 | options={ 19 | }, 20 | bases=(models.Model,), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /db_mutex/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-db-mutex/1b45ad46a12b021f7cbea1cf07467b603b936151/db_mutex/migrations/__init__.py -------------------------------------------------------------------------------- /db_mutex/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class DBMutex(models.Model): 5 | """ 6 | Models a mutex lock with a ``lock_id`` and a ``creation_time``. 7 | 8 | :type lock_id: str 9 | :param lock_id: A unique CharField with a max length of 256 10 | 11 | :type creation_time: datetime 12 | :param creation_time: The creation time of the mutex lock 13 | """ 14 | lock_id = models.CharField(max_length=256, unique=True) 15 | creation_time = models.DateTimeField(auto_now_add=True) 16 | 17 | class Meta: 18 | app_label = 'db_mutex' 19 | -------------------------------------------------------------------------------- /db_mutex/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-db-mutex/1b45ad46a12b021f7cbea1cf07467b603b936151/db_mutex/tests/__init__.py -------------------------------------------------------------------------------- /db_mutex/tests/db_mutex_tests.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from db_mutex.exceptions import DBMutexError, DBMutexTimeoutError 4 | from db_mutex.models import DBMutex 5 | from db_mutex.db_mutex import db_mutex 6 | 7 | from django.test import TestCase 8 | from django.test.utils import override_settings 9 | from freezegun import freeze_time 10 | 11 | 12 | class ContextManagerTestCase(TestCase): 13 | """ 14 | Tests db_mutex as a context manager. 15 | """ 16 | @freeze_time('2014-02-01') 17 | def test_no_lock_before(self): 18 | """ 19 | Tests that a lock is succesfully acquired. 20 | """ 21 | # There should be no locks before and after the context manager 22 | self.assertEqual(DBMutex.objects.count(), 0) 23 | with db_mutex('lock_id'): 24 | self.assertEqual(DBMutex.objects.count(), 1) 25 | m = DBMutex.objects.get(lock_id='lock_id') 26 | self.assertEqual(m.creation_time, datetime(2014, 2, 1)) 27 | self.assertEqual(DBMutex.objects.count(), 0) 28 | 29 | @freeze_time('2014-02-01') 30 | def test_lock_before(self): 31 | """ 32 | Tests when a lock already exists. 33 | """ 34 | # Create a lock 35 | m = DBMutex.objects.create(lock_id='lock_id') 36 | # Try to acquire the lock. It should raise an exception 37 | with self.assertRaises(DBMutexError): 38 | with db_mutex('lock_id'): 39 | raise NotImplementedError 40 | # The lock should still exist 41 | self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) 42 | 43 | @freeze_time('2014-02-01') 44 | def test_lock_before_suppress_acquisition_errors(self): 45 | """ 46 | Tests when a lock already exists. Verifies that an exception is thrown when 47 | suppress_acquisition_errors is True. The exception is still thrown because 48 | we are using it as a context manager 49 | """ 50 | # Create a lock 51 | m = DBMutex.objects.create(lock_id='lock_id') 52 | # Try to acquire the lock. It should neither acquire nor release it 53 | with self.assertRaises(DBMutexError): 54 | with db_mutex('lock_id', suppress_acquisition_exceptions=True): 55 | raise NotImplementedError 56 | # The lock should still exist 57 | self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) 58 | 59 | @freeze_time('2014-02-01') 60 | def test_lock_different_id(self): 61 | """ 62 | Tests that the lock still works even when another lock with a different id exists. 63 | """ 64 | # Create a lock 65 | m = DBMutex.objects.create(lock_id='lock_id') 66 | # Try to acquire the lock with a different ID 67 | with db_mutex('lock_id2'): 68 | self.assertEqual(DBMutex.objects.count(), 2) 69 | m2 = DBMutex.objects.get(lock_id='lock_id2') 70 | self.assertEqual(m2.creation_time, datetime(2014, 2, 1)) 71 | # The original lock should still exist but the other one should be gone 72 | self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) 73 | self.assertEqual(DBMutex.objects.count(), 1) 74 | 75 | def test_lock_timeout_default(self): 76 | """ 77 | Tests that the lock timeout works with the default value of 30 minutes. 78 | """ 79 | with freeze_time('2014-02-01'): 80 | # Create a lock 81 | orig_lock = DBMutex.objects.create(lock_id='lock_id') 82 | 83 | # Try to acquire the lock one minute in the future. It should fail 84 | with freeze_time('2014-02-01 00:01:00'): 85 | with self.assertRaises(DBMutexError): 86 | with db_mutex('lock_id'): 87 | raise NotImplementedError 88 | 89 | # Try to acquire the lock 9 minutes in the future. It should fail 90 | with freeze_time('2014-02-01 00:09:00'): 91 | with self.assertRaises(DBMutexError): 92 | with db_mutex('lock_id'): 93 | raise NotImplementedError 94 | 95 | # Try to acquire the lock 30 minutes in the future. It should pass since the lock timed out 96 | with freeze_time('2014-02-01 00:30:00'): 97 | with db_mutex('lock_id'): 98 | self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) 99 | self.assertEqual(DBMutex.objects.count(), 1) 100 | m = DBMutex.objects.get(lock_id='lock_id') 101 | self.assertEqual(m.creation_time, datetime(2014, 2, 1, 0, 30)) 102 | 103 | @override_settings(DB_MUTEX_TTL_SECONDS=None) 104 | def test_no_lock_timeout(self): 105 | """ 106 | Tests that the lock timeout works when None is configured as the timeout. 107 | """ 108 | with freeze_time('2014-02-01'): 109 | # Create a lock 110 | DBMutex.objects.create(lock_id='lock_id') 111 | 112 | # Try to acquire the lock one minute in the future. It should fail 113 | with freeze_time('2014-02-01 00:01:00'): 114 | with self.assertRaises(DBMutexError): 115 | with db_mutex('lock_id'): 116 | raise NotImplementedError 117 | 118 | # Try to acquire the lock 9 minutes in the future. It should fail 119 | with freeze_time('2014-02-01 00:09:00'): 120 | with self.assertRaises(DBMutexError): 121 | with db_mutex('lock_id'): 122 | raise NotImplementedError 123 | 124 | # Try to acquire the lock 30 minutes in the future. It should fail 125 | with freeze_time('2014-02-01 00:30:00'): 126 | with self.assertRaises(DBMutexError): 127 | with db_mutex('lock_id'): 128 | raise NotImplementedError 129 | 130 | # Try to acquire the lock years in the future. It should fail 131 | with freeze_time('2016-02-01 00:30:00'): 132 | with self.assertRaises(DBMutexError): 133 | with db_mutex('lock_id'): 134 | raise NotImplementedError 135 | 136 | @override_settings(DB_MUTEX_TTL_SECONDS=60 * 60) 137 | def test_custom_lock_timeout(self): 138 | """ 139 | Tests that the custom lock timeout works when an hour is configured as the timeout. 140 | """ 141 | with freeze_time('2014-02-01'): 142 | # Create a lock 143 | orig_lock = DBMutex.objects.create(lock_id='lock_id') 144 | 145 | # Try to acquire the lock one minute in the future. It should fail 146 | with freeze_time('2014-02-01 00:01:00'): 147 | with self.assertRaises(DBMutexError): 148 | with db_mutex('lock_id'): 149 | raise NotImplementedError 150 | 151 | # Try to acquire the lock 31 minutes in the future. It should fail 152 | with freeze_time('2014-02-01 00:31:00'): 153 | with self.assertRaises(DBMutexError): 154 | with db_mutex('lock_id'): 155 | raise NotImplementedError 156 | 157 | # Try to acquire the lock 60 minutes in the future. It should pass 158 | with freeze_time('2014-02-01 01:00:00'): 159 | with db_mutex('lock_id'): 160 | self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) 161 | self.assertEqual(DBMutex.objects.count(), 1) 162 | m = DBMutex.objects.get(lock_id='lock_id') 163 | self.assertEqual(m.creation_time, datetime(2014, 2, 1, 1)) 164 | 165 | def test_lock_timeout_error(self): 166 | """ 167 | Tests the case when a lock expires while the context manager is executing. 168 | """ 169 | with freeze_time('2014-02-01'): 170 | # Acquire a lock at the given time and release it before it is finished. It 171 | # should result in an error 172 | with self.assertRaises(DBMutexTimeoutError): 173 | with db_mutex('lock_id'): 174 | self.assertEqual(DBMutex.objects.count(), 1) 175 | m = DBMutex.objects.get(lock_id='lock_id') 176 | self.assertEqual(m.creation_time, datetime(2014, 2, 1)) 177 | 178 | # Release the lock before the context manager finishes 179 | m.delete() 180 | 181 | 182 | class FunctionDecoratorTestCase(TestCase): 183 | """ 184 | Tests db_mutex as a function decorator. 185 | """ 186 | @freeze_time('2014-02-01') 187 | def test_no_lock_before(self): 188 | """ 189 | Tests that a lock is succesfully acquired. 190 | """ 191 | # There should be no locks before and after the context manager 192 | self.assertEqual(DBMutex.objects.count(), 0) 193 | 194 | @db_mutex('lock_id') 195 | def run_get_lock(): 196 | self.assertEqual(DBMutex.objects.count(), 1) 197 | m = DBMutex.objects.get(lock_id='lock_id') 198 | self.assertEqual(m.creation_time, datetime(2014, 2, 1)) 199 | 200 | run_get_lock() 201 | self.assertEqual(DBMutex.objects.count(), 0) 202 | 203 | @freeze_time('2014-02-01') 204 | def test_lock_before(self): 205 | """ 206 | Tests when a lock already exists. 207 | """ 208 | # Create a lock 209 | m = DBMutex.objects.create(lock_id='lock_id') 210 | 211 | @db_mutex('lock_id') 212 | def run_get_lock(): 213 | raise NotImplementedError 214 | 215 | # Try to acquire the lock. It should raise an exception 216 | with self.assertRaises(DBMutexError): 217 | run_get_lock() 218 | 219 | # The lock should still exist 220 | self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) 221 | 222 | @freeze_time('2014-02-01') 223 | def test_lock_before_suppress_acquisition_exceptions(self): 224 | """ 225 | Tests when a lock already exists. Note that it should not raise an exception since 226 | suppress_acquisition_exceptions is True. 227 | """ 228 | # Create a lock 229 | m = DBMutex.objects.create(lock_id='lock_id') 230 | 231 | @db_mutex('lock_id', suppress_acquisition_exceptions=True) 232 | def run_get_lock(): 233 | raise NotImplementedError 234 | 235 | # Try to acquire the lock. It should not raise an exception nor acquire the lock 236 | run_get_lock() 237 | 238 | # The lock should still exist 239 | self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) 240 | 241 | @freeze_time('2014-02-01') 242 | def test_lock_different_id(self): 243 | """ 244 | Tests that the lock still works even when another lock with a different id exists. 245 | """ 246 | # Create a lock 247 | m = DBMutex.objects.create(lock_id='lock_id') 248 | 249 | @db_mutex('lock_id2') 250 | def run_get_lock2(): 251 | self.assertEqual(DBMutex.objects.count(), 2) 252 | m2 = DBMutex.objects.get(lock_id='lock_id2') 253 | self.assertEqual(m2.creation_time, datetime(2014, 2, 1)) 254 | 255 | # Try to acquire the lock with a different ID 256 | run_get_lock2() 257 | # The original lock should still exist but the other one should be gone 258 | self.assertTrue(DBMutex.objects.filter(id=m.id).exists()) 259 | self.assertEqual(DBMutex.objects.count(), 1) 260 | 261 | def test_lock_timeout_default(self): 262 | """ 263 | Tests that the lock timeout works with the default value of 30 minutes. 264 | """ 265 | with freeze_time('2014-02-01'): 266 | # Create a lock 267 | orig_lock = DBMutex.objects.create(lock_id='lock_id') 268 | 269 | # Try to acquire the lock one minute in the future. It should fail 270 | @freeze_time('2014-02-01 00:01:00') 271 | @db_mutex('lock_id') 272 | def run_get_lock1(): 273 | raise NotImplementedError 274 | 275 | with self.assertRaises(DBMutexError): 276 | run_get_lock1() 277 | 278 | # Try to acquire the lock 9 minutes in the future. It should fail 279 | @freeze_time('2014-02-01 00:09:00') 280 | @db_mutex('lock_id') 281 | def run_get_lock2(): 282 | raise NotImplementedError 283 | 284 | with self.assertRaises(DBMutexError): 285 | run_get_lock2() 286 | 287 | @freeze_time('2014-02-01 00:30:00') 288 | @db_mutex('lock_id') 289 | def run_get_lock3(): 290 | self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) 291 | self.assertEqual(DBMutex.objects.count(), 1) 292 | m = DBMutex.objects.get(lock_id='lock_id') 293 | self.assertEqual(m.creation_time, datetime(2014, 2, 1, 0, 30)) 294 | 295 | # Try to acquire the lock 30 minutes in the future. It should pass since the lock timed out 296 | run_get_lock3() 297 | 298 | @override_settings(DB_MUTEX_TTL_SECONDS=None) 299 | def test_no_lock_timeout(self): 300 | """ 301 | Tests that the lock timeout works when None is configured as the timeout. 302 | """ 303 | with freeze_time('2014-02-01'): 304 | # Create a lock 305 | DBMutex.objects.create(lock_id='lock_id') 306 | 307 | # Try to acquire the lock one minute in the future. It should fail 308 | @freeze_time('2014-02-01 00:01:00') 309 | @db_mutex('lock_id') 310 | def run_get_lock1(): 311 | raise NotImplementedError 312 | 313 | with self.assertRaises(DBMutexError): 314 | run_get_lock1() 315 | 316 | # Try to acquire the lock 9 minutes in the future. It should fail 317 | @freeze_time('2014-02-01 00:09:00') 318 | @db_mutex('lock_id') 319 | def run_get_lock2(): 320 | raise NotImplementedError 321 | 322 | with self.assertRaises(DBMutexError): 323 | run_get_lock2() 324 | 325 | # Try to acquire the lock 30 minutes in the future. It should fail 326 | @freeze_time('2014-02-01 00:30:00') 327 | @db_mutex('lock_id') 328 | def run_get_lock3(): 329 | raise NotImplementedError 330 | 331 | with self.assertRaises(DBMutexError): 332 | run_get_lock3() 333 | 334 | # Try to acquire the lock years in the future. It should fail 335 | @freeze_time('2016-02-01 00:30:00') 336 | @db_mutex('lock_id') 337 | def run_get_lock4(): 338 | raise NotImplementedError 339 | 340 | with self.assertRaises(DBMutexError): 341 | run_get_lock4() 342 | 343 | @override_settings(DB_MUTEX_TTL_SECONDS=60 * 60) 344 | def test_custom_lock_timeout(self): 345 | """ 346 | Tests that the custom lock timeout works when an hour is configured as the timeout. 347 | """ 348 | with freeze_time('2014-02-01'): 349 | # Create a lock 350 | orig_lock = DBMutex.objects.create(lock_id='lock_id') 351 | 352 | # Try to acquire the lock one minute in the future. It should fail 353 | @freeze_time('2014-02-01 00:01:00') 354 | @db_mutex('lock_id') 355 | def run_get_lock1(): 356 | raise NotImplementedError 357 | 358 | with self.assertRaises(DBMutexError): 359 | run_get_lock1() 360 | 361 | # Try to acquire the lock 31 minutes in the future. It should fail 362 | @freeze_time('2014-02-01 00:31:00') 363 | @db_mutex('lock_id') 364 | def run_get_lock2(): 365 | raise NotImplementedError 366 | 367 | with self.assertRaises(DBMutexError): 368 | run_get_lock2() 369 | 370 | # Try to acquire the lock 60 minutes in the future. It should pass 371 | @freeze_time('2014-02-01 01:00:00') 372 | @db_mutex('lock_id') 373 | def run_get_lock3(): 374 | self.assertFalse(DBMutex.objects.filter(id=orig_lock.id).exists()) 375 | self.assertEqual(DBMutex.objects.count(), 1) 376 | m = DBMutex.objects.get(lock_id='lock_id') 377 | self.assertEqual(m.creation_time, datetime(2014, 2, 1, 1)) 378 | 379 | run_get_lock3() 380 | 381 | def test_lock_timeout_error(self): 382 | """ 383 | Tests the case when a lock expires while the context manager is executing. 384 | """ 385 | @freeze_time('2014-02-01') 386 | @db_mutex('lock_id') 387 | def run_get_lock1(): 388 | # Acquire a lock at the given time and release it before it is finished. It 389 | # should result in an error 390 | self.assertEqual(DBMutex.objects.count(), 1) 391 | m = DBMutex.objects.get(lock_id='lock_id') 392 | self.assertEqual(m.creation_time, datetime(2014, 2, 1)) 393 | 394 | # Release the lock before the context manager finishes 395 | m.delete() 396 | 397 | with self.assertRaises(DBMutexTimeoutError): 398 | run_get_lock1() 399 | -------------------------------------------------------------------------------- /db_mutex/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.1.2' 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " epub to make an epub" 33 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 34 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 35 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 36 | @echo " text to make text files" 37 | @echo " man to make manual pages" 38 | @echo " texinfo to make Texinfo files" 39 | @echo " info to make Texinfo files and run them through makeinfo" 40 | @echo " gettext to make PO message catalogs" 41 | @echo " changes to make an overview of all changed/added/deprecated items" 42 | @echo " xml to make Docutils-native XML files" 43 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 44 | @echo " linkcheck to check all external links for integrity" 45 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 46 | 47 | clean: 48 | rm -rf $(BUILDDIR)/* 49 | 50 | html: 51 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 52 | @echo 53 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 54 | 55 | dirhtml: 56 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 59 | 60 | singlehtml: 61 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 62 | @echo 63 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 64 | 65 | pickle: 66 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 67 | @echo 68 | @echo "Build finished; now you can process the pickle files." 69 | 70 | json: 71 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 72 | @echo 73 | @echo "Build finished; now you can process the JSON files." 74 | 75 | htmlhelp: 76 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 77 | @echo 78 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 79 | ".hhp project file in $(BUILDDIR)/htmlhelp." 80 | 81 | epub: 82 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 83 | @echo 84 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 85 | 86 | latex: 87 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 88 | @echo 89 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 90 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 91 | "(use \`make latexpdf' here to do that automatically)." 92 | 93 | latexpdf: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo "Running LaTeX files through pdflatex..." 96 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 97 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 98 | 99 | latexpdfja: 100 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 101 | @echo "Running LaTeX files through platex and dvipdfmx..." 102 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 103 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 104 | 105 | text: 106 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 107 | @echo 108 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 109 | 110 | man: 111 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 112 | @echo 113 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 114 | 115 | texinfo: 116 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 117 | @echo 118 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 119 | @echo "Run \`make' in that directory to run these through makeinfo" \ 120 | "(use \`make info' here to do that automatically)." 121 | 122 | info: 123 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 124 | @echo "Running Texinfo files through makeinfo..." 125 | make -C $(BUILDDIR)/texinfo info 126 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 127 | 128 | gettext: 129 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 130 | @echo 131 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 132 | 133 | changes: 134 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 135 | @echo 136 | @echo "The overview file is in $(BUILDDIR)/changes." 137 | 138 | linkcheck: 139 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 140 | @echo 141 | @echo "Link check complete; look for any errors in the above output " \ 142 | "or in $(BUILDDIR)/linkcheck/output.txt." 143 | 144 | doctest: 145 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 146 | @echo "Testing of doctests in the sources finished, look at the " \ 147 | "results in $(BUILDDIR)/doctest/output.txt." 148 | 149 | xml: 150 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 151 | @echo 152 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 153 | 154 | pseudoxml: 155 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 156 | @echo 157 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 158 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # db_mutex documentation build configuration file 4 | 5 | import os 6 | import re 7 | 8 | 9 | def get_version(): 10 | """ 11 | Extracts the version number from the version.py file. 12 | """ 13 | VERSION_FILE = '../db_mutex/version.py' 14 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 15 | if mo: 16 | return mo.group(1) 17 | else: 18 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | extensions = [ 28 | 'sphinx.ext.autodoc', 29 | 'sphinx.ext.intersphinx', 30 | #'sphinx.ext.viewcode', 31 | ] 32 | 33 | # Add any paths that contain templates here, relative to this directory. 34 | templates_path = ['_templates'] 35 | 36 | # The suffix of source filenames. 37 | source_suffix = '.rst' 38 | 39 | # The master toctree document. 40 | master_doc = 'toc' 41 | 42 | # General information about the project. 43 | project = u'django-db-mutex' 44 | copyright = u'2014, Ambition Inc.' 45 | 46 | # The short X.Y version. 47 | version = get_version() 48 | # The full version, including alpha/beta/rc tags. 49 | release = version 50 | 51 | exclude_patterns = ['_build'] 52 | 53 | # The name of the Pygments (syntax highlighting) style to use. 54 | pygments_style = 'sphinx' 55 | 56 | intersphinx_mapping = { 57 | 'python': ('http://python.readthedocs.org/en/v2.7.2/', None), 58 | # 'django': ('http://django.readthedocs.org/en/latest/', None), 59 | # 'celery': ('http://celery.readthedocs.org/en/latest/', None), 60 | } 61 | 62 | # -- Options for HTML output ---------------------------------------------- 63 | 64 | html_theme = 'default' 65 | #html_theme_path = [] 66 | 67 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 68 | if not on_rtd: # only import and set the theme if we're building docs locally 69 | import sphinx_rtd_theme 70 | html_theme = 'sphinx_rtd_theme' 71 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 72 | 73 | # Add any paths that contain custom static files (such as style sheets) here, 74 | # relative to this directory. They are copied after the builtin static files, 75 | # so a file named "default.css" will overwrite the builtin "default.css". 76 | html_static_path = ['_static'] 77 | 78 | # Custom sidebar templates, maps document names to template names. 79 | #html_sidebars = {} 80 | 81 | # Additional templates that should be rendered to pages, maps page names to 82 | # template names. 83 | #html_additional_pages = {} 84 | 85 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 86 | html_show_sphinx = False 87 | 88 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 89 | html_show_copyright = True 90 | 91 | # Output file base name for HTML help builder. 92 | htmlhelp_basename = 'django-db-mutexdoc' 93 | 94 | 95 | # -- Options for LaTeX output --------------------------------------------- 96 | 97 | latex_elements = { 98 | # The paper size ('letterpaper' or 'a4paper'). 99 | #'papersize': 'letterpaper', 100 | 101 | # The font size ('10pt', '11pt' or '12pt'). 102 | #'pointsize': '10pt', 103 | 104 | # Additional stuff for the LaTeX preamble. 105 | #'preamble': '', 106 | } 107 | 108 | # Grouping the document tree into LaTeX files. List of tuples 109 | # (source start file, target name, title, 110 | # author, documentclass [howto, manual, or own class]). 111 | latex_documents = [ 112 | ('index', 'django-db-mutex.tex', u'django-db-mutex Documentation', 113 | u'Wes Kendall', 'manual'), 114 | ] 115 | 116 | # -- Options for manual page output --------------------------------------- 117 | 118 | # One entry per manual page. List of tuples 119 | # (source start file, name, description, authors, manual section). 120 | man_pages = [ 121 | ('index', 'django-db-mutex', u'django-db-mutex Documentation', 122 | [u'Wes Kendall'], 1) 123 | ] 124 | 125 | # -- Options for Texinfo output ------------------------------------------- 126 | 127 | # Grouping the document tree into Texinfo files. List of tuples 128 | # (source start file, target name, title, author, 129 | # dir menu entry, description, category) 130 | texinfo_documents = [ 131 | ('index', 'django-db-mutex', u'django-db-mutex Documentation', 132 | u'Wes Kendall', 'django-db-mutex', 'A short description', 133 | 'Miscellaneous'), 134 | ] 135 | 136 | # -- Django configuration ------------------------------------------------- 137 | import sys 138 | sys.path.insert(0, os.path.abspath('..')) 139 | from settings import configure_settings 140 | configure_settings() 141 | 142 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions and issues are most welcome! All issues and pull requests are 5 | handled through github on the `ambitioninc repository`_. Also, please check for 6 | any existing issues before filing a new one. If you have a great idea but it 7 | involves big changes, please file a ticket before making a pull request! We 8 | want to make sure you don't spend your time coding something that might not fit 9 | the scope of the project. 10 | 11 | .. _ambitioninc repository: https://github.com/ambitioninc/django-db-mutex/issues 12 | 13 | Running the tests 14 | ----------------- 15 | 16 | To get the source source code and run the unit tests, run:: 17 | 18 | $ git clone git://github.com/ambitioninc/django-db-mutex.git 19 | $ cd django-db-mutex 20 | $ virtualenv env 21 | $ . env/bin/activate 22 | $ python setup.py install 23 | $ coverage run setup.py test 24 | $ coverage report --fail-under=100 25 | 26 | While 100% code coverage does not make a library bug-free, it significantly 27 | reduces the number of easily caught bugs! Please make sure coverage is at 100% 28 | before submitting a pull request! 29 | 30 | Code Quality 31 | ------------ 32 | 33 | For code quality, please run flake8:: 34 | 35 | $ pip install flake8 36 | $ flake8 . 37 | 38 | Code Styling 39 | ------------ 40 | Please arrange imports with the following style 41 | 42 | .. code-block:: python 43 | 44 | # Standard library imports 45 | import os 46 | 47 | # Third party package imports 48 | from mock import patch 49 | from django.conf import settings 50 | 51 | # Local package imports 52 | from db_mutex.version import __version__ 53 | 54 | Please follow `Google's python style`_ guide wherever possible. 55 | 56 | .. _Google's python style: http://google-styleguide.googlecode.com/svn/trunk/pyguide.html 57 | 58 | Building the docs 59 | ----------------- 60 | 61 | When in the project directory:: 62 | 63 | $ pip install -r requirements/docs.txt 64 | $ pip uninstall -y django-db-mutex && python setup.py install 65 | $ cd docs && make html 66 | $ open docs/_build/html/index.html 67 | 68 | Release Checklist 69 | ----------------- 70 | 71 | Before a new release, please go through the following checklist: 72 | 73 | * Bump version in db_mutex/version.py 74 | * Git tag the version 75 | * Add a release note in docs/release_notes.rst 76 | * Upload to pypi 77 | 78 | Vulnerability Reporting 79 | ----------------------- 80 | 81 | For any security issues, please do NOT file an issue or pull request on github! 82 | Please contact `security@ambition.com`_ with the GPG key provided on `Ambition's 83 | website`_. 84 | 85 | .. _security@ambition.com: mailto:security@ambition.com 86 | .. _Ambition's website: http://ambition.com/security/ 87 | 88 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | How to Use Django DB Mutex 5 | -------------------------- 6 | The Django DB Mutex app provides a context manager and function decorator for 7 | locking a critical section of code. The context manager is used in the 8 | following way: 9 | 10 | .. code-block:: python 11 | 12 | from db_mutex import DBMutexError, DBMutexTimeoutError 13 | from db_mutex.db_mutex import db_mutex 14 | 15 | # Lock a critical section of code 16 | try: 17 | with db_mutex('lock_id'): 18 | # Run critical code here 19 | pass 20 | except DBMutexError: 21 | print('Could not obtain lock') 22 | except DBMutexTimeoutError: 23 | print('Task completed but the lock timed out') 24 | 25 | You'll notice that two errors were caught from this context manager. The first 26 | one, DBMutexError, is thrown if the lock cannot be acquired. The second one, 27 | DBMutexTimeoutError, is thrown if the critical code completes but the lock 28 | timed out. More about lock timeout in the next section. 29 | 30 | The db_mutex decorator can also be used in a similar manner for locking a function: 31 | 32 | .. code-block:: python 33 | 34 | from db_mutex import DBMutexError, DBMutexTimeoutError 35 | from db_mutex.db_mutex import db_mutex 36 | 37 | @db_mutex('lock_id') 38 | def critical_function(): 39 | pass 40 | 41 | try: 42 | critical_function() 43 | except DBMutexError: 44 | print('Could not obtain lock') 45 | except DBMutexTimeoutError: 46 | print('Task completed but the lock timed out') 47 | 48 | Lock Timeout 49 | ------------ 50 | Django DB Mutex comes with lock timeout baked in. This ensures that a lock 51 | cannot be held forever. This is especially important when working with segments 52 | of code that may run out of memory or produce errors that do not raise 53 | exceptions. 54 | 55 | In the default setup of this app, a lock is only valid for 30 minutes. As shown 56 | earlier in the example code, if the lock times out during the execution of a 57 | critical piece of code, a DBMutexTimeoutError will be thrown. This error 58 | basically says that a critical section of your code could have overlapped (but 59 | it doesn't necessarily say if a section of code overlapped or didn't). 60 | 61 | In order to change the duration of a lock, set the ``DB_MUTEX_TTL_SECONDS`` 62 | variable in your settings.py file to a number of seconds. If you want your 63 | locks to never expire (**beware!**), set the setting to ``None``. 64 | 65 | Usage with Celery 66 | ----------------- 67 | 68 | Django DB Mutex can be used with celery's tasks in the following manner: 69 | 70 | .. code-block:: python 71 | 72 | from db_mutex import DBMutexError, DBMutexTimeoutError 73 | from db_mutex.db_mutex import db_mutex 74 | from abc import ABCMeta 75 | from celery import Task 76 | 77 | class NonOverlappingTask(Task): 78 | __metaclass__ = ABCMeta 79 | 80 | def run_worker(self, *args, **kwargs): 81 | """ 82 | Run worker code here. 83 | """ 84 | raise NotImplementedError() 85 | 86 | def run(self, *args, **kwargs): 87 | try: 88 | with db_mutex(self.__class__.__name__): 89 | self.run_worker(*args, **kwargs): 90 | except DBMutexError: 91 | # Ignore this task since the same one is already running 92 | pass 93 | except DBMutexTimeoutError: 94 | # A task ran for a long time and another one may have overlapped with it. Report the error 95 | pass 96 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django-db-mutex Documentation 2 | ============================= 3 | 4 | Django-db-mutex provides the ability to acquire a mutex lock from the database 5 | in Django. 6 | 7 | Overview 8 | -------- 9 | 10 | For critical pieces of code that cannot overlap with one another, it is often 11 | necessary to acquire a mutex lock of some sort. Many solutions use a memcache 12 | lock strategy, however, this strategy can be brittle in the case of memcache 13 | going down or when an unconsistent hashing function is used in a distributed 14 | memcache setup. 15 | 16 | If your application does not need a high performance mutex lock, Django DB 17 | Mutex does the trick. The common use case for Django DB Mutex is to provide the 18 | abilty to lock long-running periodic tasks that should not overlap with one 19 | another. Celery is the common backend for Django when scheduling periodic 20 | tasks. 21 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | * Install Django-db-mutex with your favorite Python package manager:: 5 | 6 | # Using pip 7 | pip install django-db-mutex 8 | 9 | # Or, using pip (from source, in editable form) 10 | pip install -e git://github.com/ambitioninc/django-db-mutex.git#egg=django-db-mutex 11 | 12 | * Add ``'db_mutex'`` to your ``INSTALLED_APPS`` setting:: 13 | 14 | INSTALLED_APPS = ( 15 | # other apps 16 | 'db_mutex', 17 | ) 18 | -------------------------------------------------------------------------------- /docs/ref/code.rst: -------------------------------------------------------------------------------- 1 | .. _ref-code: 2 | 3 | Code Documentation 4 | ================== 5 | 6 | db_mutex 7 | -------- 8 | 9 | .. automodule:: db_mutex.db_mutex 10 | .. autoclass:: db_mutex.db_mutex.db_mutex 11 | :members: 12 | 13 | .. automethod:: __init__ 14 | 15 | DBMutex Model 16 | ------------- 17 | 18 | .. autoclass:: db_mutex.models.DBMutex 19 | :members: 20 | 21 | Exceptions 22 | ---------- 23 | 24 | .. automodule:: db_mutex.exceptions 25 | .. autoclass:: db_mutex.exceptions.DBMutexError 26 | .. autoclass:: db_mutex.exceptions.DBMutexTimeoutError 27 | -------------------------------------------------------------------------------- /docs/release_notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | v3.1.1 5 | ------ 6 | * Read the Docs config file v2 7 | * Github actions for testing psycopg3 8 | 9 | v3.1.0 10 | ------ 11 | * Django 4.2 12 | * Drop django 2 13 | 14 | v3.0.0 15 | ------ 16 | * Django 3.2. 4.0, 4.1 17 | * python 3.9 18 | * drop python 3.6 19 | 20 | v2.0.0 21 | ------ 22 | * Django 3.0, 3.1 23 | * Drop Django 2.0, 2.1 24 | 25 | v1.2.0 26 | ------ 27 | * Python 3.7 28 | * Django 2.1 29 | * Django 2.2 30 | 31 | v1.1.0 32 | ------ 33 | * Add tox to support more versions 34 | 35 | v1.0.0 36 | ------ 37 | * Drop Django 1.9 support 38 | * Drop Django 1.10 support 39 | * Add Django 2.0 support 40 | * Drop python 2.7 support 41 | * Drop python 3.4 support 42 | 43 | v0.5.0 44 | ------ 45 | * Add python 3.6 support 46 | * Drop Django 1.8 support 47 | * Add Django 1.10 support 48 | * Add Django 1.11 support 49 | 50 | v0.4.0 51 | ------ 52 | * Add python 3.5 support 53 | * Drop Django 1.7 support 54 | 55 | v0.3.1 56 | ------ 57 | * Fixed docs 58 | 59 | v0.3.0 60 | ------ 61 | * Added django 1.9 support 62 | 63 | v0.2.0 64 | ------ 65 | * Dropped Django 1.6, added 1.8 support 66 | 67 | v0.1.8 68 | ------ 69 | * Fixed migrations to include south_migrations 70 | 71 | v0.1.7 72 | ------ 73 | * Fixed upload to pypi 74 | 75 | v0.1.6 76 | ------ 77 | * Updated for Django 1.7 compatibility 78 | 79 | v0.1.4 80 | ------ 81 | * This release of django-db-mutex includes docs buildout 82 | * python 3.3, 3.4 compatibility 83 | -------------------------------------------------------------------------------- /docs/toc.rst: -------------------------------------------------------------------------------- 1 | Table of Contents 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | index 8 | installation 9 | examples 10 | ref/code 11 | contributing 12 | release_notes 13 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | # Show warnings about django deprecations - uncomment for version upgrade testing 5 | import warnings 6 | from django.utils.deprecation import RemovedInNextVersionWarning 7 | warnings.filterwarnings('always', category=DeprecationWarning) 8 | warnings.filterwarnings('always', category=PendingDeprecationWarning) 9 | warnings.filterwarnings('always', category=RemovedInNextVersionWarning) 10 | 11 | from settings import configure_settings 12 | 13 | 14 | if __name__ == '__main__': 15 | configure_settings() 16 | 17 | from django.core.management import execute_from_command_line 18 | 19 | execute_from_command_line(sys.argv) 20 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | subprocess.call(['pip', 'install', 'wheel']) 4 | subprocess.call(['python', 'setup.py', 'clean', '--all']) 5 | subprocess.call(['python', 'setup.py', 'register', 'sdist', 'bdist_wheel', 'upload']) 6 | subprocess.call(['rm', '-r', 'dist/']) 7 | subprocess.call(['pip', 'install', 'wheel']) 8 | subprocess.call(['pip', 'install', 'twine']) 9 | subprocess.call(['python', 'setup.py', 'clean', '--all']) 10 | subprocess.call(['python', 'setup.py', 'register', 'sdist', 'bdist_wheel']) 11 | subprocess.call(['twine', 'upload', 'dist/*']) 12 | subprocess.call(['rm', '-r', 'dist/']) 13 | subprocess.call(['rm', '-r', 'build/']) 14 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=1.2.2 2 | sphinx_rtd_theme 3 | psycopg2>=2.4.5 4 | django>=1.7 5 | -------------------------------------------------------------------------------- /requirements/requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | django-dynamic-fixture 3 | django-nose 4 | freezegun 5 | psycopg2 6 | flake8 7 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2 2 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from optparse import OptionParser 3 | from settings import configure_settings 4 | 5 | 6 | # Configure the default settings 7 | configure_settings() 8 | 9 | # Django nose must be imported here since it depends on the settings being configured 10 | from django_nose import NoseTestSuiteRunner # noqa 11 | 12 | 13 | def run_tests(*test_args, **kwargs): 14 | if not test_args: 15 | test_args = ['db_mutex'] 16 | 17 | kwargs.setdefault('interactive', False) 18 | 19 | test_runner = NoseTestSuiteRunner(**kwargs) 20 | 21 | failures = test_runner.run_tests(test_args) 22 | sys.exit(failures) 23 | 24 | 25 | if __name__ == '__main__': 26 | parser = OptionParser() 27 | parser.add_option('--verbosity', dest='verbosity', action='store', default=1, type=int) 28 | (options, args) = parser.parse_args() 29 | 30 | run_tests(*args, **options.__dict__) 31 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from django.conf import settings 5 | 6 | 7 | def configure_settings(): 8 | """ 9 | Configures settings for manage.py and for run_tests.py. 10 | """ 11 | if not settings.configured: 12 | # Determine the database settings depending on if a test_db var is set in CI mode or not 13 | test_db = os.environ.get('DB', None) 14 | if test_db is None: 15 | db_config = { 16 | 'ENGINE': 'django.db.backends.postgresql', 17 | 'NAME': 'db_mutex', 18 | 'USER': 'postgres', 19 | 'PASSWORD': '', 20 | 'HOST': 'db', 21 | } 22 | elif test_db == 'postgres': 23 | db_config = { 24 | 'ENGINE': 'django.db.backends.postgresql', 25 | 'NAME': 'db_mutex', 26 | 'USER': 'postgres', 27 | 'PASSWORD': '', 28 | 'HOST': 'db', 29 | } 30 | else: 31 | raise RuntimeError('Unsupported test DB {0}'.format(test_db)) 32 | 33 | # Check env for db override (used for github actions) 34 | if os.environ.get('DB_SETTINGS'): 35 | db_config = json.loads(os.environ.get('DB_SETTINGS')) 36 | 37 | settings.configure( 38 | TEST_RUNNER='django_nose.NoseTestSuiteRunner', 39 | SECRET_KEY='*', 40 | NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'], 41 | DATABASES={ 42 | 'default': db_config, 43 | }, 44 | INSTALLED_APPS=( 45 | 'django.contrib.auth', 46 | 'django.contrib.contenttypes', 47 | 'django.contrib.sessions', 48 | 'django.contrib.messages', 49 | 'django.contrib.admin', 50 | 'db_mutex', 51 | 'db_mutex.tests', 52 | 'django_nose', 53 | ), 54 | DEBUG=False, 55 | DEFAULT_AUTO_FIELD='django.db.models.AutoField', 56 | CACHES={ 57 | 'default': { 58 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 59 | 'LOCATION': 'unique-snowflake' 60 | } 61 | }, 62 | MIDDLEWARE = ( 63 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 64 | 'django.contrib.messages.middleware.MessageMiddleware', 65 | 'django.contrib.sessions.middleware.SessionMiddleware' 66 | ), 67 | TEMPLATES = [{ 68 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 69 | 'APP_DIRS': True, 70 | 'OPTIONS': { 71 | 'context_processors': [ 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | 'django.template.context_processors.request', 75 | ] 76 | } 77 | }], 78 | ) 79 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = docs,venv,env,*.egg,migrations,south_migrations 4 | max-complexity = 10 5 | 6 | [bdist_wheel] 7 | universal = 1 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import multiprocessing 3 | from setuptools import setup, find_packages 4 | 5 | 6 | # import multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215) 7 | assert multiprocessing 8 | 9 | 10 | def get_version(): 11 | """ 12 | Extracts the version number from the version.py file. 13 | """ 14 | VERSION_FILE = 'db_mutex/version.py' 15 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 16 | if mo: 17 | return mo.group(1) 18 | else: 19 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 20 | 21 | 22 | def get_lines(file_path): 23 | return open(file_path, 'r').read().split('\n') 24 | 25 | 26 | install_requires = get_lines('requirements/requirements.txt') 27 | tests_require = get_lines('requirements/requirements-testing.txt') 28 | 29 | 30 | setup( 31 | name='django-db-mutex', 32 | version=get_version(), 33 | description='Acquire a mutex via the DB in Django', 34 | long_description=open('README.rst').read(), 35 | url='http://github.com/ambitioninc/django-db-mutex/', 36 | author='Wes Kendall', 37 | author_email='opensource@ambition.com', 38 | packages=find_packages(), 39 | classifiers=[ 40 | 'Programming Language :: Python', 41 | 'Programming Language :: Python :: 3.7', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Operating System :: OS Independent', 47 | 'Framework :: Django', 48 | 'Framework :: Django :: 3.2', 49 | 'Framework :: Django :: 4.0', 50 | 'Framework :: Django :: 4.1', 51 | 'Framework :: Django :: 4.2', 52 | ], 53 | license='MIT', 54 | install_requires=install_requires, 55 | tests_require=tests_require, 56 | test_suite='run_tests.run_tests', 57 | include_package_data=True, 58 | zip_safe=False, 59 | ) 60 | --------------------------------------------------------------------------------