├── .gitignore ├── CHANGES.txt ├── LICENSE.txt ├── MANIFEST ├── MANIFEST.in ├── README.rst ├── django_pglocks ├── __init__.py ├── models.py ├── test_settings.py └── tests.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | v1.0.1, 2 July 2013 -- Removed transaction mode (see docs) 2 | v1.0, 2 July 2013 -- Initial release. 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2013-2025 Christophe Pettus 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | CHANGES.txt 3 | LICENSE.txt 4 | setup.py 5 | django_pglocks/__init__.py 6 | django_pglocks/models.py 7 | django_pglocks/test_settings.py 8 | django_pglocks/tests.py 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include docs *.txt -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | django-pglocks 3 | ============== 4 | 5 | django-pglocks provides a useful context manager to manage PostgreSQL advisory locks. It requires Django (tested with <= 5.1), PostgreSQL, and (probably) psycopg or psycopg2. 6 | 7 | Advisory Locks 8 | ============== 9 | 10 | Advisory locks are application-level locks that are acquired and released purely by the client of the database; PostgreSQL never acquires them on its own. They are very useful as a way of signalling to other sessions that a higher-level resource than a single row is in use, without having to lock an entire table or some other structure. 11 | 12 | It's entirely up to the application to correctly acquire the right lock. 13 | 14 | Advisory locks are either session locks or transaction locks. A session lock is held until the database session disconnects (or is reset); a transaction lock is held until the transaction terminates. 15 | 16 | Currently, the context manager only creates session locks, as the behavior of a lock persisting after the context body has been exited is surprising, and there's no way of releasing a transaction-scope advisory lock except to exit the transaction. 17 | 18 | Installing 19 | ========== 20 | 21 | Just use pip:: 22 | 23 | pip install django-pglocks 24 | 25 | Transactions 26 | ============ 27 | 28 | This assumes you are controlling transactions within the view; do not use this 29 | if you controlling transactions through the Django transation middleware. 30 | 31 | Usage 32 | ===== 33 | 34 | Usage example:: 35 | 36 | from django_pglocks import advisory_lock 37 | 38 | lock_id = 'some lock' 39 | 40 | with advisory_lock(lock_id) as acquired: 41 | # code that should be inside of the lock. 42 | 43 | The context manager attempts to take the lock, and then executes the code inside the context with the lock acquired. The lock is released when the context exits, either normally or via exception. 44 | 45 | The parameters are: 46 | 47 | * ``lock_id`` -- The ID of the lock to acquire. It can be a string, long, or a tuple of two ints. If it's a string, the hash of the string is used as the lock ID (PostgreSQL advisory lock IDs are 64 bit values). 48 | 49 | * ``shared`` (default False) -- If True, a shared lock is taken. Any number of sessions can hold a shared lock; if another session attempts to take an exclusive lock, it will wait until all shared locks are released; if a session is holding a shared lock, it will block attempts to take a shared lock. If False (the default), an exclusive lock is taken. 50 | 51 | * ``wait`` (default True) -- If True (the default), the context manager will wait until the lock has been acquired before executing the content; in that case, it always returns True (unless a deadlock occurs, in which case an exception is thrown). If False, the context manager will return immediately even if it cannot take the lock, in which case it returns false. Note that the context body is *always* executed; the only way to tell in the ``wait=False`` case whether or not the lock was acquired is to check the returned value. 52 | 53 | * ``comment`` (default False) -- If True, an SQL comment will be appended to the SELECT statement used to acquire and release locks. This comment will include the ``repr()`` of the ``lock_id``, and the calling point for the decorator. This is optional, as it does (slightly) slow down the execution of the decorator. If the Django setting ``ADVISORY_LOCK_COMMENT`` is True, the comment will be added by default (``comment=False`` will override this). If there is no ``ADVISORY_LOCK_COMMENT`` setting, ``DEBUG`` will be used instead. 54 | 55 | * ``using`` (default None) -- The database alias on which to attempt to acquire the lock. If None, the default connection is used. 56 | 57 | Contributing 58 | ============ 59 | 60 | To run the test suite, you must create a user and a database:: 61 | 62 | $ createuser -s -P django_pglocks 63 | Enter password for new role: django_pglocks 64 | Enter it again: django_pglocks 65 | $ createdb django_pglocks -O django_pglocks 66 | 67 | You can then run the tests with:: 68 | 69 | $ DJANGO_SETTINGS_MODULE=django_pglocks.test_settings PYTHONPATH=. django-admin.py test 70 | 71 | License 72 | ======= 73 | 74 | It's released under the `MIT License `_. 75 | 76 | Change History 1.1 77 | ================== 78 | 79 | Add optional comment to the end of the lock acquire/release SELECT statement 80 | with the lock_id and the calling point. 81 | 82 | 83 | Change History 1.0.2 84 | ==================== 85 | 86 | Fixed bug where lock would not be released when acquired with wait=False. 87 | Many thanks to Aymeric Augustin for finding this! 88 | 89 | Change History 1.0.1 90 | ==================== 91 | 92 | Removed transaction-level locks, as their behavior was somewhat surprising (having the lock persist after the context manager exited was unexpected behavior). 93 | -------------------------------------------------------------------------------- /django_pglocks/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1' 2 | 3 | from inspect import stack, getframeinfo 4 | from contextlib import contextmanager 5 | from zlib import crc32 6 | 7 | 8 | @contextmanager 9 | def advisory_lock(lock_id, shared=False, wait=True, comment=None, using=None): 10 | import six 11 | from django.db import DEFAULT_DB_ALIAS, connections, transaction 12 | from django.conf import settings 13 | 14 | add_comment = False 15 | 16 | if comment: 17 | add_comment = True 18 | elif comment is None: 19 | add_comment = getattr(settings, 'ADVISORY_LOCK_COMMENT', None) 20 | if add_comment is None: 21 | add_comment = getattr(settings, 'DEBUG', False) 22 | 23 | if using is None: 24 | using = DEFAULT_DB_ALIAS 25 | 26 | # Assemble the function name based on the options. 27 | 28 | function_name = 'pg_' 29 | 30 | if not wait: 31 | function_name += 'try_' 32 | 33 | function_name += 'advisory_lock' 34 | 35 | if shared: 36 | function_name += '_shared' 37 | 38 | release_function_name = 'pg_advisory_unlock' 39 | if shared: 40 | release_function_name += '_shared' 41 | 42 | # If enabled, add a comment to the SELECT statement with the lock_id, 43 | # and the calling location. 44 | 45 | if add_comment: 46 | caller = getframeinfo(stack()[2][0]) 47 | # Up two on the stack frame, since [1] is contextlib. 48 | lock_id_comment = '-- %s @ %s:%d' % (repr(lock_id), caller.filename, caller.lineno) 49 | else: 50 | lock_id_comment = '' 51 | 52 | # Format up the parameters. 53 | 54 | tuple_format = False 55 | 56 | if isinstance(lock_id, (list, tuple,)): 57 | if len(lock_id) != 2: 58 | raise ValueError("Tuples and lists as lock IDs must have exactly two entries.") 59 | 60 | if not isinstance(lock_id[0], six.integer_types) or not isinstance(lock_id[1], six.integer_types): 61 | raise ValueError("Both members of a tuple/list lock ID must be integers") 62 | 63 | tuple_format = True 64 | elif isinstance(lock_id, six.string_types): 65 | # Generates an id within postgres integer range (-2^31 to 2^31 - 1). 66 | # crc32 generates an unsigned integer in Py3, we convert it into 67 | # a signed integer using 2's complement (this is a noop in Py2) 68 | pos = crc32(lock_id.encode("utf-8")) 69 | lock_id = (2 ** 31 - 1) & pos 70 | if pos & 2 ** 31: 71 | lock_id -= 2 ** 31 72 | elif not isinstance(lock_id, six.integer_types): 73 | raise ValueError("Cannot use %s as a lock id" % lock_id) 74 | 75 | if tuple_format: 76 | base = "SELECT %s(%d, %d) %s" 77 | params = (lock_id[0], lock_id[1], lock_id_comment) 78 | else: 79 | base = "SELECT %s(%d) %s" 80 | params = (lock_id, lock_id_comment) 81 | 82 | acquire_params = (function_name,) + params 83 | 84 | command = base % acquire_params 85 | cursor = connections[using].cursor() 86 | 87 | cursor.execute(command) 88 | 89 | if not wait: 90 | acquired = cursor.fetchone()[0] 91 | else: 92 | acquired = True 93 | 94 | try: 95 | yield acquired 96 | finally: 97 | if acquired: 98 | release_params = (release_function_name,) + params 99 | 100 | command = base % release_params 101 | cursor.execute(command) 102 | 103 | cursor.close() 104 | -------------------------------------------------------------------------------- /django_pglocks/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xof/django-pglocks/e57b32cb5af229d589e889ad69dfbe87477493d2/django_pglocks/models.py -------------------------------------------------------------------------------- /django_pglocks/test_settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for django_pglocks tests. 2 | 3 | import os 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 8 | 'NAME': 'django_pglocks', 9 | 'USER': 'django_pglocks', 10 | 'PASSWORD': 'django_pglocks', 11 | } 12 | } 13 | 14 | INSTALLED_APPS = ['django_pglocks'] 15 | 16 | SECRET_KEY = 'whatever' 17 | -------------------------------------------------------------------------------- /django_pglocks/tests.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | from django.test import TransactionTestCase 3 | 4 | from django_pglocks import advisory_lock 5 | 6 | 7 | class PgLocksTests(TransactionTestCase): 8 | 9 | @classmethod 10 | def setUpClass(cls): 11 | cursor = connection.cursor() 12 | cursor.execute( 13 | "SELECT oid FROM pg_database WHERE datname = %s", 14 | [connection.settings_dict['NAME']]) 15 | cls.db_oid = cursor.fetchall()[0][0] 16 | cursor.close() 17 | 18 | def assertNumLocks(self, expected): 19 | cursor = connection.cursor() 20 | cursor.execute( 21 | "SELECT COUNT(*) FROM pg_locks WHERE database = %s AND locktype = %s", 22 | [self.db_oid, 'advisory']) 23 | actual = cursor.fetchall()[0][0] 24 | cursor.close() 25 | self.assertEqual(actual, expected) 26 | 27 | def test_basic_lock_str(self): 28 | self.assertNumLocks(0) 29 | with advisory_lock('test') as acquired: 30 | self.assertTrue(acquired) 31 | self.assertNumLocks(1) 32 | self.assertNumLocks(0) 33 | 34 | def test_basic_lock_int(self): 35 | self.assertNumLocks(0) 36 | with advisory_lock(123) as acquired: 37 | self.assertTrue(acquired) 38 | self.assertNumLocks(1) 39 | self.assertNumLocks(0) 40 | 41 | def test_basic_lock_tuple(self): 42 | self.assertNumLocks(0) 43 | with advisory_lock((123, 456)) as acquired: 44 | self.assertTrue(acquired) 45 | self.assertNumLocks(1) 46 | self.assertNumLocks(0) 47 | 48 | def test_basic_lock_no_wait(self): 49 | self.assertNumLocks(0) 50 | with advisory_lock(123, wait=False) as acquired: 51 | self.assertTrue(acquired) 52 | self.assertNumLocks(1) 53 | self.assertNumLocks(0) 54 | 55 | def test_basic_lock_shared(self): 56 | self.assertNumLocks(0) 57 | with advisory_lock(123, shared=True) as acquired: 58 | self.assertTrue(acquired) 59 | self.assertNumLocks(1) 60 | self.assertNumLocks(0) 61 | 62 | def test_basic_lock_shared_no_wait(self): 63 | self.assertNumLocks(0) 64 | with advisory_lock(123, shared=True, wait=False) as acquired: 65 | self.assertTrue(acquired) 66 | self.assertNumLocks(1) 67 | self.assertNumLocks(0) 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | import django_pglocks 6 | 7 | def get_long_description(): 8 | """ 9 | Return the contents of the README file. 10 | """ 11 | try: 12 | return open('README.rst').read() 13 | except: 14 | pass # Required to install using pip (won't have README then) 15 | 16 | setup( 17 | name = 'django-pglocks', 18 | version = django_pglocks.__version__, 19 | description = "django_pglocks provides useful context managers for advisory locks for PostgreSQL.", 20 | long_description = get_long_description(), 21 | author = "Christophe Pettus", 22 | author_email = "xof@thebuild.com", 23 | license = "MIT", 24 | url = "https://github.com/Xof/django-pglocks", 25 | install_requires = ['six>=1.0.0'], 26 | packages = [ 27 | 'django_pglocks', 28 | ], 29 | package_data = { 30 | 'facetools': ['templates/facetools/facebook_redirect.html'], 31 | }, 32 | classifiers = [ 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Environment :: Web Environment', 35 | 'Framework :: Django', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Topic :: Software Development', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 2', 41 | 'Programming Language :: Python :: 3', 42 | ] 43 | ) 44 | --------------------------------------------------------------------------------