idemkit 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.
- idemkit/__init__.py +101 -0
- idemkit/_version.py +1 -0
- idemkit/adapters/__init__.py +10 -0
- idemkit/adapters/ai.py +723 -0
- idemkit/adapters/asgi.py +533 -0
- idemkit/adapters/queue.py +413 -0
- idemkit/adapters/route.py +273 -0
- idemkit/adapters/wsgi.py +393 -0
- idemkit/backends/__init__.py +30 -0
- idemkit/backends/base.py +116 -0
- idemkit/backends/dynamodb.py +489 -0
- idemkit/backends/memory.py +325 -0
- idemkit/backends/mongo.py +395 -0
- idemkit/backends/postgres.py +682 -0
- idemkit/backends/redis.py +547 -0
- idemkit/cli.py +165 -0
- idemkit/conformance/__init__.py +25 -0
- idemkit/conformance/backend.py +257 -0
- idemkit/conformance/report.py +54 -0
- idemkit/contrib/__init__.py +30 -0
- idemkit/contrib/drf.py +181 -0
- idemkit/contrib/fastapi.py +141 -0
- idemkit/contrib/kafka.py +96 -0
- idemkit/contrib/logging.py +61 -0
- idemkit/contrib/mcp.py +113 -0
- idemkit/contrib/prometheus.py +79 -0
- idemkit/contrib/pubsub.py +98 -0
- idemkit/contrib/rabbitmq.py +138 -0
- idemkit/contrib/reconciliation.py +86 -0
- idemkit/contrib/sqs.py +117 -0
- idemkit/core/__init__.py +36 -0
- idemkit/core/codecs.py +229 -0
- idemkit/core/config.py +255 -0
- idemkit/core/engine.py +331 -0
- idemkit/core/events.py +63 -0
- idemkit/core/exception_cache.py +88 -0
- idemkit/core/exceptions.py +79 -0
- idemkit/core/fingerprint.py +153 -0
- idemkit/core/policy.py +143 -0
- idemkit/core/runner.py +664 -0
- idemkit/core/state.py +95 -0
- idemkit/core/sync_bridge.py +115 -0
- idemkit/problem_details.py +119 -0
- idemkit/py.typed +0 -0
- idemkit-0.1.0.dist-info/METADATA +446 -0
- idemkit-0.1.0.dist-info/RECORD +48 -0
- idemkit-0.1.0.dist-info/WHEEL +4 -0
- idemkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""PostgreSQL backend per spec §4.1 / §4.3 / §7.2.
|
|
2
|
+
|
|
3
|
+
Storage: one row per effective_key in ``idemkit_records``.
|
|
4
|
+
|
|
5
|
+
Atomic claim via ``INSERT ... ON CONFLICT DO NOTHING``; lease reclaim via
|
|
6
|
+
a conditional ``UPDATE ... WHERE state = 'CLAIMED' AND lease_until < NOW()``.
|
|
7
|
+
All time comparisons use PostgreSQL's ``NOW()`` — never the app's clock.
|
|
8
|
+
|
|
9
|
+
In-flight wait uses one dedicated ``LISTEN idemkit_completions`` connection
|
|
10
|
+
per ``PostgresBackend`` instance; ``pg_notify`` payloads carry the
|
|
11
|
+
effective_key and are demultiplexed in-process to per-key ``asyncio.Event``
|
|
12
|
+
waiters. Per-request LISTEN is forbidden — it exhausts the connection pool.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import secrets
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from idemkit.core.exceptions import ConfigurationError, StorageError
|
|
26
|
+
from idemkit.core.state import ClaimResult, ClaimResultType, State, StoredRecord
|
|
27
|
+
|
|
28
|
+
_logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
NOTIFY_CHANNEL = "idemkit_completions"
|
|
31
|
+
|
|
32
|
+
# In-flight wait poll schedule (spec §5.5): the bounded backed-off re-read that
|
|
33
|
+
# keeps a dropped LISTEN/NOTIFY notification from hanging a waiter to timeout.
|
|
34
|
+
_POLL_INITIAL_SECONDS = 0.05
|
|
35
|
+
_POLL_CAP_SECONDS = 1.0
|
|
36
|
+
|
|
37
|
+
SCHEMA_VERSION = 1
|
|
38
|
+
|
|
39
|
+
DEFAULT_TABLE = "idemkit_records"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_table(table: str) -> str:
|
|
43
|
+
"""Guard the table name (it is interpolated into SQL, so it cannot be bound).
|
|
44
|
+
|
|
45
|
+
Accepts an unqualified identifier only, kept short enough that the derived
|
|
46
|
+
index names fit Postgres's 63-char limit.
|
|
47
|
+
"""
|
|
48
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table) or len(table) > 46:
|
|
49
|
+
raise ConfigurationError(
|
|
50
|
+
f"idemkit: invalid Postgres table name {table!r}. Use an unqualified "
|
|
51
|
+
"identifier: letters, digits, and underscores; start with a letter or "
|
|
52
|
+
"underscore; at most 46 characters."
|
|
53
|
+
)
|
|
54
|
+
return table
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _schema_sql(table: str) -> str:
|
|
58
|
+
return f"""
|
|
59
|
+
CREATE TABLE IF NOT EXISTS {table} (
|
|
60
|
+
effective_key TEXT PRIMARY KEY,
|
|
61
|
+
state TEXT NOT NULL CHECK (state IN ('CLAIMED', 'COMPLETED')),
|
|
62
|
+
fingerprint TEXT NOT NULL,
|
|
63
|
+
fingerprint_version INTEGER NOT NULL,
|
|
64
|
+
claim_token TEXT NOT NULL,
|
|
65
|
+
claimed_at TIMESTAMPTZ NOT NULL,
|
|
66
|
+
lease_until TIMESTAMPTZ NOT NULL,
|
|
67
|
+
completed_at TIMESTAMPTZ,
|
|
68
|
+
response_status INTEGER,
|
|
69
|
+
response_headers JSONB,
|
|
70
|
+
response_body BYTEA,
|
|
71
|
+
schema_version INTEGER NOT NULL DEFAULT 1
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS {table}_lease_until_idx
|
|
75
|
+
ON {table} (lease_until)
|
|
76
|
+
WHERE state = 'CLAIMED';
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS {table}_completed_at_idx
|
|
79
|
+
ON {table} (completed_at)
|
|
80
|
+
WHERE state = 'COMPLETED';
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PostgresBackend:
|
|
85
|
+
"""Production-grade PostgreSQL backend."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
pool: Any = None,
|
|
90
|
+
*,
|
|
91
|
+
table: str = DEFAULT_TABLE,
|
|
92
|
+
_url: str | None = None,
|
|
93
|
+
_pool_kwargs: dict[str, Any] | None = None,
|
|
94
|
+
_verify_schema: bool = True,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Construct with an existing asyncpg pool, or (preferred) via
|
|
97
|
+
``PostgresBackend.from_url(...)`` for lazy initialization (§7.1).
|
|
98
|
+
|
|
99
|
+
``table`` is the records table name (default ``idemkit_records``).
|
|
100
|
+
Create it with ``idemkit init-pg <url> --table <name>`` before use.
|
|
101
|
+
"""
|
|
102
|
+
self._table = _validate_table(table)
|
|
103
|
+
self._pool = pool
|
|
104
|
+
self._url = _url
|
|
105
|
+
self._pool_kwargs = _pool_kwargs or {}
|
|
106
|
+
self._verify_schema_on_init = _verify_schema
|
|
107
|
+
self._pool_lock = asyncio.Lock()
|
|
108
|
+
self._waiters: dict[str, set[asyncio.Event]] = {}
|
|
109
|
+
self._listen_conn: Any | None = None
|
|
110
|
+
self._listen_lock = asyncio.Lock()
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_url(
|
|
114
|
+
cls,
|
|
115
|
+
url: str,
|
|
116
|
+
*,
|
|
117
|
+
table: str = DEFAULT_TABLE,
|
|
118
|
+
min_size: int = 4,
|
|
119
|
+
max_size: int = 20,
|
|
120
|
+
verify_schema: bool = True,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> PostgresBackend:
|
|
123
|
+
"""Construct from a postgres:// URL with **lazy** initialization (§7.1).
|
|
124
|
+
|
|
125
|
+
This is synchronous and does not connect: the connection pool is opened
|
|
126
|
+
on the first backend operation, on the serving event loop. This makes it
|
|
127
|
+
safe to wire into ``app.add_middleware(...)`` at construction time and
|
|
128
|
+
keeps startup cheap (nothing connects until the first request).
|
|
129
|
+
|
|
130
|
+
``verify_schema=True`` (default) checks, on first use, that the
|
|
131
|
+
``idemkit_records`` table exists and ``schema_version`` matches what this
|
|
132
|
+
library expects, raising ``ConfigurationError`` otherwise with the exact
|
|
133
|
+
CLI command to run.
|
|
134
|
+
|
|
135
|
+
A default ``command_timeout`` caps every query so a hung-but-connected
|
|
136
|
+
server fails fast (fail-closed) instead of blocking a request; override it
|
|
137
|
+
via a ``command_timeout=`` keyword. Note that under pool exhaustion an
|
|
138
|
+
``acquire`` still waits for a free connection; size ``max_size`` for your
|
|
139
|
+
concurrency.
|
|
140
|
+
"""
|
|
141
|
+
kwargs.setdefault("command_timeout", 10.0)
|
|
142
|
+
return cls(
|
|
143
|
+
pool=None,
|
|
144
|
+
table=table,
|
|
145
|
+
_url=url,
|
|
146
|
+
_pool_kwargs={"min_size": min_size, "max_size": max_size, **kwargs},
|
|
147
|
+
_verify_schema=verify_schema,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def _ensure_pool(self) -> None:
|
|
151
|
+
"""Open the pool on first use (lazy init, §7.1)."""
|
|
152
|
+
if self._pool is not None:
|
|
153
|
+
return
|
|
154
|
+
async with self._pool_lock:
|
|
155
|
+
if self._pool is not None:
|
|
156
|
+
return
|
|
157
|
+
if self._url is None:
|
|
158
|
+
raise StorageError("postgres: no pool and no URL configured")
|
|
159
|
+
try:
|
|
160
|
+
import asyncpg
|
|
161
|
+
except ImportError as e:
|
|
162
|
+
raise ImportError(
|
|
163
|
+
"idemkit: install the `postgres` extra: pip install 'idemkit[postgres]'"
|
|
164
|
+
) from e
|
|
165
|
+
pool = await asyncpg.create_pool(self._url, **self._pool_kwargs)
|
|
166
|
+
self._pool = pool
|
|
167
|
+
if self._verify_schema_on_init:
|
|
168
|
+
try:
|
|
169
|
+
await self.verify_schema()
|
|
170
|
+
except Exception:
|
|
171
|
+
# Tear the pool down so a later call can retry init cleanly
|
|
172
|
+
# (and so the schema error surfaces again, not a None pool).
|
|
173
|
+
await pool.close()
|
|
174
|
+
self._pool = None
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
async def aclose(self) -> None:
|
|
178
|
+
"""Tear down the listener and close the pool."""
|
|
179
|
+
if self._pool is None:
|
|
180
|
+
return
|
|
181
|
+
async with self._listen_lock:
|
|
182
|
+
if self._listen_conn is not None:
|
|
183
|
+
try:
|
|
184
|
+
await self._listen_conn.remove_listener(NOTIFY_CHANNEL, self._on_notification)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
try:
|
|
188
|
+
await self._pool.release(self._listen_conn)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
self._listen_conn = None
|
|
192
|
+
await self._pool.close()
|
|
193
|
+
|
|
194
|
+
async def __aenter__(self) -> PostgresBackend:
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
198
|
+
await self.aclose()
|
|
199
|
+
|
|
200
|
+
async def verify_schema(self) -> None:
|
|
201
|
+
"""Check the ``idemkit_records`` table exists with expected version.
|
|
202
|
+
|
|
203
|
+
Raises ``ConfigurationError`` with the CLI command to run if the
|
|
204
|
+
table is missing or its ``schema_version`` doesn't match.
|
|
205
|
+
"""
|
|
206
|
+
import asyncpg
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
async with self._pool.acquire() as conn:
|
|
210
|
+
row = await conn.fetchrow(f"SELECT schema_version FROM {self._table} LIMIT 1")
|
|
211
|
+
except asyncpg.UndefinedTableError as e:
|
|
212
|
+
raise ConfigurationError(
|
|
213
|
+
f"idemkit: PostgreSQL table '{self._table}' does not exist. "
|
|
214
|
+
f"Run `idemkit init-pg <database-url> --table {self._table}` to create it. "
|
|
215
|
+
"See spec §7.2 for the schema."
|
|
216
|
+
) from e
|
|
217
|
+
|
|
218
|
+
# Empty table is fine: SCHEMA_VERSION is the column default. A row
|
|
219
|
+
# with mismatched schema_version means drift since this library
|
|
220
|
+
# version was deployed.
|
|
221
|
+
if row is not None and row["schema_version"] != SCHEMA_VERSION:
|
|
222
|
+
raise ConfigurationError(
|
|
223
|
+
f"idemkit: PostgreSQL schema_version mismatch "
|
|
224
|
+
f"(table={row['schema_version']}, library={SCHEMA_VERSION}). "
|
|
225
|
+
f"Run `idemkit init-pg <database-url>` after reviewing the schema."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# ----- spec §4.1 backend Protocol -----
|
|
229
|
+
|
|
230
|
+
async def claim(
|
|
231
|
+
self,
|
|
232
|
+
effective_key: str,
|
|
233
|
+
fingerprint: str,
|
|
234
|
+
fingerprint_version: int,
|
|
235
|
+
lease_ttl_seconds: float,
|
|
236
|
+
) -> ClaimResult:
|
|
237
|
+
claim_token = secrets.token_hex(16)
|
|
238
|
+
lease_ms = int(lease_ttl_seconds * 1000)
|
|
239
|
+
|
|
240
|
+
await self._ensure_pool()
|
|
241
|
+
try:
|
|
242
|
+
async with self._pool.acquire() as conn:
|
|
243
|
+
# 1. Try fresh INSERT
|
|
244
|
+
row = await conn.fetchrow(
|
|
245
|
+
f"""
|
|
246
|
+
INSERT INTO {self._table}
|
|
247
|
+
(effective_key, state, fingerprint, fingerprint_version,
|
|
248
|
+
claim_token, claimed_at, lease_until)
|
|
249
|
+
VALUES ($1, 'CLAIMED', $2, $3, $4, NOW(),
|
|
250
|
+
NOW() + ($5 || ' milliseconds')::INTERVAL)
|
|
251
|
+
ON CONFLICT (effective_key) DO NOTHING
|
|
252
|
+
RETURNING *
|
|
253
|
+
""",
|
|
254
|
+
effective_key,
|
|
255
|
+
fingerprint,
|
|
256
|
+
fingerprint_version,
|
|
257
|
+
claim_token,
|
|
258
|
+
str(lease_ms),
|
|
259
|
+
)
|
|
260
|
+
if row is not None:
|
|
261
|
+
return ClaimResult(
|
|
262
|
+
ClaimResultType.NEW_CLAIMED,
|
|
263
|
+
self._row_to_record(row),
|
|
264
|
+
claim_token,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# 2. Reclaim an expired CLAIMED lease (§4.4) → LEASE_RECLAIMED.
|
|
268
|
+
reclaim = await conn.fetchrow(
|
|
269
|
+
f"""
|
|
270
|
+
UPDATE {self._table}
|
|
271
|
+
SET state = 'CLAIMED',
|
|
272
|
+
fingerprint = $2,
|
|
273
|
+
fingerprint_version = $3,
|
|
274
|
+
claim_token = $4,
|
|
275
|
+
claimed_at = NOW(),
|
|
276
|
+
lease_until = NOW() + ($5 || ' milliseconds')::INTERVAL,
|
|
277
|
+
completed_at = NULL,
|
|
278
|
+
response_status = NULL,
|
|
279
|
+
response_headers = NULL,
|
|
280
|
+
response_body = NULL
|
|
281
|
+
WHERE effective_key = $1
|
|
282
|
+
AND state = 'CLAIMED'
|
|
283
|
+
AND lease_until < NOW()
|
|
284
|
+
RETURNING *
|
|
285
|
+
""",
|
|
286
|
+
effective_key,
|
|
287
|
+
fingerprint,
|
|
288
|
+
fingerprint_version,
|
|
289
|
+
claim_token,
|
|
290
|
+
str(lease_ms),
|
|
291
|
+
)
|
|
292
|
+
if reclaim is not None:
|
|
293
|
+
return ClaimResult(
|
|
294
|
+
ClaimResultType.LEASE_RECLAIMED,
|
|
295
|
+
self._row_to_record(reclaim),
|
|
296
|
+
claim_token,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# 3. An expired COMPLETED record (lease_until = completed
|
|
300
|
+
# expiry, in the past) is treated as ABSENT and re-claimed
|
|
301
|
+
# fresh → NEW_CLAIMED, matching memory/redis where the record
|
|
302
|
+
# has simply TTL'd away (§4.8). This enforces completed_ttl
|
|
303
|
+
# on read instead of relying solely on pg-vacuum.
|
|
304
|
+
fresh = await conn.fetchrow(
|
|
305
|
+
f"""
|
|
306
|
+
UPDATE {self._table}
|
|
307
|
+
SET state = 'CLAIMED',
|
|
308
|
+
fingerprint = $2,
|
|
309
|
+
fingerprint_version = $3,
|
|
310
|
+
claim_token = $4,
|
|
311
|
+
claimed_at = NOW(),
|
|
312
|
+
lease_until = NOW() + ($5 || ' milliseconds')::INTERVAL,
|
|
313
|
+
completed_at = NULL,
|
|
314
|
+
response_status = NULL,
|
|
315
|
+
response_headers = NULL,
|
|
316
|
+
response_body = NULL
|
|
317
|
+
WHERE effective_key = $1
|
|
318
|
+
AND state = 'COMPLETED'
|
|
319
|
+
AND lease_until < NOW()
|
|
320
|
+
RETURNING *
|
|
321
|
+
""",
|
|
322
|
+
effective_key,
|
|
323
|
+
fingerprint,
|
|
324
|
+
fingerprint_version,
|
|
325
|
+
claim_token,
|
|
326
|
+
str(lease_ms),
|
|
327
|
+
)
|
|
328
|
+
if fresh is not None:
|
|
329
|
+
return ClaimResult(
|
|
330
|
+
ClaimResultType.NEW_CLAIMED,
|
|
331
|
+
self._row_to_record(fresh),
|
|
332
|
+
claim_token,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# 4. Read the live existing record (must exist if all above failed).
|
|
336
|
+
existing = await conn.fetchrow(
|
|
337
|
+
f"SELECT * FROM {self._table} WHERE effective_key = $1",
|
|
338
|
+
effective_key,
|
|
339
|
+
)
|
|
340
|
+
if existing is None:
|
|
341
|
+
# Lost the race against a concurrent delete; signal storage error
|
|
342
|
+
raise StorageError("postgres: record disappeared during claim; retry")
|
|
343
|
+
try:
|
|
344
|
+
record = self._row_to_record(existing)
|
|
345
|
+
except Exception:
|
|
346
|
+
# Row can't be deserialized (schema drift, tampering, a
|
|
347
|
+
# future encryption scheme). §4.12: treat as ABSENT and
|
|
348
|
+
# re-claim fresh rather than 500. Overwrite the bad row.
|
|
349
|
+
corrupt = await conn.fetchrow(
|
|
350
|
+
f"""
|
|
351
|
+
UPDATE {self._table}
|
|
352
|
+
SET state = 'CLAIMED',
|
|
353
|
+
fingerprint = $2,
|
|
354
|
+
fingerprint_version = $3,
|
|
355
|
+
claim_token = $4,
|
|
356
|
+
claimed_at = NOW(),
|
|
357
|
+
lease_until = NOW() + ($5 || ' milliseconds')::INTERVAL,
|
|
358
|
+
completed_at = NULL,
|
|
359
|
+
response_status = NULL,
|
|
360
|
+
response_headers = NULL,
|
|
361
|
+
response_body = NULL
|
|
362
|
+
WHERE effective_key = $1
|
|
363
|
+
RETURNING *
|
|
364
|
+
""",
|
|
365
|
+
effective_key,
|
|
366
|
+
fingerprint,
|
|
367
|
+
fingerprint_version,
|
|
368
|
+
claim_token,
|
|
369
|
+
str(lease_ms),
|
|
370
|
+
)
|
|
371
|
+
if corrupt is None:
|
|
372
|
+
raise StorageError(
|
|
373
|
+
"postgres: corrupt record vanished during reclaim"
|
|
374
|
+
) from None
|
|
375
|
+
return ClaimResult(
|
|
376
|
+
ClaimResultType.NEW_CLAIMED,
|
|
377
|
+
self._row_to_record(corrupt),
|
|
378
|
+
claim_token,
|
|
379
|
+
recovered_from_corrupt=True,
|
|
380
|
+
)
|
|
381
|
+
if record.state == State.COMPLETED:
|
|
382
|
+
return ClaimResult(ClaimResultType.ALREADY_COMPLETED, record)
|
|
383
|
+
return ClaimResult(ClaimResultType.ALREADY_CLAIMED, record)
|
|
384
|
+
except StorageError:
|
|
385
|
+
raise
|
|
386
|
+
except Exception as e:
|
|
387
|
+
raise StorageError(f"postgres claim failed: {e}") from e
|
|
388
|
+
|
|
389
|
+
async def complete(
|
|
390
|
+
self,
|
|
391
|
+
effective_key: str,
|
|
392
|
+
claim_token: str,
|
|
393
|
+
response_status: int,
|
|
394
|
+
response_headers: dict[str, str],
|
|
395
|
+
response_body: bytes,
|
|
396
|
+
expires_after_seconds: float,
|
|
397
|
+
) -> bool:
|
|
398
|
+
completed_ms = int(expires_after_seconds * 1000)
|
|
399
|
+
await self._ensure_pool()
|
|
400
|
+
try:
|
|
401
|
+
async with self._pool.acquire() as conn, conn.transaction():
|
|
402
|
+
# lease_until is repurposed as the COMPLETED record's expiry
|
|
403
|
+
# (completed_at + completed_ttl). The claim path treats any
|
|
404
|
+
# record past lease_until as ABSENT, so completed records
|
|
405
|
+
# expire on read uniformly with the other backends (§4.8),
|
|
406
|
+
# not only when pg-vacuum runs.
|
|
407
|
+
row = await conn.fetchrow(
|
|
408
|
+
f"""
|
|
409
|
+
UPDATE {self._table}
|
|
410
|
+
SET state = 'COMPLETED',
|
|
411
|
+
completed_at = NOW(),
|
|
412
|
+
lease_until = NOW() + ($6 || ' milliseconds')::INTERVAL,
|
|
413
|
+
response_status = $2,
|
|
414
|
+
response_headers = $3::jsonb,
|
|
415
|
+
response_body = $4
|
|
416
|
+
WHERE effective_key = $1
|
|
417
|
+
AND state = 'CLAIMED'
|
|
418
|
+
AND claim_token = $5
|
|
419
|
+
RETURNING effective_key
|
|
420
|
+
""",
|
|
421
|
+
effective_key,
|
|
422
|
+
response_status,
|
|
423
|
+
json.dumps(response_headers),
|
|
424
|
+
response_body,
|
|
425
|
+
claim_token,
|
|
426
|
+
str(completed_ms),
|
|
427
|
+
)
|
|
428
|
+
if row is None:
|
|
429
|
+
return False
|
|
430
|
+
await conn.execute(
|
|
431
|
+
"SELECT pg_notify($1, $2)",
|
|
432
|
+
NOTIFY_CHANNEL,
|
|
433
|
+
effective_key,
|
|
434
|
+
)
|
|
435
|
+
return True
|
|
436
|
+
except Exception as e:
|
|
437
|
+
raise StorageError(f"postgres complete failed: {e}") from e
|
|
438
|
+
|
|
439
|
+
async def release(self, effective_key: str, claim_token: str) -> bool:
|
|
440
|
+
await self._ensure_pool()
|
|
441
|
+
try:
|
|
442
|
+
async with self._pool.acquire() as conn, conn.transaction():
|
|
443
|
+
row = await conn.fetchrow(
|
|
444
|
+
f"""
|
|
445
|
+
DELETE FROM {self._table}
|
|
446
|
+
WHERE effective_key = $1
|
|
447
|
+
AND state = 'CLAIMED'
|
|
448
|
+
AND claim_token = $2
|
|
449
|
+
RETURNING effective_key
|
|
450
|
+
""",
|
|
451
|
+
effective_key,
|
|
452
|
+
claim_token,
|
|
453
|
+
)
|
|
454
|
+
if row is None:
|
|
455
|
+
return False
|
|
456
|
+
await conn.execute(
|
|
457
|
+
"SELECT pg_notify($1, $2)",
|
|
458
|
+
NOTIFY_CHANNEL,
|
|
459
|
+
effective_key,
|
|
460
|
+
)
|
|
461
|
+
return True
|
|
462
|
+
except Exception as e:
|
|
463
|
+
raise StorageError(f"postgres release failed: {e}") from e
|
|
464
|
+
|
|
465
|
+
async def renew(
|
|
466
|
+
self,
|
|
467
|
+
effective_key: str,
|
|
468
|
+
claim_token: str,
|
|
469
|
+
lease_ttl_seconds: float,
|
|
470
|
+
) -> bool:
|
|
471
|
+
"""Extend a live claim's lease using the storage clock (spec §5.3.1)."""
|
|
472
|
+
lease_ms = int(lease_ttl_seconds * 1000)
|
|
473
|
+
await self._ensure_pool()
|
|
474
|
+
try:
|
|
475
|
+
async with self._pool.acquire() as conn:
|
|
476
|
+
row = await conn.fetchrow(
|
|
477
|
+
f"""
|
|
478
|
+
UPDATE {self._table}
|
|
479
|
+
SET lease_until = NOW() + ($3 || ' milliseconds')::INTERVAL
|
|
480
|
+
WHERE effective_key = $1
|
|
481
|
+
AND state = 'CLAIMED'
|
|
482
|
+
AND claim_token = $2
|
|
483
|
+
RETURNING effective_key
|
|
484
|
+
""",
|
|
485
|
+
effective_key,
|
|
486
|
+
claim_token,
|
|
487
|
+
str(lease_ms),
|
|
488
|
+
)
|
|
489
|
+
return row is not None
|
|
490
|
+
except Exception as e:
|
|
491
|
+
raise StorageError(f"postgres renew failed: {e}") from e
|
|
492
|
+
|
|
493
|
+
async def wait_for_completion(
|
|
494
|
+
self,
|
|
495
|
+
effective_key: str,
|
|
496
|
+
timeout_seconds: float,
|
|
497
|
+
) -> StoredRecord | None:
|
|
498
|
+
await self._ensure_pool()
|
|
499
|
+
await self._ensure_listener()
|
|
500
|
+
|
|
501
|
+
event = asyncio.Event()
|
|
502
|
+
self._waiters.setdefault(effective_key, set()).add(event)
|
|
503
|
+
|
|
504
|
+
# Subscribe-then-read (spec §4.3), then wait on a NOTIFICATION-OR-POLL
|
|
505
|
+
# schedule (spec §5.5). LISTEN/NOTIFY is at-most-once — a notification is
|
|
506
|
+
# lost if the dedicated LISTEN connection drops (server restart, failover)
|
|
507
|
+
# before it re-subscribes. The notification is the latency optimization;
|
|
508
|
+
# the bounded backed-off SELECT is the correctness floor.
|
|
509
|
+
deadline = time.monotonic() + timeout_seconds
|
|
510
|
+
poll = _POLL_INITIAL_SECONDS
|
|
511
|
+
try:
|
|
512
|
+
while True:
|
|
513
|
+
record = await self._get_record(effective_key)
|
|
514
|
+
if record is None:
|
|
515
|
+
return None
|
|
516
|
+
if record.state == State.COMPLETED:
|
|
517
|
+
return record
|
|
518
|
+
remaining = deadline - time.monotonic()
|
|
519
|
+
if remaining <= 0:
|
|
520
|
+
return None
|
|
521
|
+
event.clear()
|
|
522
|
+
try:
|
|
523
|
+
await asyncio.wait_for(event.wait(), timeout=min(poll, remaining))
|
|
524
|
+
except asyncio.TimeoutError:
|
|
525
|
+
pass
|
|
526
|
+
poll = min(poll * 2, _POLL_CAP_SECONDS)
|
|
527
|
+
finally:
|
|
528
|
+
waiters = self._waiters.get(effective_key)
|
|
529
|
+
if waiters is not None:
|
|
530
|
+
waiters.discard(event)
|
|
531
|
+
if not waiters:
|
|
532
|
+
self._waiters.pop(effective_key, None)
|
|
533
|
+
|
|
534
|
+
# ----- internals -----
|
|
535
|
+
|
|
536
|
+
async def _get_record(self, effective_key: str) -> StoredRecord | None:
|
|
537
|
+
try:
|
|
538
|
+
async with self._pool.acquire() as conn:
|
|
539
|
+
row = await conn.fetchrow(
|
|
540
|
+
f"SELECT * FROM {self._table} WHERE effective_key = $1",
|
|
541
|
+
effective_key,
|
|
542
|
+
)
|
|
543
|
+
except Exception as e:
|
|
544
|
+
raise StorageError(f"postgres select failed: {e}") from e
|
|
545
|
+
if row is None:
|
|
546
|
+
return None
|
|
547
|
+
try:
|
|
548
|
+
return self._row_to_record(row)
|
|
549
|
+
except Exception:
|
|
550
|
+
# Corrupt row (§4.12): treat as ABSENT
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
def _row_to_record(self, row: Any) -> StoredRecord:
|
|
554
|
+
response_headers_raw = row["response_headers"]
|
|
555
|
+
if isinstance(response_headers_raw, str):
|
|
556
|
+
response_headers = json.loads(response_headers_raw)
|
|
557
|
+
elif response_headers_raw is None:
|
|
558
|
+
response_headers = {}
|
|
559
|
+
else:
|
|
560
|
+
response_headers = dict(response_headers_raw)
|
|
561
|
+
response_body = row["response_body"]
|
|
562
|
+
if response_body is None:
|
|
563
|
+
response_body = b""
|
|
564
|
+
else:
|
|
565
|
+
response_body = bytes(response_body)
|
|
566
|
+
return StoredRecord(
|
|
567
|
+
effective_key=row["effective_key"],
|
|
568
|
+
state=State(row["state"]),
|
|
569
|
+
fingerprint=row["fingerprint"],
|
|
570
|
+
fingerprint_version=row["fingerprint_version"],
|
|
571
|
+
claim_token=row["claim_token"],
|
|
572
|
+
claimed_at=row["claimed_at"].timestamp(),
|
|
573
|
+
lease_until=row["lease_until"].timestamp(),
|
|
574
|
+
completed_at=(
|
|
575
|
+
row["completed_at"].timestamp() if row["completed_at"] is not None else None
|
|
576
|
+
),
|
|
577
|
+
response_status=row["response_status"],
|
|
578
|
+
response_headers=response_headers,
|
|
579
|
+
response_body=response_body,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
async def _ensure_listener(self) -> None:
|
|
583
|
+
if self._listen_conn is not None and not self._listen_conn.is_closed():
|
|
584
|
+
return
|
|
585
|
+
async with self._listen_lock:
|
|
586
|
+
if self._listen_conn is not None and not self._listen_conn.is_closed():
|
|
587
|
+
return
|
|
588
|
+
# A prior LISTEN connection may have died (a failover, an idle drop).
|
|
589
|
+
# Drop the stale handle so we re-establish NOTIFY, instead of leaving
|
|
590
|
+
# waiters on the poll floor for the rest of the process lifetime.
|
|
591
|
+
if self._listen_conn is not None:
|
|
592
|
+
try:
|
|
593
|
+
await self._pool.release(self._listen_conn)
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
596
|
+
self._listen_conn = None
|
|
597
|
+
conn = await self._pool.acquire()
|
|
598
|
+
try:
|
|
599
|
+
await conn.add_listener(NOTIFY_CHANNEL, self._on_notification)
|
|
600
|
+
except Exception:
|
|
601
|
+
await self._pool.release(conn)
|
|
602
|
+
raise
|
|
603
|
+
# Re-establish NOTIFY automatically on the next waiter if this
|
|
604
|
+
# connection is later terminated by the server.
|
|
605
|
+
conn.add_termination_listener(self._on_listener_terminated)
|
|
606
|
+
self._listen_conn = conn
|
|
607
|
+
|
|
608
|
+
def _on_listener_terminated(self, connection: Any) -> None:
|
|
609
|
+
# The LISTEN connection was closed (server failover / idle timeout). Null
|
|
610
|
+
# our handle so the next wait_for_completion rebuilds the listener rather
|
|
611
|
+
# than polling forever. The pool discards the dead connection itself.
|
|
612
|
+
if self._listen_conn is connection:
|
|
613
|
+
self._listen_conn = None
|
|
614
|
+
|
|
615
|
+
def _on_notification(
|
|
616
|
+
self,
|
|
617
|
+
connection: Any,
|
|
618
|
+
pid: int,
|
|
619
|
+
channel: str,
|
|
620
|
+
payload: str,
|
|
621
|
+
) -> None:
|
|
622
|
+
waiters = self._waiters.get(payload)
|
|
623
|
+
if not waiters:
|
|
624
|
+
return
|
|
625
|
+
for event in list(waiters):
|
|
626
|
+
event.set()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ----- CLI-callable helpers -----
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
async def init_pg(database_url: str, *, table: str = DEFAULT_TABLE) -> None:
|
|
633
|
+
"""Create the records table and indexes (default ``idemkit_records``). Idempotent."""
|
|
634
|
+
_validate_table(table)
|
|
635
|
+
try:
|
|
636
|
+
import asyncpg
|
|
637
|
+
except ImportError as e:
|
|
638
|
+
raise ImportError(
|
|
639
|
+
"idemkit: install the `postgres` extra: pip install 'idemkit[postgres]'"
|
|
640
|
+
) from e
|
|
641
|
+
conn = await asyncpg.connect(database_url)
|
|
642
|
+
try:
|
|
643
|
+
await conn.execute(_schema_sql(table))
|
|
644
|
+
finally:
|
|
645
|
+
await conn.close()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
async def pg_vacuum(
|
|
649
|
+
database_url: str,
|
|
650
|
+
*,
|
|
651
|
+
table: str = DEFAULT_TABLE,
|
|
652
|
+
expires_after_seconds: float = 86_400.0,
|
|
653
|
+
lease_grace_seconds: float = 60.0,
|
|
654
|
+
) -> int:
|
|
655
|
+
"""Delete expired records. Returns the number of rows removed."""
|
|
656
|
+
_validate_table(table)
|
|
657
|
+
try:
|
|
658
|
+
import asyncpg
|
|
659
|
+
except ImportError as e:
|
|
660
|
+
raise ImportError(
|
|
661
|
+
"idemkit: install the `postgres` extra: pip install 'idemkit[postgres]'"
|
|
662
|
+
) from e
|
|
663
|
+
conn = await asyncpg.connect(database_url)
|
|
664
|
+
try:
|
|
665
|
+
result = await conn.execute(
|
|
666
|
+
f"""
|
|
667
|
+
DELETE FROM {table}
|
|
668
|
+
WHERE (state = 'COMPLETED'
|
|
669
|
+
AND completed_at + ($1 || ' seconds')::INTERVAL < NOW())
|
|
670
|
+
OR (state = 'CLAIMED'
|
|
671
|
+
AND lease_until + ($2 || ' seconds')::INTERVAL < NOW())
|
|
672
|
+
""",
|
|
673
|
+
str(int(expires_after_seconds)),
|
|
674
|
+
str(int(lease_grace_seconds)),
|
|
675
|
+
)
|
|
676
|
+
# result is like "DELETE n"
|
|
677
|
+
try:
|
|
678
|
+
return int(result.split()[-1])
|
|
679
|
+
except (ValueError, IndexError):
|
|
680
|
+
return 0
|
|
681
|
+
finally:
|
|
682
|
+
await conn.close()
|