postgres-mutex 0.1.0__py3-none-any.whl

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,320 @@
1
+ """Public ``Mutex`` API: sync and async acquire/release/heartbeat, context managers,
2
+ and the ``@mutex.singleton`` decorator.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import dataclasses
9
+ import functools
10
+ import logging
11
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
12
+ from typing import ParamSpec, TypeVar
13
+
14
+ from ._async import AsyncConnectionPool, AsyncMutexCore
15
+ from ._config import MutexConfig, default_instance_id
16
+ from ._sync import SyncConnectionPool, SyncMutexCore
17
+ from .metrics import MetricsHook
18
+
19
+ logger = logging.getLogger("postgres_mutex")
20
+
21
+ P = ParamSpec("P")
22
+ R = TypeVar("R")
23
+
24
+
25
+ class Mutex:
26
+ """A distributed mutex backed by a single Postgres table.
27
+
28
+ One ``Mutex`` maps to one ``lock_name``. Construct it once and reuse it — sync and
29
+ async operations are both available on the same instance, though a given lock is
30
+ typically driven from one side or the other in a given process.
31
+
32
+ Supply exactly one connection source:
33
+
34
+ - ``dsn`` (the common case): a libpq connection string passed straight to
35
+ psycopg. Mutex opens and owns one dedicated connection per side (sync/async)
36
+ used for it — fine for typical singleton-job / leader-election use, but it's an
37
+ extra always-open connection per ``Mutex`` instance.
38
+ - ``pool`` / ``async_pool``: an existing ``psycopg_pool.ConnectionPool`` /
39
+ ``AsyncConnectionPool`` that your application already manages (e.g. behind
40
+ pgbouncer). Mutex borrows a connection for the duration of each operation
41
+ (acquire, heartbeat, release) and returns it — it never owns or closes the
42
+ pool. Pass both if you use the mutex from both sync and async code.
43
+
44
+ Example::
45
+
46
+ mutex = Mutex(dsn="postgres://...", lock_name="nightly-report-job")
47
+ with mutex.acquire(blocking=False) as acquired:
48
+ if acquired:
49
+ run_the_job()
50
+ else:
51
+ log.info("another instance is running the job")
52
+
53
+ Or, reusing a pool your app already has::
54
+
55
+ mutex = Mutex(lock_name="nightly-report-job", pool=app_pool)
56
+
57
+ Args:
58
+ dsn: libpq connection string / URL passed straight to psycopg. Omit if
59
+ passing ``pool``/``async_pool`` instead.
60
+ lock_name: name of the lock, unique per table. Max 64 characters.
61
+ pool: a ``psycopg_pool.ConnectionPool`` to borrow sync connections from,
62
+ instead of ``dsn``.
63
+ async_pool: a ``psycopg_pool.AsyncConnectionPool`` to borrow async
64
+ connections from, instead of ``dsn``.
65
+ instance_id: identifies this process as the holder. Defaults to
66
+ ``<hostname>-<random>``; set explicitly if you want stable, recognizable
67
+ IDs in logs and alerts (e.g. ``f"{hostname}-pid{os.getpid()}"``).
68
+ table: name of the lock table. Defaults to ``mutex_lock``; override to run
69
+ multiple independent lock tables in the same database.
70
+ heartbeat_interval: seconds between heartbeats while the lock is held.
71
+ alert_threshold: seconds of missed heartbeat before ``on_alert``/metrics fire,
72
+ signalling on-call should investigate a possibly-stuck holder.
73
+ stale_threshold: seconds of missed heartbeat before another instance is
74
+ allowed to reclaim the lock outright, assuming the holder crashed.
75
+ poll_interval: seconds between retries while blocking on ``acquire``.
76
+ metrics: optional ``MetricsHook`` implementation (Prometheus, OTel, etc).
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ dsn: str | None = None,
82
+ lock_name: str = "",
83
+ *,
84
+ pool: SyncConnectionPool | None = None,
85
+ async_pool: AsyncConnectionPool | None = None,
86
+ instance_id: str | None = None,
87
+ table: str = "mutex_lock",
88
+ heartbeat_interval: float = 10.0,
89
+ alert_threshold: float = 30.0,
90
+ stale_threshold: float = 600.0,
91
+ poll_interval: float = 0.5,
92
+ metrics: MetricsHook | None = None,
93
+ ) -> None:
94
+ if dsn is None and pool is None and async_pool is None:
95
+ raise ValueError("Mutex needs a connection source: pass dsn, pool, or async_pool")
96
+ self.dsn = dsn
97
+ self.pool = pool
98
+ self.async_pool = async_pool
99
+ self.config = MutexConfig(
100
+ lock_name=lock_name,
101
+ instance_id=instance_id or default_instance_id(),
102
+ table=table,
103
+ heartbeat_interval=heartbeat_interval,
104
+ alert_threshold=alert_threshold,
105
+ stale_threshold=stale_threshold,
106
+ poll_interval=poll_interval,
107
+ )
108
+ self.metrics = metrics
109
+ self._sync_core: SyncMutexCore | None = None
110
+ self._async_core: AsyncMutexCore | None = None
111
+
112
+ # -- properties -------------------------------------------------
113
+
114
+ @property
115
+ def lock_name(self) -> str:
116
+ return self.config.lock_name
117
+
118
+ @property
119
+ def instance_id(self) -> str:
120
+ return self.config.instance_id
121
+
122
+ @property
123
+ def held(self) -> bool:
124
+ """Best-effort local flag: was the lock acquired and not yet released/lost?
125
+
126
+ This reflects local state, not a fresh read of the database — a heartbeat
127
+ failure updates it asynchronously (within one ``heartbeat_interval``), it
128
+ does not poll on every access.
129
+ """
130
+ if self._sync_core is not None and self._sync_core._held:
131
+ return True
132
+ return bool(self._async_core is not None and self._async_core._held)
133
+
134
+ # -- lazy core accessors -------------------------------------------------
135
+
136
+ def _sync(self) -> SyncMutexCore:
137
+ if self._sync_core is None:
138
+ if self.dsn is None and self.pool is None:
139
+ raise ValueError(
140
+ "this Mutex has no sync connection source configured "
141
+ "(pass dsn or pool) — only async_pool was given"
142
+ )
143
+ self._sync_core = SyncMutexCore(self.config, self.metrics, dsn=self.dsn, pool=self.pool)
144
+ return self._sync_core
145
+
146
+ def _async(self) -> AsyncMutexCore:
147
+ if self._async_core is None:
148
+ if self.dsn is None and self.async_pool is None:
149
+ raise ValueError(
150
+ "this Mutex has no async connection source configured "
151
+ "(pass dsn or async_pool) — only pool was given"
152
+ )
153
+ self._async_core = AsyncMutexCore(
154
+ self.config, self.metrics, dsn=self.dsn, pool=self.async_pool
155
+ )
156
+ return self._async_core
157
+
158
+ # -- schema -------------------------------------------------
159
+
160
+ def create_schema(self) -> None:
161
+ """Create the lock table if it doesn't already exist. Safe to call repeatedly."""
162
+ self._sync().ensure_schema()
163
+
164
+ async def create_schema_async(self) -> None:
165
+ """Async counterpart to ``create_schema``."""
166
+ await self._async().ensure_schema()
167
+
168
+ # -- sync API -------------------------------------------------
169
+
170
+ @contextlib.contextmanager
171
+ def acquire(self, blocking: bool = False, timeout: float | None = None) -> Iterator[bool]:
172
+ """Context manager. Yields ``True`` if the lock was acquired, ``False`` otherwise.
173
+
174
+ With ``blocking=False`` (default), returns immediately either way — check the
175
+ yielded value to decide whether to proceed. With ``blocking=True``, waits
176
+ (polling every ``poll_interval`` seconds) until the lock frees up or
177
+ ``timeout`` elapses, raising ``LockAcquisitionTimeout`` on timeout.
178
+
179
+ The lock is released automatically on exiting the ``with`` block, regardless
180
+ of whether the body raises.
181
+ """
182
+ core = self._sync()
183
+ acquired = core.acquire(blocking=blocking, timeout=timeout)
184
+ try:
185
+ yield acquired
186
+ finally:
187
+ if acquired:
188
+ core.release()
189
+
190
+ def release(self) -> None:
191
+ """Release the lock outright (deletes the row) and stop the heartbeat thread.
192
+
193
+ Prefer the ``acquire()`` context manager, which calls this for you. Raises
194
+ ``LockNotHeldError`` if this instance no longer holds the lock (e.g. it was
195
+ reclaimed after a missed heartbeat).
196
+ """
197
+ self._sync().release()
198
+
199
+ def heartbeat(self) -> None:
200
+ """Manually send one heartbeat. Not usually needed — ``acquire()`` starts a
201
+ background thread that does this automatically. Raises ``LockNotHeldError``
202
+ if the lock was reclaimed by another instance.
203
+ """
204
+ self._sync().heartbeat()
205
+
206
+ def singleton(
207
+ self,
208
+ lock_name: str | None = None,
209
+ *,
210
+ blocking: bool = False,
211
+ timeout: float | None = None,
212
+ ) -> Callable[[Callable[P, R]], Callable[P, R | None]]:
213
+ """Decorator: run the wrapped function only if the lock is acquired.
214
+
215
+ Returns ``None`` (and logs at INFO level) instead of calling the function
216
+ when another instance already holds the lock and ``blocking=False``::
217
+
218
+ @mutex.singleton("nightly-report-job")
219
+ def run_the_job():
220
+ ...
221
+ """
222
+ target = self if lock_name is None else self._sibling(lock_name)
223
+
224
+ def decorator(func: Callable[P, R]) -> Callable[P, R | None]:
225
+ @functools.wraps(func)
226
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
227
+ with target.acquire(blocking=blocking, timeout=timeout) as acquired:
228
+ if not acquired:
229
+ logger.info(
230
+ "postgres_mutex: skipping %s — lock %r held elsewhere",
231
+ func.__name__,
232
+ target.lock_name,
233
+ )
234
+ return None
235
+ return func(*args, **kwargs)
236
+
237
+ return wrapper
238
+
239
+ return decorator
240
+
241
+ # -- async API -------------------------------------------------
242
+
243
+ @contextlib.asynccontextmanager
244
+ async def acquire_async(
245
+ self, blocking: bool = True, timeout: float | None = None
246
+ ) -> AsyncIterator[bool]:
247
+ """Async counterpart to ``acquire()``. Note the different default: ``blocking``
248
+ defaults to ``True`` here, matching the common async usage pattern of awaiting
249
+ a turn rather than polling manually.
250
+ """
251
+ core = self._async()
252
+ acquired = await core.acquire(blocking=blocking, timeout=timeout)
253
+ try:
254
+ yield acquired
255
+ finally:
256
+ if acquired:
257
+ await core.release()
258
+
259
+ async def release_async(self) -> None:
260
+ await self._async().release()
261
+
262
+ async def heartbeat_async(self) -> None:
263
+ await self._async().heartbeat()
264
+
265
+ def singleton_async(
266
+ self,
267
+ lock_name: str | None = None,
268
+ *,
269
+ blocking: bool = True,
270
+ timeout: float | None = None,
271
+ ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R | None]]]:
272
+ """Async counterpart to ``singleton()``."""
273
+ target = self if lock_name is None else self._sibling(lock_name)
274
+
275
+ def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R | None]]:
276
+ @functools.wraps(func)
277
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
278
+ async with target.acquire_async(blocking=blocking, timeout=timeout) as acquired:
279
+ if not acquired:
280
+ logger.info(
281
+ "postgres_mutex: skipping %s — lock %r held elsewhere",
282
+ func.__name__,
283
+ target.lock_name,
284
+ )
285
+ return None
286
+ return await func(*args, **kwargs)
287
+
288
+ return wrapper
289
+
290
+ return decorator
291
+
292
+ # -- lifecycle -------------------------------------------------
293
+
294
+ def close(self) -> None:
295
+ """Close the underlying sync connection. Does not release the lock — call
296
+ ``release()`` first if you want a clean shutdown.
297
+ """
298
+ if self._sync_core is not None:
299
+ self._sync_core.close()
300
+
301
+ async def close_async(self) -> None:
302
+ if self._async_core is not None:
303
+ await self._async_core.close()
304
+
305
+ # -- internals -------------------------------------------------
306
+
307
+ def _sibling(self, lock_name: str) -> Mutex:
308
+ """A new Mutex for a different lock_name, same connection source/config/metrics."""
309
+ overrides = dataclasses.replace(
310
+ self.config, lock_name=lock_name, instance_id=self.config.instance_id
311
+ )
312
+ sibling = Mutex.__new__(Mutex)
313
+ sibling.dsn = self.dsn
314
+ sibling.pool = self.pool
315
+ sibling.async_pool = self.async_pool
316
+ sibling.config = overrides
317
+ sibling.metrics = self.metrics
318
+ sibling._sync_core = None
319
+ sibling._async_core = None
320
+ return sibling
File without changes
@@ -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,13 @@
1
+ postgres_mutex/__init__.py,sha256=Bs-K5CGp-q_q12y7t2D5sBMRex69JAjIpSIRAonvvrU,652
2
+ postgres_mutex/_async.py,sha256=qPlpj1FR4WgzQ4rX-I9tmSIbxsIAVUF2fiXSnISyBWA,11658
3
+ postgres_mutex/_config.py,sha256=J1tEq-01tEcbZAEJHr3z4I8vb8mVHxdDmYev5R_87aQ,2082
4
+ postgres_mutex/_sql.py,sha256=wOI_Fj4Jp_VA7CwronMM0EITd4NhWMPW5JcoDZR-6AM,2574
5
+ postgres_mutex/_sync.py,sha256=diMXziHYExjsW1Yom1F3Ga6rSsnlUQMSdyYUGmZMjlg,11867
6
+ postgres_mutex/exceptions.py,sha256=4oFYvurr0sE14lms4JrfMrDhvuC3LxU1nxf1R9FnWE8,1122
7
+ postgres_mutex/metrics.py,sha256=mwPR5oaxzQ1ZkoG8q_obVLkXFUDp78hjyC6My94EFqU,3099
8
+ postgres_mutex/mutex.py,sha256=CAcNKT4uonn8xsYPFVTVylwqQnwsa00MjEZb7qGwuiI,12923
9
+ postgres_mutex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ postgres_mutex-0.1.0.dist-info/METADATA,sha256=wAk_hGKYAk346d5BIJoVFYwFf-LIxdp2WPyY85nfh4k,11069
11
+ postgres_mutex-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ postgres_mutex-0.1.0.dist-info/licenses/LICENSE,sha256=PqvBoZmFz07eUUbwFsE4I1CFLngmhk2tEzYvaO-eXss,1067
13
+ postgres_mutex-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.