postgres-mutex 0.1.0__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.
- postgres_mutex-0.1.0/.gitignore +17 -0
- postgres_mutex-0.1.0/LICENSE +21 -0
- postgres_mutex-0.1.0/PKG-INFO +258 -0
- postgres_mutex-0.1.0/README.md +222 -0
- postgres_mutex-0.1.0/pyproject.toml +77 -0
- postgres_mutex-0.1.0/src/postgres_mutex/__init__.py +22 -0
- postgres_mutex-0.1.0/src/postgres_mutex/_async.py +290 -0
- postgres_mutex-0.1.0/src/postgres_mutex/_config.py +52 -0
- postgres_mutex-0.1.0/src/postgres_mutex/_sql.py +69 -0
- postgres_mutex-0.1.0/src/postgres_mutex/_sync.py +298 -0
- postgres_mutex-0.1.0/src/postgres_mutex/exceptions.py +32 -0
- postgres_mutex-0.1.0/src/postgres_mutex/metrics.py +77 -0
- postgres_mutex-0.1.0/src/postgres_mutex/mutex.py +320 -0
- postgres_mutex-0.1.0/src/postgres_mutex/py.typed +0 -0
- postgres_mutex-0.1.0/tests/conftest.py +58 -0
- postgres_mutex-0.1.0/tests/test_acquire_release.py +84 -0
- postgres_mutex-0.1.0/tests/test_async.py +109 -0
- postgres_mutex-0.1.0/tests/test_clock_skew.py +90 -0
- postgres_mutex-0.1.0/tests/test_connection_sources.py +74 -0
- postgres_mutex-0.1.0/tests/test_contention.py +78 -0
- postgres_mutex-0.1.0/tests/test_crash_recovery.py +132 -0
- postgres_mutex-0.1.0/tests/test_network_partition.py +70 -0
- postgres_mutex-0.1.0/tests/test_singleton_decorator.py +52 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rishi Rana
|
|
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,258 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: postgres-mutex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A distributed mutex for Postgres. One table, heartbeat-based liveness, automatic crash recovery. No ZooKeeper, no Redis.
|
|
5
|
+
Project-URL: Homepage, https://github.com/rishi-rana/postgres-mutex
|
|
6
|
+
Project-URL: Repository, https://github.com/rishi-rana/postgres-mutex
|
|
7
|
+
Project-URL: Issues, https://github.com/rishi-rana/postgres-mutex/issues
|
|
8
|
+
Author: Rishi Rana
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: distributed-lock,leader-election,mutex,postgres,postgresql,scheduling,singleton
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
27
|
+
Requires-Dist: psycopg-pool>=3.1; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
32
|
+
Requires-Dist: testcontainers[postgres]>=4.0; extra == 'dev'
|
|
33
|
+
Provides-Extra: pool
|
|
34
|
+
Requires-Dist: psycopg-pool>=3.1; extra == 'pool'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# postgres-mutex
|
|
38
|
+
|
|
39
|
+
A distributed mutex for Postgres. One table, heartbeat-based liveness, automatic
|
|
40
|
+
recovery from crashed lock holders. No ZooKeeper, no Redis, no Consul, no etcd.
|
|
41
|
+
|
|
42
|
+
If you already have Postgres, you shouldn't need any of those just to run a job on
|
|
43
|
+
exactly one instance at a time.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install postgres-mutex
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from postgres_mutex import Mutex
|
|
53
|
+
|
|
54
|
+
mutex = Mutex(dsn="postgres://user:pass@host/db", lock_name="nightly-report-job")
|
|
55
|
+
mutex.create_schema() # creates the mutex_lock table if it doesn't exist; idempotent
|
|
56
|
+
|
|
57
|
+
with mutex.acquire(blocking=False) as acquired:
|
|
58
|
+
if acquired:
|
|
59
|
+
run_the_job()
|
|
60
|
+
else:
|
|
61
|
+
print("another instance is running the job")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Async works the same way:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
async with mutex.acquire_async(blocking=True, timeout=30):
|
|
68
|
+
await run_the_job_async()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or skip the `if acquired` branch entirely with the decorator:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
@mutex.singleton("nightly-report-job")
|
|
75
|
+
def run_the_job(): ...
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Supplying connection info
|
|
79
|
+
|
|
80
|
+
Two ways to give `Mutex` a way to reach Postgres — pick one:
|
|
81
|
+
|
|
82
|
+
**`dsn`** (the common case) — any libpq connection string, passed straight to
|
|
83
|
+
psycopg untouched. `Mutex` opens and owns one dedicated connection for it.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
Mutex(dsn="postgres://user:pass@host:5432/dbname?sslmode=require", lock_name="job")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Because it's handed to psycopg unmodified, all the usual libpq conventions work:
|
|
90
|
+
keyword form (`"host=localhost dbname=mydb user=me password=secret"`), partial DSNs
|
|
91
|
+
filled in from `PGHOST` / `PGPORT` / `PGUSER` / `PGPASSWORD` / `PGDATABASE` /
|
|
92
|
+
`PGSSLMODE`, and `.pgpass` for passwords you don't want in the DSN at all. Where that
|
|
93
|
+
string comes from — an env var, a secrets manager, a config file — is up to you;
|
|
94
|
+
postgres-mutex just needs a valid one.
|
|
95
|
+
|
|
96
|
+
**`pool` / `async_pool`** — if your app already manages a `psycopg_pool`
|
|
97
|
+
`ConnectionPool` / `AsyncConnectionPool` (e.g. sitting in front of pgbouncer),
|
|
98
|
+
hand it to `Mutex` instead of a `dsn` so it doesn't open an extra always-on
|
|
99
|
+
connection of its own. `Mutex` borrows a connection for the duration of each
|
|
100
|
+
operation (acquire, heartbeat, release) and gives it back — it never owns, opens, or
|
|
101
|
+
closes the pool.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from psycopg_pool import ConnectionPool
|
|
105
|
+
|
|
106
|
+
pool = ConnectionPool("postgres://...") # owned and closed by your app
|
|
107
|
+
mutex = Mutex(lock_name="nightly-report-job", pool=pool)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Pass `async_pool` instead (or as well, if the same lock is driven from both sync and
|
|
111
|
+
async code) for `acquire_async` / `heartbeat_async` / `release_async`. Requires the
|
|
112
|
+
`psycopg-pool` package (`pip install postgres-mutex[pool]`).
|
|
113
|
+
|
|
114
|
+
## How it works
|
|
115
|
+
|
|
116
|
+
One table:
|
|
117
|
+
|
|
118
|
+
```sql
|
|
119
|
+
CREATE TABLE mutex_lock (
|
|
120
|
+
lock_name VARCHAR(64) NOT NULL,
|
|
121
|
+
locked INT NOT NULL DEFAULT 1,
|
|
122
|
+
instance_id VARCHAR(64) NOT NULL,
|
|
123
|
+
last_heartbeat TIMESTAMPTZ NOT NULL,
|
|
124
|
+
acquired_at TIMESTAMPTZ NOT NULL,
|
|
125
|
+
CONSTRAINT uq_mutex_lock UNIQUE (locked, lock_name),
|
|
126
|
+
CONSTRAINT chk_locked CHECK (locked = 1)
|
|
127
|
+
);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`UNIQUE(locked, lock_name)` combined with `CHECK (locked = 1)` means only one row can
|
|
131
|
+
ever exist per `lock_name`. Acquiring is just "try to insert a row" — whoever's
|
|
132
|
+
`INSERT` lands first wins, enforced by the database itself. No CAS loop, no advisory
|
|
133
|
+
locks, no race conditions to reason about.
|
|
134
|
+
|
|
135
|
+
The lock holder heartbeats every 10 seconds (`UPDATE ... SET last_heartbeat = now()`).
|
|
136
|
+
Every field used for staleness detection — `now()`, the interval comparisons — is
|
|
137
|
+
computed **inside a single Postgres statement**, so results depend only on the
|
|
138
|
+
Postgres server's clock, never on any client's wall clock. Skewed or jumping client
|
|
139
|
+
clocks can't corrupt lock state.
|
|
140
|
+
|
|
141
|
+
### Dual-threshold stale handling
|
|
142
|
+
|
|
143
|
+
Most distributed-lock tutorials use one timeout. That's the wrong call:
|
|
144
|
+
|
|
145
|
+
- **30 seconds without a heartbeat** → an alert fires (via your metrics hook /
|
|
146
|
+
`on_alert` callback) so on-call can look — is the holder just slow, or actually dead?
|
|
147
|
+
- **10 minutes without a heartbeat** → the lock auto-releases; safe to assume the
|
|
148
|
+
holder crashed.
|
|
149
|
+
|
|
150
|
+
If you release at 30 seconds, a holder that's merely slow (GC pause, a long query, a
|
|
151
|
+
noisy neighbor) loses the lock to another instance that's likely to hit the exact same
|
|
152
|
+
slowness — you can end up in a churn loop where nobody makes progress. The alert
|
|
153
|
+
window buys a human time to intervene before the system self-heals on its own.
|
|
154
|
+
|
|
155
|
+
### Clean shutdown
|
|
156
|
+
|
|
157
|
+
On graceful shutdown, `release()` deletes the row outright, so the next instance can
|
|
158
|
+
acquire immediately instead of waiting out the stale threshold.
|
|
159
|
+
|
|
160
|
+
## API
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
Mutex(
|
|
164
|
+
dsn: str | None = None, # or pass pool / async_pool instead
|
|
165
|
+
lock_name: str,
|
|
166
|
+
*,
|
|
167
|
+
pool: psycopg_pool.ConnectionPool | None = None,
|
|
168
|
+
async_pool: psycopg_pool.AsyncConnectionPool | None = None,
|
|
169
|
+
instance_id: str | None = None, # default: "<hostname>-<random>"
|
|
170
|
+
table: str = "mutex_lock",
|
|
171
|
+
heartbeat_interval: float = 10.0,
|
|
172
|
+
alert_threshold: float = 30.0,
|
|
173
|
+
stale_threshold: float = 600.0,
|
|
174
|
+
poll_interval: float = 0.5, # blocking-acquire retry interval
|
|
175
|
+
metrics: MetricsHook | None = None,
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- `mutex.acquire(blocking=False, timeout=None)` — sync context manager, yields `bool`
|
|
180
|
+
- `mutex.acquire_async(blocking=True, timeout=None)` — async context manager
|
|
181
|
+
- `mutex.release()` / `mutex.release_async()`
|
|
182
|
+
- `mutex.heartbeat()` / `mutex.heartbeat_async()` — manual heartbeat (usually automatic)
|
|
183
|
+
- `mutex.singleton(lock_name=None, *, blocking=False, timeout=None)` — decorator
|
|
184
|
+
- `mutex.singleton_async(...)` — async decorator
|
|
185
|
+
- `mutex.create_schema()` / `mutex.create_schema_async()` — idempotent `CREATE TABLE IF NOT EXISTS`
|
|
186
|
+
|
|
187
|
+
### Metrics
|
|
188
|
+
|
|
189
|
+
Implement whichever subset of `MetricsHook` you care about (Prometheus, OpenTelemetry,
|
|
190
|
+
statsd, or your own):
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
class PrometheusMetrics:
|
|
194
|
+
def acquired(self, lock_name, *, instance_id): ...
|
|
195
|
+
def released(self, lock_name, *, instance_id): ...
|
|
196
|
+
def contended(self, lock_name, *, instance_id): ...
|
|
197
|
+
def stale_reclaimed(self, lock_name, *, instance_id, previous_holder): ...
|
|
198
|
+
def alert(self, lock_name, *, holder, seconds_since_heartbeat): ...
|
|
199
|
+
def heartbeat_latency(self, lock_name, *, instance_id, seconds): ...
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
mutex = Mutex(dsn, "nightly-report-job", metrics=PrometheusMetrics())
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A broken metrics implementation can never break the lock — every call is wrapped in a
|
|
206
|
+
best-effort try/except.
|
|
207
|
+
|
|
208
|
+
## Why not X?
|
|
209
|
+
|
|
210
|
+
| | Extra infra? | Survives a crash cleanly? | Blocks other queries? | Notes |
|
|
211
|
+
|---|---|---|---|---|
|
|
212
|
+
| **postgres-mutex** | No — uses Postgres you already have | Yes — heartbeat + dual-threshold auto-release | No | Single table, ~one round trip per acquire |
|
|
213
|
+
| **Redis Redlock** | Yes — Redis (ideally 5 independent nodes) | Depends on TTL tuning | No | Correctness has been [actively debated](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) for multi-node deployments |
|
|
214
|
+
| **ShedLock** | No extra infra, but adds a dependency + its own table conventions | Yes, similar TTL model | No | Good option if you're already on it; postgres-mutex is a smaller, dependency-light alternative |
|
|
215
|
+
| **`SELECT ... FOR UPDATE`** | No | No — a crashed holder's transaction rollback releases it, but a hung connection holds the lock indefinitely and blocks readers | Yes — blocks queries waiting on the row/table | Simplest option for short critical sections inside one transaction; not a good fit for "hold across a long job" |
|
|
216
|
+
| **ZooKeeper / Consul / etcd** | Yes — a whole coordination service to run and operate | Yes — ephemeral nodes / sessions | No | Correct and battle-tested, but a lot of infrastructure for "run this job on one instance" |
|
|
217
|
+
|
|
218
|
+
## Failure modes, explicitly
|
|
219
|
+
|
|
220
|
+
- **Holder crashes**: heartbeat stops. Alert fires at `alert_threshold` (default 30s).
|
|
221
|
+
Lock is reclaimable by any other instance at `stale_threshold` (default 600s).
|
|
222
|
+
- **Holder is just slow** (GC pause, long query): same alert fires at 30s — that's the
|
|
223
|
+
point, so a human can tell "slow" from "dead" before the system takes action.
|
|
224
|
+
- **Network partition** (holder alive, can't reach Postgres): heartbeats fail
|
|
225
|
+
silently and retry; from every other instance's point of view this is
|
|
226
|
+
indistinguishable from a crash, so the same dual-threshold logic applies. If the
|
|
227
|
+
partition heals before `stale_threshold`, the original holder keeps the lock. If not,
|
|
228
|
+
another instance reclaims it, and the original holder's next heartbeat raises
|
|
229
|
+
`LockNotHeldError` — write your job logic to check for that on long-running work if
|
|
230
|
+
split-brain during the alert window is unacceptable for your use case.
|
|
231
|
+
- **Clock skew between instances**: irrelevant. Every staleness check runs `now() -
|
|
232
|
+
last_heartbeat` inside one Postgres statement — only the Postgres server's clock is
|
|
233
|
+
ever consulted.
|
|
234
|
+
- **`instance_id` reused across two live processes**: don't do this — release/heartbeat
|
|
235
|
+
scoping is by `(lock_name, instance_id)`, so two processes sharing an `instance_id`
|
|
236
|
+
can each mutate the other's lock state. The default (`hostname-<random>`) avoids
|
|
237
|
+
this; set your own only if it's genuinely unique per process.
|
|
238
|
+
|
|
239
|
+
## What's not in v1
|
|
240
|
+
|
|
241
|
+
- No fencing tokens
|
|
242
|
+
- No read/write locks — single mutex only
|
|
243
|
+
- No lock hierarchies
|
|
244
|
+
- No cross-database coordination
|
|
245
|
+
- Postgres only (MySQL/SQLite not planned for v1)
|
|
246
|
+
|
|
247
|
+
## Development
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
pip install -e ".[dev]"
|
|
251
|
+
pytest # spins up real Postgres via testcontainers — needs Docker
|
|
252
|
+
ruff check .
|
|
253
|
+
mypy
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# postgres-mutex
|
|
2
|
+
|
|
3
|
+
A distributed mutex for Postgres. One table, heartbeat-based liveness, automatic
|
|
4
|
+
recovery from crashed lock holders. No ZooKeeper, no Redis, no Consul, no etcd.
|
|
5
|
+
|
|
6
|
+
If you already have Postgres, you shouldn't need any of those just to run a job on
|
|
7
|
+
exactly one instance at a time.
|
|
8
|
+
|
|
9
|
+
## Quickstart
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install postgres-mutex
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from postgres_mutex import Mutex
|
|
17
|
+
|
|
18
|
+
mutex = Mutex(dsn="postgres://user:pass@host/db", lock_name="nightly-report-job")
|
|
19
|
+
mutex.create_schema() # creates the mutex_lock table if it doesn't exist; idempotent
|
|
20
|
+
|
|
21
|
+
with mutex.acquire(blocking=False) as acquired:
|
|
22
|
+
if acquired:
|
|
23
|
+
run_the_job()
|
|
24
|
+
else:
|
|
25
|
+
print("another instance is running the job")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Async works the same way:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
async with mutex.acquire_async(blocking=True, timeout=30):
|
|
32
|
+
await run_the_job_async()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or skip the `if acquired` branch entirely with the decorator:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
@mutex.singleton("nightly-report-job")
|
|
39
|
+
def run_the_job(): ...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Supplying connection info
|
|
43
|
+
|
|
44
|
+
Two ways to give `Mutex` a way to reach Postgres — pick one:
|
|
45
|
+
|
|
46
|
+
**`dsn`** (the common case) — any libpq connection string, passed straight to
|
|
47
|
+
psycopg untouched. `Mutex` opens and owns one dedicated connection for it.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
Mutex(dsn="postgres://user:pass@host:5432/dbname?sslmode=require", lock_name="job")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Because it's handed to psycopg unmodified, all the usual libpq conventions work:
|
|
54
|
+
keyword form (`"host=localhost dbname=mydb user=me password=secret"`), partial DSNs
|
|
55
|
+
filled in from `PGHOST` / `PGPORT` / `PGUSER` / `PGPASSWORD` / `PGDATABASE` /
|
|
56
|
+
`PGSSLMODE`, and `.pgpass` for passwords you don't want in the DSN at all. Where that
|
|
57
|
+
string comes from — an env var, a secrets manager, a config file — is up to you;
|
|
58
|
+
postgres-mutex just needs a valid one.
|
|
59
|
+
|
|
60
|
+
**`pool` / `async_pool`** — if your app already manages a `psycopg_pool`
|
|
61
|
+
`ConnectionPool` / `AsyncConnectionPool` (e.g. sitting in front of pgbouncer),
|
|
62
|
+
hand it to `Mutex` instead of a `dsn` so it doesn't open an extra always-on
|
|
63
|
+
connection of its own. `Mutex` borrows a connection for the duration of each
|
|
64
|
+
operation (acquire, heartbeat, release) and gives it back — it never owns, opens, or
|
|
65
|
+
closes the pool.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from psycopg_pool import ConnectionPool
|
|
69
|
+
|
|
70
|
+
pool = ConnectionPool("postgres://...") # owned and closed by your app
|
|
71
|
+
mutex = Mutex(lock_name="nightly-report-job", pool=pool)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Pass `async_pool` instead (or as well, if the same lock is driven from both sync and
|
|
75
|
+
async code) for `acquire_async` / `heartbeat_async` / `release_async`. Requires the
|
|
76
|
+
`psycopg-pool` package (`pip install postgres-mutex[pool]`).
|
|
77
|
+
|
|
78
|
+
## How it works
|
|
79
|
+
|
|
80
|
+
One table:
|
|
81
|
+
|
|
82
|
+
```sql
|
|
83
|
+
CREATE TABLE mutex_lock (
|
|
84
|
+
lock_name VARCHAR(64) NOT NULL,
|
|
85
|
+
locked INT NOT NULL DEFAULT 1,
|
|
86
|
+
instance_id VARCHAR(64) NOT NULL,
|
|
87
|
+
last_heartbeat TIMESTAMPTZ NOT NULL,
|
|
88
|
+
acquired_at TIMESTAMPTZ NOT NULL,
|
|
89
|
+
CONSTRAINT uq_mutex_lock UNIQUE (locked, lock_name),
|
|
90
|
+
CONSTRAINT chk_locked CHECK (locked = 1)
|
|
91
|
+
);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`UNIQUE(locked, lock_name)` combined with `CHECK (locked = 1)` means only one row can
|
|
95
|
+
ever exist per `lock_name`. Acquiring is just "try to insert a row" — whoever's
|
|
96
|
+
`INSERT` lands first wins, enforced by the database itself. No CAS loop, no advisory
|
|
97
|
+
locks, no race conditions to reason about.
|
|
98
|
+
|
|
99
|
+
The lock holder heartbeats every 10 seconds (`UPDATE ... SET last_heartbeat = now()`).
|
|
100
|
+
Every field used for staleness detection — `now()`, the interval comparisons — is
|
|
101
|
+
computed **inside a single Postgres statement**, so results depend only on the
|
|
102
|
+
Postgres server's clock, never on any client's wall clock. Skewed or jumping client
|
|
103
|
+
clocks can't corrupt lock state.
|
|
104
|
+
|
|
105
|
+
### Dual-threshold stale handling
|
|
106
|
+
|
|
107
|
+
Most distributed-lock tutorials use one timeout. That's the wrong call:
|
|
108
|
+
|
|
109
|
+
- **30 seconds without a heartbeat** → an alert fires (via your metrics hook /
|
|
110
|
+
`on_alert` callback) so on-call can look — is the holder just slow, or actually dead?
|
|
111
|
+
- **10 minutes without a heartbeat** → the lock auto-releases; safe to assume the
|
|
112
|
+
holder crashed.
|
|
113
|
+
|
|
114
|
+
If you release at 30 seconds, a holder that's merely slow (GC pause, a long query, a
|
|
115
|
+
noisy neighbor) loses the lock to another instance that's likely to hit the exact same
|
|
116
|
+
slowness — you can end up in a churn loop where nobody makes progress. The alert
|
|
117
|
+
window buys a human time to intervene before the system self-heals on its own.
|
|
118
|
+
|
|
119
|
+
### Clean shutdown
|
|
120
|
+
|
|
121
|
+
On graceful shutdown, `release()` deletes the row outright, so the next instance can
|
|
122
|
+
acquire immediately instead of waiting out the stale threshold.
|
|
123
|
+
|
|
124
|
+
## API
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
Mutex(
|
|
128
|
+
dsn: str | None = None, # or pass pool / async_pool instead
|
|
129
|
+
lock_name: str,
|
|
130
|
+
*,
|
|
131
|
+
pool: psycopg_pool.ConnectionPool | None = None,
|
|
132
|
+
async_pool: psycopg_pool.AsyncConnectionPool | None = None,
|
|
133
|
+
instance_id: str | None = None, # default: "<hostname>-<random>"
|
|
134
|
+
table: str = "mutex_lock",
|
|
135
|
+
heartbeat_interval: float = 10.0,
|
|
136
|
+
alert_threshold: float = 30.0,
|
|
137
|
+
stale_threshold: float = 600.0,
|
|
138
|
+
poll_interval: float = 0.5, # blocking-acquire retry interval
|
|
139
|
+
metrics: MetricsHook | None = None,
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `mutex.acquire(blocking=False, timeout=None)` — sync context manager, yields `bool`
|
|
144
|
+
- `mutex.acquire_async(blocking=True, timeout=None)` — async context manager
|
|
145
|
+
- `mutex.release()` / `mutex.release_async()`
|
|
146
|
+
- `mutex.heartbeat()` / `mutex.heartbeat_async()` — manual heartbeat (usually automatic)
|
|
147
|
+
- `mutex.singleton(lock_name=None, *, blocking=False, timeout=None)` — decorator
|
|
148
|
+
- `mutex.singleton_async(...)` — async decorator
|
|
149
|
+
- `mutex.create_schema()` / `mutex.create_schema_async()` — idempotent `CREATE TABLE IF NOT EXISTS`
|
|
150
|
+
|
|
151
|
+
### Metrics
|
|
152
|
+
|
|
153
|
+
Implement whichever subset of `MetricsHook` you care about (Prometheus, OpenTelemetry,
|
|
154
|
+
statsd, or your own):
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
class PrometheusMetrics:
|
|
158
|
+
def acquired(self, lock_name, *, instance_id): ...
|
|
159
|
+
def released(self, lock_name, *, instance_id): ...
|
|
160
|
+
def contended(self, lock_name, *, instance_id): ...
|
|
161
|
+
def stale_reclaimed(self, lock_name, *, instance_id, previous_holder): ...
|
|
162
|
+
def alert(self, lock_name, *, holder, seconds_since_heartbeat): ...
|
|
163
|
+
def heartbeat_latency(self, lock_name, *, instance_id, seconds): ...
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
mutex = Mutex(dsn, "nightly-report-job", metrics=PrometheusMetrics())
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
A broken metrics implementation can never break the lock — every call is wrapped in a
|
|
170
|
+
best-effort try/except.
|
|
171
|
+
|
|
172
|
+
## Why not X?
|
|
173
|
+
|
|
174
|
+
| | Extra infra? | Survives a crash cleanly? | Blocks other queries? | Notes |
|
|
175
|
+
|---|---|---|---|---|
|
|
176
|
+
| **postgres-mutex** | No — uses Postgres you already have | Yes — heartbeat + dual-threshold auto-release | No | Single table, ~one round trip per acquire |
|
|
177
|
+
| **Redis Redlock** | Yes — Redis (ideally 5 independent nodes) | Depends on TTL tuning | No | Correctness has been [actively debated](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) for multi-node deployments |
|
|
178
|
+
| **ShedLock** | No extra infra, but adds a dependency + its own table conventions | Yes, similar TTL model | No | Good option if you're already on it; postgres-mutex is a smaller, dependency-light alternative |
|
|
179
|
+
| **`SELECT ... FOR UPDATE`** | No | No — a crashed holder's transaction rollback releases it, but a hung connection holds the lock indefinitely and blocks readers | Yes — blocks queries waiting on the row/table | Simplest option for short critical sections inside one transaction; not a good fit for "hold across a long job" |
|
|
180
|
+
| **ZooKeeper / Consul / etcd** | Yes — a whole coordination service to run and operate | Yes — ephemeral nodes / sessions | No | Correct and battle-tested, but a lot of infrastructure for "run this job on one instance" |
|
|
181
|
+
|
|
182
|
+
## Failure modes, explicitly
|
|
183
|
+
|
|
184
|
+
- **Holder crashes**: heartbeat stops. Alert fires at `alert_threshold` (default 30s).
|
|
185
|
+
Lock is reclaimable by any other instance at `stale_threshold` (default 600s).
|
|
186
|
+
- **Holder is just slow** (GC pause, long query): same alert fires at 30s — that's the
|
|
187
|
+
point, so a human can tell "slow" from "dead" before the system takes action.
|
|
188
|
+
- **Network partition** (holder alive, can't reach Postgres): heartbeats fail
|
|
189
|
+
silently and retry; from every other instance's point of view this is
|
|
190
|
+
indistinguishable from a crash, so the same dual-threshold logic applies. If the
|
|
191
|
+
partition heals before `stale_threshold`, the original holder keeps the lock. If not,
|
|
192
|
+
another instance reclaims it, and the original holder's next heartbeat raises
|
|
193
|
+
`LockNotHeldError` — write your job logic to check for that on long-running work if
|
|
194
|
+
split-brain during the alert window is unacceptable for your use case.
|
|
195
|
+
- **Clock skew between instances**: irrelevant. Every staleness check runs `now() -
|
|
196
|
+
last_heartbeat` inside one Postgres statement — only the Postgres server's clock is
|
|
197
|
+
ever consulted.
|
|
198
|
+
- **`instance_id` reused across two live processes**: don't do this — release/heartbeat
|
|
199
|
+
scoping is by `(lock_name, instance_id)`, so two processes sharing an `instance_id`
|
|
200
|
+
can each mutate the other's lock state. The default (`hostname-<random>`) avoids
|
|
201
|
+
this; set your own only if it's genuinely unique per process.
|
|
202
|
+
|
|
203
|
+
## What's not in v1
|
|
204
|
+
|
|
205
|
+
- No fencing tokens
|
|
206
|
+
- No read/write locks — single mutex only
|
|
207
|
+
- No lock hierarchies
|
|
208
|
+
- No cross-database coordination
|
|
209
|
+
- Postgres only (MySQL/SQLite not planned for v1)
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
pip install -e ".[dev]"
|
|
215
|
+
pytest # spins up real Postgres via testcontainers — needs Docker
|
|
216
|
+
ruff check .
|
|
217
|
+
mypy
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "postgres-mutex"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A distributed mutex for Postgres. One table, heartbeat-based liveness, automatic crash recovery. No ZooKeeper, no Redis."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Rishi Rana" }]
|
|
13
|
+
keywords = ["postgres", "postgresql", "distributed-lock", "mutex", "leader-election", "singleton", "scheduling"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Database",
|
|
24
|
+
"Topic :: System :: Distributed Computing",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["psycopg[binary]>=3.1"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
pool = ["psycopg-pool>=3.1"]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-asyncio>=0.24",
|
|
34
|
+
"pytest-cov>=5.0",
|
|
35
|
+
"testcontainers[postgres]>=4.0",
|
|
36
|
+
"psycopg-pool>=3.1",
|
|
37
|
+
"mypy>=1.10",
|
|
38
|
+
"ruff>=0.6",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/rishi-rana/postgres-mutex"
|
|
43
|
+
Repository = "https://github.com/rishi-rana/postgres-mutex"
|
|
44
|
+
Issues = "https://github.com/rishi-rana/postgres-mutex/issues"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/postgres_mutex"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = [
|
|
51
|
+
"/src",
|
|
52
|
+
"/tests",
|
|
53
|
+
"/README.md",
|
|
54
|
+
"/LICENSE",
|
|
55
|
+
"/pyproject.toml",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
strict = true
|
|
60
|
+
files = ["src/postgres_mutex"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
line-length = 100
|
|
64
|
+
target-version = "py310"
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
asyncio_mode = "auto"
|
|
71
|
+
testpaths = ["tests"]
|
|
72
|
+
|
|
73
|
+
[tool.coverage.run]
|
|
74
|
+
source = ["src/postgres_mutex"]
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
fail_under = 90
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""postgres_mutex: a zero-dependency distributed mutex for Postgres.
|
|
2
|
+
|
|
3
|
+
One table, heartbeat-based liveness, automatic recovery from crashed lock holders.
|
|
4
|
+
No ZooKeeper, no Redis, no Consul, no etcd — if you already have Postgres, you
|
|
5
|
+
shouldn't need any of those just to run a job on exactly one instance at a time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .exceptions import LockAcquisitionTimeout, LockNotHeldError, PgMutexError
|
|
9
|
+
from .metrics import MetricsHook, NullMetrics
|
|
10
|
+
from .mutex import Mutex
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Mutex",
|
|
16
|
+
"PgMutexError",
|
|
17
|
+
"LockAcquisitionTimeout",
|
|
18
|
+
"LockNotHeldError",
|
|
19
|
+
"MetricsHook",
|
|
20
|
+
"NullMetrics",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|