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.
- postgres_mutex/__init__.py +22 -0
- postgres_mutex/_async.py +290 -0
- postgres_mutex/_config.py +52 -0
- postgres_mutex/_sql.py +69 -0
- postgres_mutex/_sync.py +298 -0
- postgres_mutex/exceptions.py +32 -0
- postgres_mutex/metrics.py +77 -0
- postgres_mutex/mutex.py +320 -0
- postgres_mutex/py.typed +0 -0
- postgres_mutex-0.1.0.dist-info/METADATA +258 -0
- postgres_mutex-0.1.0.dist-info/RECORD +13 -0
- postgres_mutex-0.1.0.dist-info/WHEEL +4 -0
- postgres_mutex-0.1.0.dist-info/licenses/LICENSE +21 -0
postgres_mutex/_sync.py
ADDED
|
@@ -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)
|