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,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
+ ]
@@ -0,0 +1,290 @@
1
+ """Async implementation: mirrors _sync.py but over psycopg's AsyncConnection and asyncio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import time
9
+ from collections.abc import AsyncIterator
10
+ from typing import Any, Protocol
11
+
12
+ import psycopg
13
+ from psycopg import sql
14
+
15
+ from . import _sql
16
+ from ._config import MutexConfig
17
+ from .exceptions import LockAcquisitionTimeout, LockNotHeldError
18
+ from .metrics import MetricsHook, NullMetrics, safe_call
19
+
20
+ logger = logging.getLogger("postgres_mutex")
21
+
22
+
23
+ class AsyncConnectionPool(Protocol):
24
+ """Shape of ``psycopg_pool.AsyncConnectionPool`` — matched structurally so
25
+ postgres_mutex doesn't need psycopg-pool as a hard dependency.
26
+ """
27
+
28
+ def connection(self, timeout: float | None = None) -> Any: ... # async context manager
29
+
30
+
31
+ class AsyncMutexCore:
32
+ """Async counterpart to ``SyncMutexCore``.
33
+
34
+ Either owns one dedicated connection (``dsn`` mode) or borrows short-lived
35
+ connections from a caller-supplied pool (``pool`` mode) — see ``Mutex.__init__``.
36
+ In ``dsn`` mode, a single asyncio task holds the connection and both issues
37
+ heartbeats and serves ad hoc queries (acquire/release); the asyncio.Lock below
38
+ just prevents two coroutines from interleaving queries on that one connection.
39
+ In ``pool`` mode the lock is unnecessary (each borrow gets its own connection)
40
+ but harmless, so the same code path is used for both.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ config: MutexConfig,
46
+ metrics: MetricsHook | None,
47
+ *,
48
+ dsn: str | None = None,
49
+ pool: AsyncConnectionPool | None = None,
50
+ ) -> None:
51
+ self.dsn = dsn
52
+ self.pool = pool
53
+ self.config = config
54
+ self.metrics = metrics or NullMetrics()
55
+ self._conn: psycopg.AsyncConnection[Any] | None = None
56
+ self._db_lock = asyncio.Lock()
57
+ self._heartbeat_task: asyncio.Task[None] | None = None
58
+ self._held = False
59
+ self._lost = asyncio.Event()
60
+ self._last_alert_at = 0.0
61
+
62
+ async def _connection(self) -> psycopg.AsyncConnection[Any]:
63
+ """The dedicated dsn-mode connection, opened lazily. Not used in pool mode."""
64
+ if self._conn is None or self._conn.closed:
65
+ assert self.dsn is not None
66
+ self._conn = await psycopg.AsyncConnection.connect(self.dsn, autocommit=True)
67
+ return self._conn
68
+
69
+ @contextlib.asynccontextmanager
70
+ async def _borrow(self) -> AsyncIterator[psycopg.AsyncConnection[Any]]:
71
+ """Yield a connection to run one operation against.
72
+
73
+ dsn mode: yields the long-lived dedicated connection, unchanged after use.
74
+ pool mode: borrows one from the pool and returns it on exit — postgres_mutex never
75
+ owns or closes a caller-supplied pool.
76
+ """
77
+ if self.pool is not None:
78
+ async with self.pool.connection() as conn:
79
+ yield conn
80
+ else:
81
+ yield await self._connection()
82
+
83
+ async def ensure_schema(self) -> None:
84
+ query = sql.SQL(_sql.CREATE_TABLE).format(
85
+ table=sql.Identifier(self.config.table),
86
+ table_uq=sql.Identifier(f"{self.config.table}_uq"),
87
+ table_chk=sql.Identifier(f"{self.config.table}_chk_locked"),
88
+ )
89
+ async with self._db_lock, self._borrow() as conn, conn.cursor() as cur:
90
+ await cur.execute(query)
91
+
92
+ async def close(self) -> None:
93
+ """Close the dedicated dsn-mode connection, if one was opened. In pool mode,
94
+ this is a no-op — the pool belongs to the caller.
95
+ """
96
+ await self.stop_heartbeat()
97
+ async with self._db_lock:
98
+ if self._conn is not None and not self._conn.closed:
99
+ await self._conn.close()
100
+
101
+ async def try_acquire(self) -> bool:
102
+ q_select = sql.SQL(_sql.SELECT_FOR_ACQUIRE).format(table=sql.Identifier(self.config.table))
103
+ q_delete = sql.SQL(_sql.DELETE_ROW).format(table=sql.Identifier(self.config.table))
104
+ q_insert = sql.SQL(_sql.INSERT_ROW).format(table=sql.Identifier(self.config.table))
105
+ lock_params = {"lock_name": self.config.lock_name}
106
+ insert_params = {"lock_name": self.config.lock_name, "instance_id": self.config.instance_id}
107
+
108
+ async with self._db_lock, self._borrow() as conn:
109
+ try:
110
+ async with conn.transaction(), conn.cursor() as cur:
111
+ await cur.execute(q_select, lock_params)
112
+ row = await cur.fetchone()
113
+
114
+ if row is None:
115
+ await cur.execute(q_insert, insert_params)
116
+ acquired, reclaimed_from, holder, seconds_since = True, None, None, None
117
+ else:
118
+ holder, seconds_since = row
119
+ if (
120
+ seconds_since is not None
121
+ and seconds_since >= self.config.stale_threshold
122
+ ):
123
+ await cur.execute(q_delete, lock_params)
124
+ await cur.execute(q_insert, insert_params)
125
+ acquired, reclaimed_from = True, holder
126
+ else:
127
+ acquired, reclaimed_from = False, None
128
+ except psycopg.errors.UniqueViolation:
129
+ # See SyncMutexCore.try_acquire: SELECT ... FOR UPDATE can't serialize
130
+ # two contenders racing to INSERT a lock row that doesn't exist yet.
131
+ # The UNIQUE constraint still guarantees exactly one wins; the loser
132
+ # just lost a race, not an error.
133
+ acquired, reclaimed_from, holder, seconds_since = False, None, None, None
134
+
135
+ if not acquired:
136
+ safe_call(
137
+ self.metrics,
138
+ "contended",
139
+ lock_name=self.config.lock_name,
140
+ instance_id=self.config.instance_id,
141
+ )
142
+ if holder is not None and seconds_since is not None: # noqa: SIM102 (mypy narrowing)
143
+ if seconds_since >= self.config.alert_threshold:
144
+ self._fire_alert(holder, seconds_since)
145
+ return False
146
+
147
+ self._held = True
148
+ self._lost = asyncio.Event()
149
+ if reclaimed_from is not None:
150
+ safe_call(
151
+ self.metrics,
152
+ "stale_reclaimed",
153
+ lock_name=self.config.lock_name,
154
+ instance_id=self.config.instance_id,
155
+ previous_holder=reclaimed_from,
156
+ )
157
+ logger.warning(
158
+ "postgres_mutex: reclaimed lock %r from stale holder %r (instance %r)",
159
+ self.config.lock_name,
160
+ reclaimed_from,
161
+ self.config.instance_id,
162
+ )
163
+ safe_call(
164
+ self.metrics,
165
+ "acquired",
166
+ lock_name=self.config.lock_name,
167
+ instance_id=self.config.instance_id,
168
+ )
169
+ return True
170
+
171
+ def _fire_alert(self, holder: str, seconds_since: float) -> None:
172
+ """Page on-call: the current holder's heartbeat is stale enough to investigate
173
+ (past alert_threshold) but not yet stale enough to auto-reclaim.
174
+ """
175
+ now = time.monotonic()
176
+ if now - self._last_alert_at < self.config.alert_threshold:
177
+ return # already alerted recently; don't page repeatedly every poll
178
+ self._last_alert_at = now
179
+
180
+ logger.warning(
181
+ "postgres_mutex: lock %r held by %r with no heartbeat for %.1fs "
182
+ "(alert threshold %.1fs)",
183
+ self.config.lock_name,
184
+ holder,
185
+ seconds_since,
186
+ self.config.alert_threshold,
187
+ )
188
+ safe_call(
189
+ self.metrics,
190
+ "alert",
191
+ lock_name=self.config.lock_name,
192
+ holder=holder,
193
+ seconds_since_heartbeat=seconds_since,
194
+ )
195
+
196
+ async def heartbeat(self) -> None:
197
+ query = sql.SQL(_sql.HEARTBEAT).format(table=sql.Identifier(self.config.table))
198
+ params = {"lock_name": self.config.lock_name, "instance_id": self.config.instance_id}
199
+ start = time.monotonic()
200
+ async with self._db_lock, self._borrow() as conn, conn.cursor() as cur:
201
+ await cur.execute(query, params)
202
+ row = await cur.fetchone()
203
+ elapsed = time.monotonic() - start
204
+
205
+ if row is None:
206
+ self._held = False
207
+ self._lost.set()
208
+ raise LockNotHeldError(self.config.lock_name)
209
+
210
+ safe_call(
211
+ self.metrics,
212
+ "heartbeat_latency",
213
+ lock_name=self.config.lock_name,
214
+ instance_id=self.config.instance_id,
215
+ seconds=elapsed,
216
+ )
217
+
218
+ async def release(self) -> None:
219
+ await self.stop_heartbeat()
220
+ query = sql.SQL(_sql.RELEASE).format(table=sql.Identifier(self.config.table))
221
+ params = {"lock_name": self.config.lock_name, "instance_id": self.config.instance_id}
222
+ async with self._db_lock, self._borrow() as conn, conn.cursor() as cur:
223
+ await cur.execute(query, params)
224
+ row = await cur.fetchone()
225
+
226
+ self._held = False
227
+ if row is None:
228
+ raise LockNotHeldError(self.config.lock_name)
229
+
230
+ safe_call(
231
+ self.metrics,
232
+ "released",
233
+ lock_name=self.config.lock_name,
234
+ instance_id=self.config.instance_id,
235
+ )
236
+
237
+ # -- background heartbeat task -------------------------------------------------
238
+
239
+ def start_heartbeat(self) -> None:
240
+ if self._heartbeat_task is not None:
241
+ return
242
+ self._heartbeat_task = asyncio.ensure_future(self._heartbeat_loop())
243
+
244
+ async def stop_heartbeat(self) -> None:
245
+ task, self._heartbeat_task = self._heartbeat_task, None
246
+ if task is not None and not task.done():
247
+ task.cancel()
248
+ with contextlib.suppress(asyncio.CancelledError):
249
+ await task
250
+
251
+ async def _heartbeat_loop(self) -> None:
252
+ try:
253
+ while True:
254
+ await asyncio.sleep(self.config.heartbeat_interval)
255
+ try:
256
+ await self.heartbeat()
257
+ except LockNotHeldError:
258
+ logger.error(
259
+ "postgres_mutex: lost lock %r (instance %r) — heartbeat missed the "
260
+ "stale window and another instance reclaimed it",
261
+ self.config.lock_name,
262
+ self.config.instance_id,
263
+ )
264
+ return
265
+ except Exception:
266
+ logger.exception(
267
+ "postgres_mutex: heartbeat failed for lock %r (instance %r); will retry",
268
+ self.config.lock_name,
269
+ self.config.instance_id,
270
+ )
271
+ except asyncio.CancelledError:
272
+ raise
273
+
274
+ # -- blocking wait -------------------------------------------------
275
+
276
+ async def acquire(self, blocking: bool, timeout: float | None) -> bool:
277
+ if await self.try_acquire():
278
+ self.start_heartbeat()
279
+ return True
280
+ if not blocking:
281
+ return False
282
+
283
+ deadline = None if timeout is None else time.monotonic() + timeout
284
+ while True:
285
+ if deadline is not None and time.monotonic() >= deadline:
286
+ raise LockAcquisitionTimeout(self.config.lock_name, timeout) # type: ignore[arg-type]
287
+ await asyncio.sleep(self.config.poll_interval)
288
+ if await self.try_acquire():
289
+ self.start_heartbeat()
290
+ return True
@@ -0,0 +1,52 @@
1
+ """Configuration and validation for a Mutex instance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import socket
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+
10
+ _VALID_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
11
+
12
+
13
+ def default_instance_id() -> str:
14
+ """A reasonably unique, human-traceable default: ``<hostname>-<8 hex chars>``."""
15
+ return f"{socket.gethostname()}-{uuid.uuid4().hex[:8]}"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class MutexConfig:
20
+ """Validated, immutable configuration for one lock.
21
+
22
+ The dual-threshold defaults (30s alert / 600s auto-release) match the design
23
+ rationale in the project README: a single timeout forces a choice between
24
+ releasing too eagerly (handing the lock to another instance that hits the same
25
+ slow-processing problem) or too slowly (leaving a crashed holder's job undone
26
+ for ages). The alert window gives on-call a chance to intervene before the
27
+ system self-heals.
28
+ """
29
+
30
+ lock_name: str
31
+ instance_id: str = field(default_factory=default_instance_id)
32
+ table: str = "mutex_lock"
33
+ heartbeat_interval: float = 10.0
34
+ alert_threshold: float = 30.0
35
+ stale_threshold: float = 600.0
36
+ poll_interval: float = 0.5
37
+
38
+ def __post_init__(self) -> None:
39
+ if not self.lock_name or len(self.lock_name) > 64:
40
+ raise ValueError("lock_name must be non-empty and <= 64 characters")
41
+ if not self.instance_id or len(self.instance_id) > 64:
42
+ raise ValueError("instance_id must be non-empty and <= 64 characters")
43
+ if not _VALID_IDENTIFIER.match(self.table):
44
+ raise ValueError(f"invalid table name: {self.table!r}")
45
+ if not (0 < self.heartbeat_interval < self.alert_threshold < self.stale_threshold):
46
+ raise ValueError(
47
+ "require 0 < heartbeat_interval < alert_threshold < stale_threshold "
48
+ f"(got {self.heartbeat_interval}, {self.alert_threshold}, "
49
+ f"{self.stale_threshold})"
50
+ )
51
+ if self.poll_interval <= 0:
52
+ raise ValueError("poll_interval must be > 0")
postgres_mutex/_sql.py ADDED
@@ -0,0 +1,69 @@
1
+ """Raw SQL for the mutex_lock table.
2
+
3
+ Kept in one place so the acquire/steal logic — the one part of this project that has
4
+ to be exactly right — is easy to audit independently of the Python control flow
5
+ around it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ CREATE_TABLE = """
11
+ CREATE TABLE IF NOT EXISTS {table} (
12
+ lock_name VARCHAR(64) NOT NULL,
13
+ locked INT NOT NULL DEFAULT 1,
14
+ instance_id VARCHAR(64) NOT NULL,
15
+ last_heartbeat TIMESTAMPTZ NOT NULL,
16
+ acquired_at TIMESTAMPTZ NOT NULL,
17
+ CONSTRAINT {table_uq} UNIQUE (locked, lock_name),
18
+ CONSTRAINT {table_chk} CHECK (locked = 1)
19
+ )
20
+ """
21
+
22
+ # Acquire is a locate-then-act sequence run inside one explicit transaction (see
23
+ # SyncMutexCore.try_acquire / AsyncMutexCore.try_acquire):
24
+ #
25
+ # 1. SELECT_FOR_ACQUIRE ... FOR UPDATE locks the row (if any) so two concurrent
26
+ # acquirers for the same lock_name serialize on it instead of racing.
27
+ # 2. If no row: INSERT_ROW.
28
+ # 3. If the row is stale (past stale_threshold): DELETE_ROW then INSERT_ROW.
29
+ # 4. Otherwise: no writes; the transaction commits as a no-op.
30
+ #
31
+ # Earlier this was a single statement with a data-modifying DELETE ... CTE feeding an
32
+ # INSERT ... ON CONFLICT. That looked atomic but isn't: sibling CTEs in one statement
33
+ # all execute against the *same* snapshot, so the INSERT's conflict check can't see
34
+ # rows the CTE's DELETE just removed — the delete would apply but the insert would
35
+ # still no-op, silently emptying the table. An explicit transaction with row-level
36
+ # locking avoids that pitfall and, as a bonus, lets us read the previous holder's
37
+ # instance_id directly instead of smuggling it out of a CTE.
38
+ SELECT_FOR_ACQUIRE = """
39
+ SELECT instance_id, EXTRACT(EPOCH FROM (now() - last_heartbeat)) AS seconds_since_heartbeat
40
+ FROM {table}
41
+ WHERE lock_name = %(lock_name)s
42
+ FOR UPDATE
43
+ """
44
+
45
+ DELETE_ROW = """
46
+ DELETE FROM {table}
47
+ WHERE lock_name = %(lock_name)s
48
+ """
49
+
50
+ INSERT_ROW = """
51
+ INSERT INTO {table} (lock_name, locked, instance_id, last_heartbeat, acquired_at)
52
+ VALUES (%(lock_name)s, 1, %(instance_id)s, now(), now())
53
+ """
54
+
55
+ HEARTBEAT = """
56
+ UPDATE {table}
57
+ SET last_heartbeat = now()
58
+ WHERE lock_name = %(lock_name)s AND instance_id = %(instance_id)s
59
+ RETURNING 1
60
+ """
61
+
62
+ # Clean shutdown: delete the row outright so the next instance doesn't wait out the
63
+ # stale_threshold. Scoped to instance_id so a holder that already lost the lock to a
64
+ # stale-reclaim can't accidentally delete the new holder's row.
65
+ RELEASE = """
66
+ DELETE FROM {table}
67
+ WHERE lock_name = %(lock_name)s AND instance_id = %(instance_id)s
68
+ RETURNING 1
69
+ """