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,395 @@
1
+ """MongoDB backend for idemkit (spec §4.1).
2
+
3
+ One document per ``effective_key`` (``_id``), the same single-record layout as the
4
+ Postgres backend. Correctness rests on MongoDB's per-document atomicity plus the
5
+ server clock: every claim/complete/renew is an aggregation-pipeline update that uses
6
+ ``$$NOW`` (the server's time), so a skewed app clock cannot cause a wrongful reclaim
7
+ (spec §5.7). ``lease_until`` doubles as the COMPLETED record's expiry, so an expired
8
+ record is treated as ABSENT on read, exactly like the other backends.
9
+
10
+ A TTL index on ``expire_at`` (the lease plus a grace period) lets MongoDB reap
11
+ expired docs on its own, so (like Redis) there is nothing to vacuum. The grace keeps
12
+ a just-lapsed claim readable long enough to be reclaimed as ``lease_reclaimed``
13
+ rather than vanishing. In-flight waiters poll (there is no push channel); that is the
14
+ correctness floor every backend already falls back to.
15
+
16
+ Requires the ``mongo`` extra: ``pip install 'idemkit[mongo]'``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import secrets
22
+ import time
23
+ from datetime import datetime, timezone
24
+ from typing import Any
25
+
26
+ from idemkit.core.exceptions import StorageError
27
+ from idemkit.core.state import ClaimResult, ClaimResultType, State, StoredRecord
28
+
29
+ DEFAULT_COLLECTION = "idemkit_records"
30
+
31
+ # In-flight waiters poll on a backed-off schedule (spec §5.5); the bounded poll is
32
+ # the correctness floor. MongoDB change streams need a replica set, so we do not
33
+ # rely on a push notification here.
34
+ _POLL_INITIAL_SECONDS = 0.05
35
+ _POLL_CAP_SECONDS = 1.0
36
+
37
+
38
+ def _epoch(value: Any) -> float:
39
+ """A BSON Date (or None) to epoch seconds."""
40
+ if isinstance(value, datetime):
41
+ if value.tzinfo is None:
42
+ value = value.replace(tzinfo=timezone.utc)
43
+ return float(value.timestamp())
44
+ raise TypeError(f"expected a datetime, got {type(value).__name__}")
45
+
46
+
47
+ class MongoBackend:
48
+ """MongoDB idempotency backend."""
49
+
50
+ def __init__(
51
+ self,
52
+ collection: Any = None,
53
+ *,
54
+ lease_grace_seconds: float = 60.0,
55
+ _url: str | None = None,
56
+ _database: str | None = None,
57
+ _collection_name: str = DEFAULT_COLLECTION,
58
+ _client_kwargs: dict[str, Any] | None = None,
59
+ ) -> None:
60
+ """Construct with an existing (async) pymongo collection, or via
61
+ :meth:`from_url` for lazy initialization.
62
+
63
+ ``lease_grace_seconds`` keeps an expired record readable past its lease before
64
+ the TTL index reaps it, so a just-lapsed claim is still visible to be reclaimed
65
+ (reported as ``lease_reclaimed``) rather than vanishing (reported as ``new``).
66
+ """
67
+ self._collection = collection
68
+ self._grace_ms = int(lease_grace_seconds * 1000)
69
+ self._url = _url
70
+ self._database = _database
71
+ self._collection_name = _collection_name
72
+ self._client_kwargs = _client_kwargs or {}
73
+ self._client: Any = None
74
+ import asyncio
75
+
76
+ self._init_lock = asyncio.Lock()
77
+
78
+ @classmethod
79
+ def from_url(
80
+ cls,
81
+ url: str,
82
+ *,
83
+ database: str = "idemkit",
84
+ collection: str = DEFAULT_COLLECTION,
85
+ lease_grace_seconds: float = 60.0,
86
+ **kwargs: Any,
87
+ ) -> MongoBackend:
88
+ """Construct from a ``mongodb://`` URL with lazy initialization.
89
+
90
+ The client connects and the TTL index is created on first use, so this is
91
+ safe to wire in at construction time. A default ``timeoutMS`` caps ops so a
92
+ hung server fails fast (fail-closed) rather than blocking; override it.
93
+ """
94
+ kwargs.setdefault("timeoutMS", 10_000)
95
+ return cls(
96
+ None,
97
+ lease_grace_seconds=lease_grace_seconds,
98
+ _url=url,
99
+ _database=database,
100
+ _collection_name=collection,
101
+ _client_kwargs=kwargs,
102
+ )
103
+
104
+ async def _ensure_collection(self) -> Any:
105
+ if self._collection is not None:
106
+ return self._collection
107
+ async with self._init_lock:
108
+ if self._collection is not None:
109
+ return self._collection
110
+ try:
111
+ from pymongo import AsyncMongoClient
112
+ except ImportError as e:
113
+ raise ImportError(
114
+ "idemkit: install the `mongo` extra: pip install 'idemkit[mongo]'"
115
+ ) from e
116
+ if self._url is None:
117
+ raise StorageError("mongo: no collection and no URL configured")
118
+ self._client = AsyncMongoClient(self._url, **self._client_kwargs)
119
+ coll = self._client[self._database][self._collection_name]
120
+ # Reap expired docs automatically (a claim past lease_until + grace), so
121
+ # there is no vacuum step. Correctness never depends on it (expiry is
122
+ # enforced on read); this only bounds disk. The TTL is on expire_at, not
123
+ # lease_until, so the grace window keeps a lapsed claim reclaimable.
124
+ try:
125
+ await coll.create_index("expire_at", expireAfterSeconds=0)
126
+ except Exception:
127
+ pass # index may already exist with different options; not fatal
128
+ self._collection = coll
129
+ return coll
130
+
131
+ async def aclose(self) -> None:
132
+ """Close the MongoDB client."""
133
+ if self._client is not None:
134
+ await self._client.close()
135
+ self._client = None
136
+
137
+ async def __aenter__(self) -> MongoBackend:
138
+ return self
139
+
140
+ async def __aexit__(self, *exc: object) -> None:
141
+ await self.aclose()
142
+
143
+ # ----- spec §4.1 backend Protocol -----
144
+
145
+ async def claim(
146
+ self,
147
+ effective_key: str,
148
+ fingerprint: str,
149
+ fingerprint_version: int,
150
+ lease_ttl_seconds: float,
151
+ ) -> ClaimResult:
152
+ coll = await self._ensure_collection()
153
+ token = secrets.token_hex(16)
154
+ lease_ms = int(lease_ttl_seconds * 1000)
155
+ # Take the key if it is new OR its lease has expired (by the SERVER clock),
156
+ # else leave the live record untouched. One atomic pipeline update.
157
+ pipeline = [
158
+ {
159
+ "$set": {
160
+ "__take": {
161
+ "$or": [
162
+ {"$eq": [{"$type": "$state"}, "missing"]},
163
+ {"$lt": ["$lease_until", "$$NOW"]},
164
+ ]
165
+ }
166
+ }
167
+ },
168
+ {
169
+ "$set": {
170
+ # Capture the pre-claim state (all RHS see the stage's input doc)
171
+ # so one atomic op can tell a fresh claim from a lease reclaim,
172
+ # with no second read to race against.
173
+ "__prev_state": "$state",
174
+ "state": {"$cond": ["$__take", "CLAIMED", "$state"]},
175
+ "fingerprint": {"$cond": ["$__take", fingerprint, "$fingerprint"]},
176
+ "fingerprint_version": {
177
+ "$cond": ["$__take", fingerprint_version, "$fingerprint_version"]
178
+ },
179
+ "claim_token": {"$cond": ["$__take", token, "$claim_token"]},
180
+ "claimed_at": {"$cond": ["$__take", "$$NOW", "$claimed_at"]},
181
+ "lease_until": {
182
+ "$cond": ["$__take", {"$add": ["$$NOW", lease_ms]}, "$lease_until"]
183
+ },
184
+ "expire_at": {
185
+ "$cond": [
186
+ "$__take",
187
+ {"$add": ["$$NOW", lease_ms + self._grace_ms]},
188
+ "$expire_at",
189
+ ]
190
+ },
191
+ "completed_at": {"$cond": ["$__take", None, "$completed_at"]},
192
+ "response_status": {"$cond": ["$__take", None, "$response_status"]},
193
+ "response_headers": {"$cond": ["$__take", {}, "$response_headers"]},
194
+ "response_body": {"$cond": ["$__take", b"", "$response_body"]},
195
+ }
196
+ },
197
+ {"$unset": "__take"},
198
+ ]
199
+ try:
200
+ from pymongo import ReturnDocument
201
+
202
+ after = await coll.find_one_and_update(
203
+ {"_id": effective_key},
204
+ pipeline,
205
+ upsert=True,
206
+ return_document=ReturnDocument.AFTER,
207
+ )
208
+ except Exception as e:
209
+ raise StorageError(f"mongo claim failed: {e}") from e
210
+
211
+ # upsert + return-AFTER always yields the doc, so there is no disappeared-record
212
+ # window to fail closed on.
213
+ took = after.get("claim_token") == token
214
+ if took:
215
+ try:
216
+ record = self._doc_to_record(after)
217
+ except Exception as e:
218
+ raise StorageError(f"mongo claim produced an unreadable record: {e}") from e
219
+ if after.get("__prev_state") == "CLAIMED":
220
+ return ClaimResult(ClaimResultType.LEASE_RECLAIMED, record, token)
221
+ return ClaimResult(ClaimResultType.NEW_CLAIMED, record, token)
222
+
223
+ # We did not take it: a live record is held by someone else.
224
+ try:
225
+ record = self._doc_to_record(after)
226
+ except Exception:
227
+ # §4.12: an unreadable record is treated as ABSENT and re-claimed fresh.
228
+ return await self._force_claim(
229
+ coll, effective_key, fingerprint, fingerprint_version, lease_ms
230
+ )
231
+ if record.state == State.COMPLETED:
232
+ return ClaimResult(ClaimResultType.ALREADY_COMPLETED, record)
233
+ return ClaimResult(ClaimResultType.ALREADY_CLAIMED, record)
234
+
235
+ async def _force_claim(
236
+ self,
237
+ coll: Any,
238
+ effective_key: str,
239
+ fingerprint: str,
240
+ fingerprint_version: int,
241
+ lease_ms: int,
242
+ ) -> ClaimResult:
243
+ """Overwrite a corrupt record with a fresh claim (§4.12)."""
244
+ token = secrets.token_hex(16)
245
+ pipeline = [
246
+ {
247
+ "$set": {
248
+ "state": "CLAIMED",
249
+ "fingerprint": fingerprint,
250
+ "fingerprint_version": fingerprint_version,
251
+ "claim_token": token,
252
+ "claimed_at": "$$NOW",
253
+ "lease_until": {"$add": ["$$NOW", lease_ms]},
254
+ "expire_at": {"$add": ["$$NOW", lease_ms + self._grace_ms]},
255
+ "completed_at": None,
256
+ "response_status": None,
257
+ "response_headers": {},
258
+ "response_body": b"",
259
+ }
260
+ }
261
+ ]
262
+ try:
263
+ from pymongo import ReturnDocument
264
+
265
+ after = await coll.find_one_and_update(
266
+ {"_id": effective_key}, pipeline, upsert=True, return_document=ReturnDocument.AFTER
267
+ )
268
+ except Exception as e:
269
+ raise StorageError(f"mongo corrupt-record reclaim failed: {e}") from e
270
+ return ClaimResult(
271
+ ClaimResultType.NEW_CLAIMED,
272
+ self._doc_to_record(after),
273
+ token,
274
+ recovered_from_corrupt=True,
275
+ )
276
+
277
+ async def complete(
278
+ self,
279
+ effective_key: str,
280
+ claim_token: str,
281
+ response_status: int,
282
+ response_headers: dict[str, str],
283
+ response_body: bytes,
284
+ expires_after_seconds: float,
285
+ ) -> bool:
286
+ coll = await self._ensure_collection()
287
+ completed_ms = int(expires_after_seconds * 1000)
288
+ # lease_until is repurposed as the COMPLETED record's expiry, so the claim
289
+ # path expires it on read like every other backend (§4.8).
290
+ pipeline = [
291
+ {
292
+ "$set": {
293
+ "state": "COMPLETED",
294
+ "completed_at": "$$NOW",
295
+ "lease_until": {"$add": ["$$NOW", completed_ms]},
296
+ "expire_at": {"$add": ["$$NOW", completed_ms + self._grace_ms]},
297
+ "response_status": response_status,
298
+ "response_headers": response_headers,
299
+ "response_body": response_body,
300
+ }
301
+ }
302
+ ]
303
+ try:
304
+ result = await coll.update_one(
305
+ {"_id": effective_key, "state": "CLAIMED", "claim_token": claim_token}, pipeline
306
+ )
307
+ except Exception as e:
308
+ raise StorageError(f"mongo complete failed: {e}") from e
309
+ return bool(result.matched_count == 1)
310
+
311
+ async def release(self, effective_key: str, claim_token: str) -> bool:
312
+ coll = await self._ensure_collection()
313
+ try:
314
+ result = await coll.delete_one(
315
+ {"_id": effective_key, "state": "CLAIMED", "claim_token": claim_token}
316
+ )
317
+ except Exception as e:
318
+ raise StorageError(f"mongo release failed: {e}") from e
319
+ return bool(result.deleted_count == 1)
320
+
321
+ async def renew(
322
+ self,
323
+ effective_key: str,
324
+ claim_token: str,
325
+ lease_ttl_seconds: float,
326
+ ) -> bool:
327
+ coll = await self._ensure_collection()
328
+ lease_ms = int(lease_ttl_seconds * 1000)
329
+ pipeline = [
330
+ {
331
+ "$set": {
332
+ "lease_until": {"$add": ["$$NOW", lease_ms]},
333
+ "expire_at": {"$add": ["$$NOW", lease_ms + self._grace_ms]},
334
+ }
335
+ }
336
+ ]
337
+ try:
338
+ result = await coll.update_one(
339
+ {"_id": effective_key, "state": "CLAIMED", "claim_token": claim_token}, pipeline
340
+ )
341
+ except Exception as e:
342
+ raise StorageError(f"mongo renew failed: {e}") from e
343
+ return bool(result.matched_count == 1)
344
+
345
+ async def wait_for_completion(
346
+ self,
347
+ effective_key: str,
348
+ timeout_seconds: float,
349
+ ) -> StoredRecord | None:
350
+ import asyncio
351
+
352
+ deadline = time.monotonic() + timeout_seconds
353
+ poll = _POLL_INITIAL_SECONDS
354
+ while True:
355
+ record = await self._get_record(effective_key)
356
+ if record is None:
357
+ return None
358
+ if record.state == State.COMPLETED:
359
+ return record
360
+ remaining = deadline - time.monotonic()
361
+ if remaining <= 0:
362
+ return None
363
+ await asyncio.sleep(min(poll, remaining))
364
+ poll = min(poll * 2, _POLL_CAP_SECONDS)
365
+
366
+ # ----- internals -----
367
+
368
+ async def _get_record(self, effective_key: str) -> StoredRecord | None:
369
+ coll = await self._ensure_collection()
370
+ try:
371
+ doc = await coll.find_one({"_id": effective_key})
372
+ except Exception as e:
373
+ raise StorageError(f"mongo read failed: {e}") from e
374
+ if doc is None:
375
+ return None
376
+ try:
377
+ return self._doc_to_record(doc)
378
+ except Exception:
379
+ return None
380
+
381
+ def _doc_to_record(self, doc: dict[str, Any]) -> StoredRecord:
382
+ body = doc.get("response_body") or b""
383
+ return StoredRecord(
384
+ effective_key=doc["_id"],
385
+ state=State(doc["state"]),
386
+ fingerprint=doc["fingerprint"],
387
+ fingerprint_version=int(doc["fingerprint_version"]),
388
+ claim_token=doc["claim_token"],
389
+ claimed_at=_epoch(doc["claimed_at"]),
390
+ lease_until=_epoch(doc["lease_until"]),
391
+ completed_at=_epoch(doc["completed_at"]) if doc.get("completed_at") else None,
392
+ response_status=doc.get("response_status"),
393
+ response_headers=dict(doc.get("response_headers") or {}),
394
+ response_body=bytes(body),
395
+ )