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,298 @@
1
+ """Sync implementation: connection handling, acquire/release/heartbeat over psycopg 3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import threading
8
+ import time
9
+ from collections.abc import Iterator
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 SyncConnectionPool(Protocol):
24
+ """Shape of ``psycopg_pool.ConnectionPool`` — matched structurally so postgres_mutex
25
+ doesn't need psycopg-pool as a hard dependency; any object with this method works.
26
+ """
27
+
28
+ def connection(self, timeout: float | None = None) -> Any: ... # context manager
29
+
30
+
31
+ class SyncMutexCore:
32
+ """Does the actual SQL work, either over one dedicated connection (``dsn`` mode)
33
+ or by borrowing short-lived connections from a caller-supplied pool (``pool``
34
+ mode) — see ``Mutex.__init__``.
35
+
36
+ A background thread sends heartbeats while the lock is held. In ``dsn`` mode, all
37
+ queries against the single shared connection (from the main thread or the
38
+ heartbeat thread) go through ``_db_lock`` — psycopg connections are not safe for
39
+ concurrent use from multiple threads. In ``pool`` mode the lock is unnecessary
40
+ (each borrow gets its own connection) but harmless, so the same code path is used
41
+ for both to avoid maintaining two.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ config: MutexConfig,
47
+ metrics: MetricsHook | None,
48
+ *,
49
+ dsn: str | None = None,
50
+ pool: SyncConnectionPool | None = None,
51
+ ) -> None:
52
+ self.dsn = dsn
53
+ self.pool = pool
54
+ self.config = config
55
+ self.metrics = metrics or NullMetrics()
56
+ self._conn: psycopg.Connection[Any] | None = None
57
+ self._db_lock = threading.Lock()
58
+ self._heartbeat_thread: threading.Thread | None = None
59
+ self._stop_heartbeat = threading.Event()
60
+ self._held = False
61
+ self._lost = threading.Event()
62
+ self._last_alert_at = 0.0
63
+
64
+ # -- connection / schema -------------------------------------------------
65
+
66
+ def _connection(self) -> psycopg.Connection[Any]:
67
+ """The dedicated dsn-mode connection, opened lazily. Not used in pool mode."""
68
+ if self._conn is None or self._conn.closed:
69
+ assert self.dsn is not None
70
+ self._conn = psycopg.connect(self.dsn, autocommit=True)
71
+ return self._conn
72
+
73
+ @contextlib.contextmanager
74
+ def _borrow(self) -> Iterator[psycopg.Connection[Any]]:
75
+ """Yield a connection to run one operation against.
76
+
77
+ dsn mode: yields the long-lived dedicated connection, unchanged after use.
78
+ pool mode: borrows one from the pool and returns it on exit — postgres_mutex never
79
+ owns or closes a caller-supplied pool.
80
+ """
81
+ if self.pool is not None:
82
+ with self.pool.connection() as conn:
83
+ yield conn
84
+ else:
85
+ yield self._connection()
86
+
87
+ def ensure_schema(self) -> None:
88
+ query = sql.SQL(_sql.CREATE_TABLE).format(
89
+ table=sql.Identifier(self.config.table),
90
+ table_uq=sql.Identifier(f"{self.config.table}_uq"),
91
+ table_chk=sql.Identifier(f"{self.config.table}_chk_locked"),
92
+ )
93
+ with self._db_lock, self._borrow() as conn, conn.cursor() as cur:
94
+ cur.execute(query)
95
+
96
+ def close(self) -> None:
97
+ """Close the dedicated dsn-mode connection, if one was opened. In pool mode,
98
+ this is a no-op — the pool belongs to the caller.
99
+ """
100
+ self.stop_heartbeat()
101
+ with self._db_lock:
102
+ if self._conn is not None and not self._conn.closed:
103
+ self._conn.close()
104
+
105
+ # -- core operations -------------------------------------------------
106
+
107
+ def try_acquire(self) -> bool:
108
+ q_select = sql.SQL(_sql.SELECT_FOR_ACQUIRE).format(table=sql.Identifier(self.config.table))
109
+ q_delete = sql.SQL(_sql.DELETE_ROW).format(table=sql.Identifier(self.config.table))
110
+ q_insert = sql.SQL(_sql.INSERT_ROW).format(table=sql.Identifier(self.config.table))
111
+ lock_params = {"lock_name": self.config.lock_name}
112
+ insert_params = {"lock_name": self.config.lock_name, "instance_id": self.config.instance_id}
113
+
114
+ with self._db_lock, self._borrow() as conn:
115
+ try:
116
+ with conn.transaction(), conn.cursor() as cur:
117
+ cur.execute(q_select, lock_params)
118
+ row = cur.fetchone()
119
+
120
+ if row is None:
121
+ cur.execute(q_insert, insert_params)
122
+ acquired, reclaimed_from, holder, seconds_since = True, None, None, None
123
+ else:
124
+ holder, seconds_since = row
125
+ if (
126
+ seconds_since is not None
127
+ and seconds_since >= self.config.stale_threshold
128
+ ):
129
+ cur.execute(q_delete, lock_params)
130
+ cur.execute(q_insert, insert_params)
131
+ acquired, reclaimed_from = True, holder
132
+ else:
133
+ acquired, reclaimed_from = False, None
134
+ except psycopg.errors.UniqueViolation:
135
+ # SELECT ... FOR UPDATE only serializes against a row that already
136
+ # exists — when the lock is free, two contenders can both see "no
137
+ # row" and both attempt INSERT. The UNIQUE constraint is still the
138
+ # source of truth: exactly one wins, and the loser just lost a race,
139
+ # not an error. Treat it the same as ordinary contention.
140
+ acquired, reclaimed_from, holder, seconds_since = False, None, None, None
141
+
142
+ if not acquired:
143
+ safe_call(
144
+ self.metrics,
145
+ "contended",
146
+ lock_name=self.config.lock_name,
147
+ instance_id=self.config.instance_id,
148
+ )
149
+ if holder is not None and seconds_since is not None: # noqa: SIM102 (mypy narrowing)
150
+ if seconds_since >= self.config.alert_threshold:
151
+ self._fire_alert(holder, seconds_since)
152
+ return False
153
+
154
+ self._held = True
155
+ self._lost.clear()
156
+ if reclaimed_from is not None:
157
+ safe_call(
158
+ self.metrics,
159
+ "stale_reclaimed",
160
+ lock_name=self.config.lock_name,
161
+ instance_id=self.config.instance_id,
162
+ previous_holder=reclaimed_from,
163
+ )
164
+ logger.warning(
165
+ "postgres_mutex: reclaimed lock %r from stale holder %r (instance %r)",
166
+ self.config.lock_name,
167
+ reclaimed_from,
168
+ self.config.instance_id,
169
+ )
170
+ safe_call(
171
+ self.metrics,
172
+ "acquired",
173
+ lock_name=self.config.lock_name,
174
+ instance_id=self.config.instance_id,
175
+ )
176
+ return True
177
+
178
+ def _fire_alert(self, holder: str, seconds_since: float) -> None:
179
+ """Page on-call: the current holder's heartbeat is stale enough to investigate
180
+ (past alert_threshold) but not yet stale enough to auto-reclaim.
181
+ """
182
+ now = time.monotonic()
183
+ if now - self._last_alert_at < self.config.alert_threshold:
184
+ return # already alerted recently; don't page repeatedly every poll
185
+ self._last_alert_at = now
186
+
187
+ logger.warning(
188
+ "postgres_mutex: lock %r held by %r with no heartbeat for %.1fs "
189
+ "(alert threshold %.1fs)",
190
+ self.config.lock_name,
191
+ holder,
192
+ seconds_since,
193
+ self.config.alert_threshold,
194
+ )
195
+ safe_call(
196
+ self.metrics,
197
+ "alert",
198
+ lock_name=self.config.lock_name,
199
+ holder=holder,
200
+ seconds_since_heartbeat=seconds_since,
201
+ )
202
+
203
+ def heartbeat(self) -> None:
204
+ query = sql.SQL(_sql.HEARTBEAT).format(table=sql.Identifier(self.config.table))
205
+ params = {"lock_name": self.config.lock_name, "instance_id": self.config.instance_id}
206
+ start = time.monotonic()
207
+ with self._db_lock, self._borrow() as conn, conn.cursor() as cur:
208
+ cur.execute(query, params)
209
+ row = cur.fetchone()
210
+ elapsed = time.monotonic() - start
211
+
212
+ if row is None:
213
+ self._held = False
214
+ self._lost.set()
215
+ raise LockNotHeldError(self.config.lock_name)
216
+
217
+ safe_call(
218
+ self.metrics,
219
+ "heartbeat_latency",
220
+ lock_name=self.config.lock_name,
221
+ instance_id=self.config.instance_id,
222
+ seconds=elapsed,
223
+ )
224
+
225
+ def release(self) -> None:
226
+ self.stop_heartbeat()
227
+ query = sql.SQL(_sql.RELEASE).format(table=sql.Identifier(self.config.table))
228
+ params = {"lock_name": self.config.lock_name, "instance_id": self.config.instance_id}
229
+ with self._db_lock, self._borrow() as conn, conn.cursor() as cur:
230
+ cur.execute(query, params)
231
+ row = cur.fetchone()
232
+
233
+ self._held = False
234
+ if row is None:
235
+ raise LockNotHeldError(self.config.lock_name)
236
+
237
+ safe_call(
238
+ self.metrics,
239
+ "released",
240
+ lock_name=self.config.lock_name,
241
+ instance_id=self.config.instance_id,
242
+ )
243
+
244
+ # -- background heartbeat thread -------------------------------------------------
245
+
246
+ def start_heartbeat(self) -> None:
247
+ if self._heartbeat_thread is not None:
248
+ return
249
+ self._stop_heartbeat.clear()
250
+ self._heartbeat_thread = threading.Thread(
251
+ target=self._heartbeat_loop,
252
+ name=f"postgres-mutex-heartbeat-{self.config.lock_name}",
253
+ daemon=True,
254
+ )
255
+ self._heartbeat_thread.start()
256
+
257
+ def stop_heartbeat(self) -> None:
258
+ self._stop_heartbeat.set()
259
+ thread, self._heartbeat_thread = self._heartbeat_thread, None
260
+ if thread is not None and thread.is_alive() and thread is not threading.current_thread():
261
+ thread.join(timeout=self.config.heartbeat_interval)
262
+
263
+ def _heartbeat_loop(self) -> None:
264
+ while not self._stop_heartbeat.wait(self.config.heartbeat_interval):
265
+ try:
266
+ self.heartbeat()
267
+ except LockNotHeldError:
268
+ logger.error(
269
+ "postgres_mutex: lost lock %r (instance %r) — heartbeat missed the "
270
+ "stale window and another instance reclaimed it",
271
+ self.config.lock_name,
272
+ self.config.instance_id,
273
+ )
274
+ return
275
+ except Exception:
276
+ logger.exception(
277
+ "postgres_mutex: heartbeat failed for lock %r (instance %r); will retry",
278
+ self.config.lock_name,
279
+ self.config.instance_id,
280
+ )
281
+
282
+ # -- blocking wait -------------------------------------------------
283
+
284
+ def acquire(self, blocking: bool, timeout: float | None) -> bool:
285
+ if self.try_acquire():
286
+ self.start_heartbeat()
287
+ return True
288
+ if not blocking:
289
+ return False
290
+
291
+ deadline = None if timeout is None else time.monotonic() + timeout
292
+ while True:
293
+ if deadline is not None and time.monotonic() >= deadline:
294
+ raise LockAcquisitionTimeout(self.config.lock_name, timeout) # type: ignore[arg-type]
295
+ time.sleep(self.config.poll_interval)
296
+ if self.try_acquire():
297
+ self.start_heartbeat()
298
+ return True
@@ -0,0 +1,32 @@
1
+ """Exceptions raised by postgres_mutex."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PgMutexError(Exception):
7
+ """Base class for all postgres_mutex errors."""
8
+
9
+
10
+ class LockAcquisitionTimeout(PgMutexError):
11
+ """Raised when a blocking ``acquire()`` times out before the lock frees up."""
12
+
13
+ def __init__(self, lock_name: str, timeout: float) -> None:
14
+ self.lock_name = lock_name
15
+ self.timeout = timeout
16
+ super().__init__(f"timed out after {timeout}s waiting to acquire lock {lock_name!r}")
17
+
18
+
19
+ class LockNotHeldError(PgMutexError):
20
+ """Raised by ``release()`` or ``heartbeat()`` when the caller is not the current holder.
21
+
22
+ This happens when the lock was stolen out from under an instance because its
23
+ heartbeat went stale for longer than ``stale_threshold`` — i.e. another instance
24
+ already decided this one had crashed.
25
+ """
26
+
27
+ def __init__(self, lock_name: str) -> None:
28
+ self.lock_name = lock_name
29
+ super().__init__(
30
+ f"lock {lock_name!r} is not held by this instance "
31
+ "(it may have been reclaimed after a missed heartbeat)"
32
+ )
@@ -0,0 +1,77 @@
1
+ """Metrics hook interface.
2
+
3
+ postgres_mutex has no hard dependency on any metrics backend. Instead it defines a small
4
+ protocol that Prometheus (``prometheus_client``), OpenTelemetry, statsd, or a
5
+ homegrown metrics object can all satisfy without postgres_mutex knowing about any of them.
6
+
7
+ Every method is called synchronously and must not raise — a broken metrics backend
8
+ should never take down the lock. ``Mutex`` wraps every call in a best-effort try/except.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ from typing import Protocol, runtime_checkable
15
+
16
+
17
+ @runtime_checkable
18
+ class MetricsHook(Protocol):
19
+ """Implement whichever subset of these you care about; unset methods are no-ops.
20
+
21
+ A minimal Prometheus adapter, for example, might implement ``acquired`` as
22
+ ``counter.labels(lock_name=lock_name).inc()`` and ``heartbeat_latency`` as
23
+ ``histogram.labels(lock_name=lock_name).observe(seconds)``.
24
+ """
25
+
26
+ def acquired(self, lock_name: str, *, instance_id: str) -> None:
27
+ """Called every time this instance successfully acquires the lock."""
28
+
29
+ def released(self, lock_name: str, *, instance_id: str) -> None:
30
+ """Called every time this instance cleanly releases the lock."""
31
+
32
+ def contended(self, lock_name: str, *, instance_id: str) -> None:
33
+ """Called every time an acquire attempt finds the lock already held."""
34
+
35
+ def stale_reclaimed(self, lock_name: str, *, instance_id: str, previous_holder: str) -> None:
36
+ """Called when this instance steals a lock whose holder went stale (>10min)."""
37
+
38
+ def alert(self, lock_name: str, *, holder: str, seconds_since_heartbeat: float) -> None:
39
+ """Called when a holder's heartbeat is stale enough to page on-call (>30s)."""
40
+
41
+ def heartbeat_latency(self, lock_name: str, *, instance_id: str, seconds: float) -> None:
42
+ """Called after each heartbeat UPDATE with the round-trip latency."""
43
+
44
+
45
+ class NullMetrics:
46
+ """Default no-op metrics hook used when the caller doesn't supply one."""
47
+
48
+ def acquired(self, lock_name: str, *, instance_id: str) -> None:
49
+ pass
50
+
51
+ def released(self, lock_name: str, *, instance_id: str) -> None:
52
+ pass
53
+
54
+ def contended(self, lock_name: str, *, instance_id: str) -> None:
55
+ pass
56
+
57
+ def stale_reclaimed(self, lock_name: str, *, instance_id: str, previous_holder: str) -> None:
58
+ pass
59
+
60
+ def alert(self, lock_name: str, *, holder: str, seconds_since_heartbeat: float) -> None:
61
+ pass
62
+
63
+ def heartbeat_latency(self, lock_name: str, *, instance_id: str, seconds: float) -> None:
64
+ pass
65
+
66
+
67
+ def safe_call(hook: object, method: str, /, **kwargs: object) -> None:
68
+ """Call ``hook.<method>(**kwargs)`` if present, swallowing any exception it raises.
69
+
70
+ A user-supplied metrics backend must never be able to break lock acquisition or
71
+ release. If it raises, we drop the metric on the floor rather than propagate.
72
+ """
73
+ fn = getattr(hook, method, None)
74
+ if fn is None:
75
+ return
76
+ with contextlib.suppress(Exception): # metrics must never break the mutex
77
+ fn(**kwargs)