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.
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .pytest_cache/
12
+ .coverage
13
+ htmlcov/
14
+ .env
15
+ .DS_Store
16
+ .idea/
17
+ *.iml
@@ -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
+ ]