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
|
@@ -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
|
+
]
|
postgres_mutex/_async.py
ADDED
|
@@ -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
|
+
"""
|