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.
Files changed (48) hide show
  1. idemkit/__init__.py +101 -0
  2. idemkit/_version.py +1 -0
  3. idemkit/adapters/__init__.py +10 -0
  4. idemkit/adapters/ai.py +723 -0
  5. idemkit/adapters/asgi.py +533 -0
  6. idemkit/adapters/queue.py +413 -0
  7. idemkit/adapters/route.py +273 -0
  8. idemkit/adapters/wsgi.py +393 -0
  9. idemkit/backends/__init__.py +30 -0
  10. idemkit/backends/base.py +116 -0
  11. idemkit/backends/dynamodb.py +489 -0
  12. idemkit/backends/memory.py +325 -0
  13. idemkit/backends/mongo.py +395 -0
  14. idemkit/backends/postgres.py +682 -0
  15. idemkit/backends/redis.py +547 -0
  16. idemkit/cli.py +165 -0
  17. idemkit/conformance/__init__.py +25 -0
  18. idemkit/conformance/backend.py +257 -0
  19. idemkit/conformance/report.py +54 -0
  20. idemkit/contrib/__init__.py +30 -0
  21. idemkit/contrib/drf.py +181 -0
  22. idemkit/contrib/fastapi.py +141 -0
  23. idemkit/contrib/kafka.py +96 -0
  24. idemkit/contrib/logging.py +61 -0
  25. idemkit/contrib/mcp.py +113 -0
  26. idemkit/contrib/prometheus.py +79 -0
  27. idemkit/contrib/pubsub.py +98 -0
  28. idemkit/contrib/rabbitmq.py +138 -0
  29. idemkit/contrib/reconciliation.py +86 -0
  30. idemkit/contrib/sqs.py +117 -0
  31. idemkit/core/__init__.py +36 -0
  32. idemkit/core/codecs.py +229 -0
  33. idemkit/core/config.py +255 -0
  34. idemkit/core/engine.py +331 -0
  35. idemkit/core/events.py +63 -0
  36. idemkit/core/exception_cache.py +88 -0
  37. idemkit/core/exceptions.py +79 -0
  38. idemkit/core/fingerprint.py +153 -0
  39. idemkit/core/policy.py +143 -0
  40. idemkit/core/runner.py +664 -0
  41. idemkit/core/state.py +95 -0
  42. idemkit/core/sync_bridge.py +115 -0
  43. idemkit/problem_details.py +119 -0
  44. idemkit/py.typed +0 -0
  45. idemkit-0.1.0.dist-info/METADATA +446 -0
  46. idemkit-0.1.0.dist-info/RECORD +48 -0
  47. idemkit-0.1.0.dist-info/WHEEL +4 -0
  48. 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()