Skip to content

Commit 5477e7a

Browse files
committed
Add support for explicit table-level locks
1 parent 9b809d1 commit 5477e7a

File tree

7 files changed

+286
-1
lines changed

7 files changed

+286
-1
lines changed

docs/source/api_reference.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ API Reference
3737
.. autoclass:: ConditionalUniqueIndex
3838
.. autoclass:: CaseInsensitiveUniqueIndex
3939

40+
.. automodule:: psqlextra.locking
41+
:members:
42+
4043
.. automodule:: psqlextra.partitioning
4144
:members:
4245

docs/source/index.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ Explore the documentation to learn about all features:
3535

3636
Support for ``TRUNCATE TABLE`` statements (including cascading).
3737

38+
* :ref:`Locking models & tables <locking_page>`
39+
40+
Support for explicit table-level locks.
41+
3842

3943
.. toctree::
4044
:maxdepth: 2
@@ -49,6 +53,7 @@ Explore the documentation to learn about all features:
4953
table_partitioning
5054
expressions
5155
annotations
56+
locking
5257
settings
5358
api_reference
5459
major_releases

docs/source/locking.rst

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
.. include:: ./snippets/postgres_doc_links.rst
2+
3+
.. _locking_page:
4+
5+
Locking
6+
=======
7+
8+
`Explicit table-level locks`_ are supported through the :meth:`psqlextra.locking.postgres_lock_model` and :meth:`psqlextra.locking.postgres_lock_table` methods. All table-level lock methods are supported.
9+
10+
Locks are always bound to the current transaction and are released when the transaction is comitted or rolled back. There is no support (in PostgreSQL) for explicitly releasing a lock.
11+
12+
.. warning::
13+
14+
Locks are only released when the *outer* transaction commits or when a nested transaction is rolled back. You can ensure that the transaction you created is the outermost one by passing the ``durable=True`` argument to ``transaction.atomic``.
15+
16+
.. note::
17+
18+
Use `django-pglocks <https://pypi.org/project/django-pglocks/>`_ if you need a advisory lock.
19+
20+
Locking a model
21+
---------------
22+
23+
Use :class:`psqlextra.locking.PostgresTableLockMode` to indicate the type of lock to acquire.
24+
25+
.. code-block:: python
26+
27+
from django.db import transaction
28+
29+
from psqlextra.locking import PostgresTableLockMode, postgres_lock_table
30+
31+
with transaction.atomic(durable=True):
32+
postgres_lock_model(MyModel, PostgresTableLockMode.EXCLUSIVE)
33+
34+
# locks are released here, when the transaction comitted
35+
36+
37+
Locking a table
38+
---------------
39+
40+
Use :meth:`psqlextra.locking.postgres_lock_table` to lock arbitrary tables in arbitrary schemas.
41+
42+
.. code-block:: python
43+
44+
from django.db import transaction
45+
46+
from psqlextra.locking import PostgresTableLockMode, postgres_lock_table
47+
48+
with transaction.atomic(durable=True):
49+
postgres_lock_table("mytable", PostgresTableLockMode.EXCLUSIVE)
50+
postgres_lock_table(
51+
"tableinotherschema",
52+
PostgresTableLockMode.EXCLUSIVE,
53+
schema_name="myschema"
54+
)
55+
56+
# locks are released here, when the transaction comitted

docs/source/snippets/postgres_doc_links.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
.. _TRUNCATE TABLE: https://www.postgresql.org/docs/9.1/sql-truncate.html
33
.. _hstore: https://www.postgresql.org/docs/11/hstore.html
44
.. _PostgreSQL Declarative Table Partitioning: https://www.postgresql.org/docs/current/ddl-partitioning.html#DDL-PARTITIONING-DECLARATIVE
5+
.. _Explicit table-level locks: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-TABLES

psqlextra/backend/introspection.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dataclasses import dataclass
2-
from typing import List, Optional
2+
from typing import List, Optional, Tuple
33

44
from psqlextra.types import PostgresPartitioningMethod
55

@@ -198,3 +198,19 @@ def get_constraints(self, cursor, table_name: str):
198198
constraint["definition"] = definition
199199

200200
return constraints
201+
202+
def get_table_locks(self, cursor) -> List[Tuple[str, str, str]]:
203+
cursor.execute(
204+
"""
205+
SELECT
206+
n.nspname,
207+
t.relname,
208+
l.mode
209+
FROM pg_locks l
210+
INNER JOIN pg_class t ON t.oid = l.relation
211+
INNER JOIN pg_namespace n ON n.oid = t.relnamespace
212+
WHERE t.relnamespace >= 2200
213+
ORDER BY n.nspname, t.relname, l.mode"""
214+
)
215+
216+
return cursor.fetchall()

psqlextra/locking.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from enum import Enum
2+
from typing import Optional, Type
3+
4+
from django.db import DEFAULT_DB_ALIAS, connections, models
5+
6+
7+
class PostgresTableLockMode(Enum):
8+
"""List of table locking modes.
9+
10+
See: https://www.postgresql.org/docs/current/explicit-locking.html
11+
"""
12+
13+
ACCESS_SHARE = "ACCESS SHARE"
14+
ROW_SHARE = "ROW SHARE"
15+
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
16+
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
17+
SHARE = "SHARE"
18+
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
19+
EXCLUSIVE = "EXCLUSIVE"
20+
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
21+
22+
23+
def postgres_lock_table(
24+
table_name: str,
25+
lock_mode: PostgresTableLockMode,
26+
*,
27+
schema_name: Optional[str] = None,
28+
using: str = DEFAULT_DB_ALIAS,
29+
) -> None:
30+
"""Locks the specified table with the specified mode.
31+
32+
The lock is held until the end of the current transaction.
33+
34+
Arguments:
35+
table_name:
36+
Unquoted table name to acquire the lock on.
37+
38+
lock_mode:
39+
Type of lock to acquire.
40+
41+
schema_name:
42+
Optionally, the unquoted name of the schema
43+
the table to lock is in. If not specified,
44+
the table name is resolved by PostgreSQL
45+
using it's ``search_path``.
46+
47+
using:
48+
Name of the database alias to use.
49+
"""
50+
51+
connection = connections[using]
52+
53+
with connection.cursor() as cursor:
54+
quoted_fqn = connection.ops.quote_name(table_name)
55+
if schema_name:
56+
quoted_fqn = (
57+
connection.ops.quote_name(schema_name) + "." + quoted_fqn
58+
)
59+
60+
cursor.execute(f"LOCK TABLE {quoted_fqn} IN {lock_mode.value} MODE")
61+
62+
63+
def postgres_lock_model(
64+
model: Type[models.Model],
65+
lock_mode: PostgresTableLockMode,
66+
*,
67+
using: str = DEFAULT_DB_ALIAS,
68+
schema_name: Optional[str] = None,
69+
) -> None:
70+
"""Locks the specified model with the specified mode.
71+
72+
The lock is held until the end of the current transaction.
73+
74+
Arguments:
75+
model:
76+
The model of which to lock the table.
77+
78+
lock_mode:
79+
Type of lock to acquire.
80+
81+
schema_name:
82+
Optionally, the unquoted name of the schema
83+
the table to lock is in. If not specified,
84+
the table name is resolved by PostgreSQL
85+
using it's ``search_path``.
86+
87+
Django models always reside in the default
88+
("public") schema. You should not specify
89+
this unless you're doing something special.
90+
91+
using:
92+
Name of the database alias to use.
93+
"""
94+
95+
postgres_lock_table(
96+
model._meta.db_table, lock_mode, schema_name=schema_name, using=using
97+
)

tests/test_locking.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import uuid
2+
3+
import pytest
4+
5+
from django.db import connection, models, transaction
6+
7+
from psqlextra.locking import (
8+
PostgresTableLockMode,
9+
postgres_lock_model,
10+
postgres_lock_table,
11+
)
12+
13+
from .fake_model import get_fake_model
14+
15+
16+
@pytest.fixture
17+
def mocked_model():
18+
return get_fake_model(
19+
{
20+
"name": models.TextField(),
21+
}
22+
)
23+
24+
25+
def get_table_locks():
26+
with connection.cursor() as cursor:
27+
return connection.introspection.get_table_locks(cursor)
28+
29+
30+
@pytest.mark.django_db(transaction=True)
31+
def test_postgres_lock_table(mocked_model):
32+
lock_signature = (
33+
"public",
34+
mocked_model._meta.db_table,
35+
"AccessExclusiveLock",
36+
)
37+
with transaction.atomic():
38+
postgres_lock_table(
39+
mocked_model._meta.db_table, PostgresTableLockMode.ACCESS_EXCLUSIVE
40+
)
41+
assert lock_signature in get_table_locks()
42+
43+
assert lock_signature not in get_table_locks()
44+
45+
46+
@pytest.mark.django_db(transaction=True)
47+
def test_postgres_lock_table_in_schema():
48+
schema_name = str(uuid.uuid4())[:8]
49+
table_name = str(uuid.uuid4())[:8]
50+
quoted_schema_name = connection.ops.quote_name(schema_name)
51+
quoted_table_name = connection.ops.quote_name(table_name)
52+
53+
with connection.cursor() as cursor:
54+
cursor.execute(f"CREATE SCHEMA {quoted_schema_name}")
55+
cursor.execute(
56+
f"CREATE TABLE {quoted_schema_name}.{quoted_table_name} AS SELECT 'hello world'"
57+
)
58+
59+
lock_signature = (schema_name, table_name, "ExclusiveLock")
60+
with transaction.atomic():
61+
postgres_lock_table(
62+
table_name, PostgresTableLockMode.EXCLUSIVE, schema_name=schema_name
63+
)
64+
assert lock_signature in get_table_locks()
65+
66+
assert lock_signature not in get_table_locks()
67+
68+
69+
@pytest.mark.django_db(transaction=True)
70+
def test_postgres_lock_mode(mocked_model):
71+
lock_signature = (
72+
"public",
73+
mocked_model._meta.db_table,
74+
"AccessExclusiveLock",
75+
)
76+
77+
with transaction.atomic():
78+
postgres_lock_model(
79+
mocked_model, PostgresTableLockMode.ACCESS_EXCLUSIVE
80+
)
81+
assert lock_signature in get_table_locks()
82+
83+
assert lock_signature not in get_table_locks()
84+
85+
86+
@pytest.mark.django_db(transaction=True)
87+
def test_postgres_lock_model_in_schema(mocked_model):
88+
schema_name = str(uuid.uuid4())[:8]
89+
quoted_schema_name = connection.ops.quote_name(schema_name)
90+
quoted_table_name = connection.ops.quote_name(mocked_model._meta.db_table)
91+
92+
with connection.cursor() as cursor:
93+
cursor.execute(f"CREATE SCHEMA {quoted_schema_name}")
94+
cursor.execute(
95+
f"CREATE TABLE {quoted_schema_name}.{quoted_table_name} (LIKE public.{quoted_table_name} INCLUDING ALL)"
96+
)
97+
98+
lock_signature = (schema_name, mocked_model._meta.db_table, "ExclusiveLock")
99+
with transaction.atomic():
100+
postgres_lock_model(
101+
mocked_model,
102+
PostgresTableLockMode.EXCLUSIVE,
103+
schema_name=schema_name,
104+
)
105+
assert lock_signature in get_table_locks()
106+
107+
assert lock_signature not in get_table_locks()

0 commit comments

Comments
 (0)