pg-advisory-lock 0.0.15__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pg_advisory_lock-0.0.15/LICENSE +21 -0
- pg_advisory_lock-0.0.15/PKG-INFO +218 -0
- pg_advisory_lock-0.0.15/README.md +198 -0
- pg_advisory_lock-0.0.15/pyproject.toml +70 -0
- pg_advisory_lock-0.0.15/src/pg_advisory_lock/__init__.py +10 -0
- pg_advisory_lock-0.0.15/src/pg_advisory_lock/exceptions.py +41 -0
- pg_advisory_lock-0.0.15/src/pg_advisory_lock/lock.py +88 -0
- pg_advisory_lock-0.0.15/src/pg_advisory_lock/lock_arguments.py +66 -0
- pg_advisory_lock-0.0.15/src/pg_advisory_lock/pg_local_setting.py +44 -0
- pg_advisory_lock-0.0.15/src/pg_advisory_lock/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Tryastsyn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, 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,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pg-advisory-lock
|
|
3
|
+
Version: 0.0.15
|
|
4
|
+
Summary: Pythonic PostgreSQL advisory locking for SQLAlchemy, with structured keys and explicit contention handling.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: postgresql,advisory-lock,sqlalchemy,locking,concurrency
|
|
7
|
+
Author: Alexander Tryastsyn
|
|
8
|
+
Requires-Python: >=3.12,<4.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: sqlalchemy (>=1.4,<3.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pg-advisory-lock
|
|
21
|
+
|
|
22
|
+
## Pythonic PostgreSQL advisory locking for SQLAlchemy, with structured keys and explicit contention handling.
|
|
23
|
+
|
|
24
|
+
Provides a high-level interface for acquiring PostgreSQL advisory locks with structured
|
|
25
|
+
lock arguments and timeout support. Advisory locks are application-level locks that are
|
|
26
|
+
automatically released when the transaction commits or rolls back.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Key features:
|
|
30
|
+
- Deterministic lock keys composed from `str`, `int`, and `uuid.UUID` components
|
|
31
|
+
- Non-blocking immediate lock acquisition
|
|
32
|
+
- Blocking lock acquisition with configurable timeout
|
|
33
|
+
- Automatic lock release on transaction end (uses pg_advisory_xact_lock functions)
|
|
34
|
+
- Configurable handling for expected lock contention: raise exceptions (default) or return a boolean
|
|
35
|
+
- Database and infrastructure errors are propagated unchanged
|
|
36
|
+
- Inline type hints shipped with the package (PEP 561)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Failure handling
|
|
40
|
+
`raise_on_failure` controls expected lock contention only:
|
|
41
|
+
|
|
42
|
+
- `raise_on_failure=True` (default):
|
|
43
|
+
- `lock_immediate()` raises `PgAdvisoryLockNotAcquired` when the lock is already held.
|
|
44
|
+
- `lock()` raises `PgAdvisoryLockTimeout` when its timeout expires.
|
|
45
|
+
- `raise_on_failure=False`:
|
|
46
|
+
- Both methods return `False` for expected contention or timeout.
|
|
47
|
+
- Unexpected database and infrastructure errors are always propagated.
|
|
48
|
+
|
|
49
|
+
`raise_on_failure` must be a boolean. Other values raise `PgAdvisoryLockError`.
|
|
50
|
+
|
|
51
|
+
`PgAdvisoryLockTimeout` is a subclass of `PgAdvisoryLockNotAcquired`.
|
|
52
|
+
|
|
53
|
+
Boolean mode discards the originating database error. Use the default exception mode when the
|
|
54
|
+
cause matters: the underlying `DBAPIError` is chained as `__cause__`.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
### Usage examples:
|
|
58
|
+
Import the public API from the installed package:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pg_advisory_lock import (
|
|
62
|
+
PgAdvisoryLock,
|
|
63
|
+
PgAdvisoryLockError,
|
|
64
|
+
PgAdvisoryLockNotAcquired,
|
|
65
|
+
PgAdvisoryLockTimeout,
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
#### Default behavior: raises exception on failure (safe by default):
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
locker = PgAdvisoryLock(lock_args=[UserModel.__table__.name, user_id])
|
|
73
|
+
|
|
74
|
+
with session.begin():
|
|
75
|
+
locker.lock(session=session)
|
|
76
|
+
process_payment()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#### Boolean mode: explicit handling of lock contention:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
locker = PgAdvisoryLock(lock_args=[UserModel.__table__.name, user_id], raise_on_failure=False)
|
|
84
|
+
|
|
85
|
+
with session.begin():
|
|
86
|
+
if locker.lock(session=session, timeout_in_ms=5000):
|
|
87
|
+
process_payment()
|
|
88
|
+
else:
|
|
89
|
+
schedule_retry()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#### Immediate (non-blocking) attempts:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
locker = PgAdvisoryLock(
|
|
97
|
+
lock_args=['payment_processing', order_id],
|
|
98
|
+
raise_on_failure=False,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
with session.begin():
|
|
102
|
+
if locker.lock_immediate(session=session):
|
|
103
|
+
process_payment()
|
|
104
|
+
else:
|
|
105
|
+
schedule_retry()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#### Lock on multiple components:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
locker = PgAdvisoryLock(lock_args=['user_sync', region, user_id])
|
|
113
|
+
|
|
114
|
+
with session.begin():
|
|
115
|
+
locker.lock(session=session)
|
|
116
|
+
synchronize_user()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Transaction behavior
|
|
120
|
+
The library uses transaction-scoped advisory locks. The critical section must remain
|
|
121
|
+
inside the same open transaction in which the lock was acquired. Committing or rolling
|
|
122
|
+
back that transaction releases the lock automatically.
|
|
123
|
+
|
|
124
|
+
When `lock()` reaches its timeout, the failed PostgreSQL statement is isolated within
|
|
125
|
+
a savepoint. After `False` is returned or `PgAdvisoryLockTimeout` is caught, the caller's
|
|
126
|
+
outer transaction remains usable.
|
|
127
|
+
|
|
128
|
+
`lock()` temporarily changes the transaction-local PostgreSQL `lock_timeout`. After a
|
|
129
|
+
successful acquisition or handled timeout, the caller's previous `lock_timeout` value
|
|
130
|
+
is restored.
|
|
131
|
+
|
|
132
|
+
A connection or infrastructure failure cannot be repaired by a savepoint and is
|
|
133
|
+
propagated to the caller.
|
|
134
|
+
|
|
135
|
+
### Lock arguments and key generation
|
|
136
|
+
`lock_args` must be a non-empty list or tuple. A bare string, set, mapping, generator,
|
|
137
|
+
or other iterable is rejected.
|
|
138
|
+
|
|
139
|
+
The arguments are copied to an immutable tuple during construction, so later changes
|
|
140
|
+
to the original list do not affect the lock identity.
|
|
141
|
+
|
|
142
|
+
For a lock with one component, wrap the value in a list or one-element tuple:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
PgAdvisoryLock(lock_args=['payment_processing'])
|
|
146
|
+
PgAdvisoryLock(lock_args=('payment_processing',))
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Each component must be a `str`, `int`, or `uuid.UUID`. Any other type, including `None`, SQLAlchemy
|
|
150
|
+
model instances, model classes, and `Table` objects, raises `PgAdvisoryLockError` at construction.
|
|
151
|
+
|
|
152
|
+
The restriction exists because a value relying on Python's default `repr` stringifies with a memory
|
|
153
|
+
address that differs in every process. Hashing one would derive a key that never matches another
|
|
154
|
+
process, so the lock would appear granted while excluding nobody.
|
|
155
|
+
|
|
156
|
+
Pass the table name explicitly:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
locker = PgAdvisoryLock(lock_args=[UserModel.__table__.name, user.id])
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`__table__.name` avoids duplicating the table name in application code. Note that renaming the table
|
|
163
|
+
changes the derived key, whereas a string literal would not.
|
|
164
|
+
|
|
165
|
+
The normalized strings are serialized as a compact JSON array, preserving component
|
|
166
|
+
boundaries even when values contain separators. The serialized value is hashed with
|
|
167
|
+
SHA-256 and mapped to PostgreSQL's signed 64-bit advisory-lock key range.
|
|
168
|
+
|
|
169
|
+
Normalization is intentionally type-insensitive, so values with the same normalized
|
|
170
|
+
string representation, such as `1` and `'1'`, produce the same lock key.
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
### Thread safety
|
|
174
|
+
- An instance keeps no state after construction and can be shared across sessions and threads.
|
|
175
|
+
- Each thread needs its own `Session`; SQLAlchemy sessions are not thread-safe.
|
|
176
|
+
- A single acquisition belongs to the session and transaction that made it.
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
### Running tests
|
|
182
|
+
Install the project and its development dependencies:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
poetry install
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Start the local PostgreSQL test database and run the test suite:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
docker compose -f docker/compose.yaml up -d
|
|
192
|
+
poetry run pytest
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Tests connect to the following database by default:
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
postgresql+psycopg://postgres:123@localhost:6543/testing
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Override the database URL when using another PostgreSQL instance:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
TEST_DATABASE_URL='postgresql+psycopg://user:password@host:5432/database' \
|
|
205
|
+
poetry run pytest
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Stop the local test database when it is no longer needed:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
docker compose -f docker/compose.yaml down
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
MIT. See [LICENSE](LICENSE).
|
|
218
|
+
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# pg-advisory-lock
|
|
2
|
+
|
|
3
|
+
## Pythonic PostgreSQL advisory locking for SQLAlchemy, with structured keys and explicit contention handling.
|
|
4
|
+
|
|
5
|
+
Provides a high-level interface for acquiring PostgreSQL advisory locks with structured
|
|
6
|
+
lock arguments and timeout support. Advisory locks are application-level locks that are
|
|
7
|
+
automatically released when the transaction commits or rolls back.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Key features:
|
|
11
|
+
- Deterministic lock keys composed from `str`, `int`, and `uuid.UUID` components
|
|
12
|
+
- Non-blocking immediate lock acquisition
|
|
13
|
+
- Blocking lock acquisition with configurable timeout
|
|
14
|
+
- Automatic lock release on transaction end (uses pg_advisory_xact_lock functions)
|
|
15
|
+
- Configurable handling for expected lock contention: raise exceptions (default) or return a boolean
|
|
16
|
+
- Database and infrastructure errors are propagated unchanged
|
|
17
|
+
- Inline type hints shipped with the package (PEP 561)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Failure handling
|
|
21
|
+
`raise_on_failure` controls expected lock contention only:
|
|
22
|
+
|
|
23
|
+
- `raise_on_failure=True` (default):
|
|
24
|
+
- `lock_immediate()` raises `PgAdvisoryLockNotAcquired` when the lock is already held.
|
|
25
|
+
- `lock()` raises `PgAdvisoryLockTimeout` when its timeout expires.
|
|
26
|
+
- `raise_on_failure=False`:
|
|
27
|
+
- Both methods return `False` for expected contention or timeout.
|
|
28
|
+
- Unexpected database and infrastructure errors are always propagated.
|
|
29
|
+
|
|
30
|
+
`raise_on_failure` must be a boolean. Other values raise `PgAdvisoryLockError`.
|
|
31
|
+
|
|
32
|
+
`PgAdvisoryLockTimeout` is a subclass of `PgAdvisoryLockNotAcquired`.
|
|
33
|
+
|
|
34
|
+
Boolean mode discards the originating database error. Use the default exception mode when the
|
|
35
|
+
cause matters: the underlying `DBAPIError` is chained as `__cause__`.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Usage examples:
|
|
39
|
+
Import the public API from the installed package:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pg_advisory_lock import (
|
|
43
|
+
PgAdvisoryLock,
|
|
44
|
+
PgAdvisoryLockError,
|
|
45
|
+
PgAdvisoryLockNotAcquired,
|
|
46
|
+
PgAdvisoryLockTimeout,
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
#### Default behavior: raises exception on failure (safe by default):
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
locker = PgAdvisoryLock(lock_args=[UserModel.__table__.name, user_id])
|
|
54
|
+
|
|
55
|
+
with session.begin():
|
|
56
|
+
locker.lock(session=session)
|
|
57
|
+
process_payment()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#### Boolean mode: explicit handling of lock contention:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
locker = PgAdvisoryLock(lock_args=[UserModel.__table__.name, user_id], raise_on_failure=False)
|
|
65
|
+
|
|
66
|
+
with session.begin():
|
|
67
|
+
if locker.lock(session=session, timeout_in_ms=5000):
|
|
68
|
+
process_payment()
|
|
69
|
+
else:
|
|
70
|
+
schedule_retry()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
#### Immediate (non-blocking) attempts:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
locker = PgAdvisoryLock(
|
|
78
|
+
lock_args=['payment_processing', order_id],
|
|
79
|
+
raise_on_failure=False,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
with session.begin():
|
|
83
|
+
if locker.lock_immediate(session=session):
|
|
84
|
+
process_payment()
|
|
85
|
+
else:
|
|
86
|
+
schedule_retry()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#### Lock on multiple components:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
locker = PgAdvisoryLock(lock_args=['user_sync', region, user_id])
|
|
94
|
+
|
|
95
|
+
with session.begin():
|
|
96
|
+
locker.lock(session=session)
|
|
97
|
+
synchronize_user()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Transaction behavior
|
|
101
|
+
The library uses transaction-scoped advisory locks. The critical section must remain
|
|
102
|
+
inside the same open transaction in which the lock was acquired. Committing or rolling
|
|
103
|
+
back that transaction releases the lock automatically.
|
|
104
|
+
|
|
105
|
+
When `lock()` reaches its timeout, the failed PostgreSQL statement is isolated within
|
|
106
|
+
a savepoint. After `False` is returned or `PgAdvisoryLockTimeout` is caught, the caller's
|
|
107
|
+
outer transaction remains usable.
|
|
108
|
+
|
|
109
|
+
`lock()` temporarily changes the transaction-local PostgreSQL `lock_timeout`. After a
|
|
110
|
+
successful acquisition or handled timeout, the caller's previous `lock_timeout` value
|
|
111
|
+
is restored.
|
|
112
|
+
|
|
113
|
+
A connection or infrastructure failure cannot be repaired by a savepoint and is
|
|
114
|
+
propagated to the caller.
|
|
115
|
+
|
|
116
|
+
### Lock arguments and key generation
|
|
117
|
+
`lock_args` must be a non-empty list or tuple. A bare string, set, mapping, generator,
|
|
118
|
+
or other iterable is rejected.
|
|
119
|
+
|
|
120
|
+
The arguments are copied to an immutable tuple during construction, so later changes
|
|
121
|
+
to the original list do not affect the lock identity.
|
|
122
|
+
|
|
123
|
+
For a lock with one component, wrap the value in a list or one-element tuple:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
PgAdvisoryLock(lock_args=['payment_processing'])
|
|
127
|
+
PgAdvisoryLock(lock_args=('payment_processing',))
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Each component must be a `str`, `int`, or `uuid.UUID`. Any other type, including `None`, SQLAlchemy
|
|
131
|
+
model instances, model classes, and `Table` objects, raises `PgAdvisoryLockError` at construction.
|
|
132
|
+
|
|
133
|
+
The restriction exists because a value relying on Python's default `repr` stringifies with a memory
|
|
134
|
+
address that differs in every process. Hashing one would derive a key that never matches another
|
|
135
|
+
process, so the lock would appear granted while excluding nobody.
|
|
136
|
+
|
|
137
|
+
Pass the table name explicitly:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
locker = PgAdvisoryLock(lock_args=[UserModel.__table__.name, user.id])
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`__table__.name` avoids duplicating the table name in application code. Note that renaming the table
|
|
144
|
+
changes the derived key, whereas a string literal would not.
|
|
145
|
+
|
|
146
|
+
The normalized strings are serialized as a compact JSON array, preserving component
|
|
147
|
+
boundaries even when values contain separators. The serialized value is hashed with
|
|
148
|
+
SHA-256 and mapped to PostgreSQL's signed 64-bit advisory-lock key range.
|
|
149
|
+
|
|
150
|
+
Normalization is intentionally type-insensitive, so values with the same normalized
|
|
151
|
+
string representation, such as `1` and `'1'`, produce the same lock key.
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
### Thread safety
|
|
155
|
+
- An instance keeps no state after construction and can be shared across sessions and threads.
|
|
156
|
+
- Each thread needs its own `Session`; SQLAlchemy sessions are not thread-safe.
|
|
157
|
+
- A single acquisition belongs to the session and transaction that made it.
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
### Running tests
|
|
163
|
+
Install the project and its development dependencies:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
poetry install
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Start the local PostgreSQL test database and run the test suite:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
docker compose -f docker/compose.yaml up -d
|
|
173
|
+
poetry run pytest
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Tests connect to the following database by default:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
postgresql+psycopg://postgres:123@localhost:6543/testing
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Override the database URL when using another PostgreSQL instance:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
TEST_DATABASE_URL='postgresql+psycopg://user:password@host:5432/database' \
|
|
186
|
+
poetry run pytest
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Stop the local test database when it is no longer needed:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
docker compose -f docker/compose.yaml down
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pg-advisory-lock"
|
|
3
|
+
version = "0.0.15"
|
|
4
|
+
description = "Pythonic PostgreSQL advisory locking for SQLAlchemy, with structured keys and explicit contention handling."
|
|
5
|
+
authors = ["Alexander Tryastsyn"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = ["postgresql", "advisory-lock", "sqlalchemy", "locking", "concurrency"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Topic :: Database",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
packages = [{ include = "pg_advisory_lock", from = "src" }]
|
|
17
|
+
|
|
18
|
+
[tool.poetry.dependencies]
|
|
19
|
+
python = "^3.12"
|
|
20
|
+
sqlalchemy = ">=1.4,<3.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[tool.poetry.group.dev.dependencies]
|
|
24
|
+
pytest = "^9.1.1"
|
|
25
|
+
ruff = "^0.16.0"
|
|
26
|
+
psycopg = "^3.3.4"
|
|
27
|
+
mypy = "^2.3.0"
|
|
28
|
+
pytest-cov = "^7.1.0"
|
|
29
|
+
twine = "^7.0.0"
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
line-length = 120
|
|
33
|
+
include = ["src/**/*.py", "tests/**/*.py"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.format]
|
|
36
|
+
quote-style = "single"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = [
|
|
40
|
+
# pycodestyle
|
|
41
|
+
"E",
|
|
42
|
+
# Pyflakes
|
|
43
|
+
"F",
|
|
44
|
+
# pyupgrade
|
|
45
|
+
"UP",
|
|
46
|
+
# flake8-bugbear
|
|
47
|
+
"B",
|
|
48
|
+
# flake8-simplify
|
|
49
|
+
"SIM",
|
|
50
|
+
# isort
|
|
51
|
+
"I",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.mypy]
|
|
55
|
+
files = ['src']
|
|
56
|
+
python_version = '3.12'
|
|
57
|
+
strict = true
|
|
58
|
+
enable_error_code = ['ignore-without-code']
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests",]
|
|
62
|
+
python_files = ["test_*.py", "*_test.py"]
|
|
63
|
+
python_classes = ["Test*"]
|
|
64
|
+
python_functions = ["test_*"]
|
|
65
|
+
addopts = "-v --tb=short --cov=src --cov-report=term-missing --cov-fail-under=90"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
[build-system]
|
|
69
|
+
requires = ["poetry-core"]
|
|
70
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Transaction-scoped PostgreSQL advisory locks for SQLAlchemy sessions."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .exceptions import PgAdvisoryLockError, PgAdvisoryLockNotAcquired, PgAdvisoryLockTimeout
|
|
6
|
+
from .lock import PgAdvisoryLock
|
|
7
|
+
|
|
8
|
+
__all__ = ['PgAdvisoryLock', 'PgAdvisoryLockError', 'PgAdvisoryLockTimeout', 'PgAdvisoryLockNotAcquired']
|
|
9
|
+
|
|
10
|
+
__version__ = version('pg-advisory-lock')
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Public exception hierarchy and classification of PostgreSQL lock-contention errors."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.exc import DBAPIError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PgAdvisoryLockError(Exception):
|
|
7
|
+
"""Base error for every failure raised by this library.
|
|
8
|
+
|
|
9
|
+
Also covers misuse detected before any database call, such as an invalid lock-argument
|
|
10
|
+
container or a non-boolean raise_on_failure.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PgAdvisoryLockNotAcquired(PgAdvisoryLockError):
|
|
15
|
+
"""Raised when the lock is already held and immediate acquisition cannot proceed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PgAdvisoryLockTimeout(PgAdvisoryLockNotAcquired):
|
|
19
|
+
"""Raised when a blocking acquisition exceeds its timeout.
|
|
20
|
+
|
|
21
|
+
Subclasses PgAdvisoryLockNotAcquired so a single except clause catches every contention outcome.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
LOCK_NOT_AVAILABLE_CODE = '55P03'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def lock_is_not_available(error: DBAPIError) -> bool:
|
|
29
|
+
"""Report whether a database error is lock contention rather than a real failure.
|
|
30
|
+
|
|
31
|
+
Only SQLSTATE 55P03 counts as contention. Psycopg 3 exposes it as sqlstate and Psycopg 2 as
|
|
32
|
+
pgcode, so both are probed with sqlstate taking precedence. Anything else, including a driver
|
|
33
|
+
that exposes neither attribute, is treated as unrelated so the caller propagates it.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
if hasattr(error.orig, 'sqlstate'):
|
|
37
|
+
return bool(getattr(error.orig, 'sqlstate', None) == LOCK_NOT_AVAILABLE_CODE)
|
|
38
|
+
elif hasattr(error.orig, 'pgcode'):
|
|
39
|
+
return bool(getattr(error.orig, 'pgcode', None) == LOCK_NOT_AVAILABLE_CODE)
|
|
40
|
+
else:
|
|
41
|
+
return False
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Transaction-scoped PostgreSQL advisory locks acquired through a SQLAlchemy session."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import text
|
|
6
|
+
from sqlalchemy.exc import DBAPIError
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from .exceptions import PgAdvisoryLockError, PgAdvisoryLockNotAcquired, PgAdvisoryLockTimeout, lock_is_not_available
|
|
10
|
+
from .lock_arguments import get_key_from_args, validate
|
|
11
|
+
from .pg_local_setting import set_lock_timeout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PgAdvisoryLock:
|
|
15
|
+
"""Advisory lock addressed by a key derived from structured lock arguments.
|
|
16
|
+
|
|
17
|
+
The key is derived once at construction, so invalid arguments fail before any database call.
|
|
18
|
+
Acquisition is transaction-scoped: PostgreSQL releases the lock when the surrounding
|
|
19
|
+
transaction commits or rolls back, so the critical section must stay inside that transaction.
|
|
20
|
+
|
|
21
|
+
An instance holds no session state and can be reused, but any single acquisition belongs to the
|
|
22
|
+
one session and transaction that made it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, lock_args: list[Any] | tuple[Any, ...], raise_on_failure: bool = True) -> None:
|
|
26
|
+
if not isinstance(raise_on_failure, bool):
|
|
27
|
+
raise PgAdvisoryLockError('Raise_on_failure argument must be a bool.')
|
|
28
|
+
self.raise_on_failure = raise_on_failure
|
|
29
|
+
|
|
30
|
+
validate(lock_args)
|
|
31
|
+
self.lock_args = tuple(lock_args)
|
|
32
|
+
|
|
33
|
+
self.lock_key = get_key_from_args(self.lock_args)
|
|
34
|
+
|
|
35
|
+
def lock_immediate(self, session: Session) -> bool:
|
|
36
|
+
"""Try to acquire the lock without waiting.
|
|
37
|
+
|
|
38
|
+
Contention either raises PgAdvisoryLockNotAcquired or returns False, according to
|
|
39
|
+
raise_on_failure. No lock_timeout is involved, so the call never blocks.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
result = session.execute(
|
|
43
|
+
statement=text('SELECT pg_try_advisory_xact_lock(:key)'),
|
|
44
|
+
params={'key': self.lock_key},
|
|
45
|
+
).scalar()
|
|
46
|
+
if result:
|
|
47
|
+
return True
|
|
48
|
+
else:
|
|
49
|
+
if self.raise_on_failure:
|
|
50
|
+
raise PgAdvisoryLockNotAcquired('Failed to acquire advisory lock.')
|
|
51
|
+
else:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def lock(self, session: Session, timeout_in_ms: int = 1000) -> bool:
|
|
55
|
+
"""Acquire the lock, waiting up to the given timeout.
|
|
56
|
+
|
|
57
|
+
A zero timeout delegates to the non-blocking path. Otherwise the wait runs inside a
|
|
58
|
+
savepoint with lock_timeout applied transaction-locally, so a timeout aborts only that
|
|
59
|
+
savepoint and leaves the caller's transaction usable.
|
|
60
|
+
|
|
61
|
+
Only SQLSTATE 55P03 counts as contention: it raises PgAdvisoryLockTimeout or returns False,
|
|
62
|
+
according to raise_on_failure. Every other database error propagates unchanged. The boolean
|
|
63
|
+
mode discards the originating error, so use the exception mode when the cause matters,
|
|
64
|
+
where it is available as __cause__.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
if not isinstance(timeout_in_ms, int) or timeout_in_ms < 0:
|
|
68
|
+
raise PgAdvisoryLockError('Lock timeout must be a non-negative integer.')
|
|
69
|
+
|
|
70
|
+
if timeout_in_ms == 0:
|
|
71
|
+
return self.lock_immediate(session=session)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with session.begin_nested(), set_lock_timeout(session, f'{timeout_in_ms}ms'):
|
|
75
|
+
session.execute(text('SELECT pg_advisory_xact_lock(:key)'), {'key': self.lock_key})
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
except DBAPIError as exc:
|
|
79
|
+
if not lock_is_not_available(error=exc):
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
elif self.raise_on_failure:
|
|
83
|
+
raise PgAdvisoryLockTimeout(
|
|
84
|
+
f'Failed to acquire advisory lock within {timeout_in_ms} ms.',
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
else:
|
|
88
|
+
return False
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Validation and deterministic key derivation for advisory-lock arguments."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .exceptions import PgAdvisoryLockError
|
|
9
|
+
|
|
10
|
+
LOCK_ARGUMENT_TYPES: tuple[type, ...] = (str, int, uuid.UUID)
|
|
11
|
+
LOCK_ARGUMENT_TYPE_NAMES = ', '.join([_.__name__ for _ in LOCK_ARGUMENT_TYPES])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate(args: Any) -> None:
|
|
15
|
+
"""Enforce the lock-argument contract before a key is derived.
|
|
16
|
+
|
|
17
|
+
Only lists and tuples are accepted. Bare strings, sets, mappings, and generators are rejected
|
|
18
|
+
because they iterate per character, have no stable order, or would be consumed on first use.
|
|
19
|
+
None is rejected because it cannot be told apart from the string 'None'.
|
|
20
|
+
|
|
21
|
+
Components are restricted to str, int, and UUID, whose str() is stable across processes. Anything
|
|
22
|
+
else, including ORM instances, model classes, and Table objects, is rejected rather than hashed: a
|
|
23
|
+
value relying on the default repr embeds a memory address and would derive a different key in every
|
|
24
|
+
process, granting a lock that excludes nobody.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
if not isinstance(args, (list, tuple)):
|
|
28
|
+
raise PgAdvisoryLockError('Lock arguments must be a list or tuple.')
|
|
29
|
+
|
|
30
|
+
if not args:
|
|
31
|
+
raise PgAdvisoryLockError('At least one lock argument is required.')
|
|
32
|
+
|
|
33
|
+
for _arg in args:
|
|
34
|
+
if _arg is None:
|
|
35
|
+
raise PgAdvisoryLockError('Lock argument can not be "None".')
|
|
36
|
+
if not isinstance(_arg, LOCK_ARGUMENT_TYPES):
|
|
37
|
+
raise PgAdvisoryLockError(
|
|
38
|
+
f'Lock argument must be one of "{LOCK_ARGUMENT_TYPE_NAMES}", got "{type(_arg).__name__}" instead.'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize(arg: Any) -> str:
|
|
43
|
+
"""Reduce one lock component to the string used for key derivation.
|
|
44
|
+
|
|
45
|
+
Normalization is deliberately type-insensitive: 1 and '1' yield the same component, so an integer
|
|
46
|
+
primary key and its string form address the same lock.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
return str(arg)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_key_from_args(args: tuple[Any, ...]) -> int:
|
|
53
|
+
"""Build a deterministic signed 64-bit PostgreSQL advisory-lock key.
|
|
54
|
+
|
|
55
|
+
Components are normalized to strings and serialized as a compact JSON array so
|
|
56
|
+
component boundaries remain unambiguous. The first eight bytes of the SHA-256
|
|
57
|
+
digest are mapped to PostgreSQL's signed bigint range.
|
|
58
|
+
|
|
59
|
+
Truncating the digest retains a theoretical 64-bit hash-collision risk. A collision
|
|
60
|
+
only causes unrelated operations to serialize on the same advisory lock.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
serialized = json.dumps(obj=[normalize(_arg) for _arg in args], ensure_ascii=False, separators=(',', ':'))
|
|
64
|
+
digest = hashlib.sha256(serialized.encode('utf-8')).digest()
|
|
65
|
+
raw = int.from_bytes(digest[:8], 'big')
|
|
66
|
+
return raw - (1 << 64) if raw & (1 << 63) else raw
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Helpers for reading and temporarily overriding transaction-local PostgreSQL settings."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import text
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pg_get_local_setting(session: Session, name: str) -> str:
|
|
11
|
+
"""Read the session's effective value for a PostgreSQL run-time setting.
|
|
12
|
+
|
|
13
|
+
PostgreSQL errors on an unrecognized setting name, so the caller must pass one that exists in
|
|
14
|
+
the server configuration.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
result = session.execute(statement=text('SELECT current_setting(:name)'), params={'name': name}).scalar_one()
|
|
18
|
+
return str(result)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def pg_set_local_setting(session: Session, name: str, value: str) -> Generator[None]:
|
|
23
|
+
"""Override a transaction-local PostgreSQL setting for the duration of the block.
|
|
24
|
+
|
|
25
|
+
The override uses SET LOCAL semantics, so it is scoped to the current transaction or savepoint
|
|
26
|
+
and never affects other sessions. On normal exit the captured previous value is written back
|
|
27
|
+
explicitly. If the block raises, that restore is skipped and the caller's transaction or
|
|
28
|
+
savepoint rollback is what undoes the override.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
current_value = pg_get_local_setting(session=session, name=name)
|
|
32
|
+
|
|
33
|
+
session.execute(text('SELECT set_config(:name, :value, TRUE)'), {'name': name, 'value': value})
|
|
34
|
+
|
|
35
|
+
yield
|
|
36
|
+
|
|
37
|
+
session.execute(text('SELECT set_config(:name, :value, TRUE)'), {'name': name, 'value': current_value})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def set_lock_timeout(session: Session, timeout: str) -> AbstractContextManager[None]:
|
|
41
|
+
"""Override lock_timeout for the block, using a PostgreSQL interval string such as '1000ms'."""
|
|
42
|
+
|
|
43
|
+
setting_name = 'lock_timeout'
|
|
44
|
+
return pg_set_local_setting(session=session, name=setting_name, value=timeout)
|
|
File without changes
|