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,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
|
+
)
|