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,489 @@
1
+ """Amazon DynamoDB backend for idemkit (spec §4.1).
2
+
3
+ One item per ``effective_key`` (the partition key), the same single-record layout as
4
+ the Postgres and Mongo backends. Correctness rests on DynamoDB's conditional writes:
5
+ ``claim`` is an ``UpdateItem`` with ``ConditionExpression`` (create if absent, or
6
+ reclaim if the lease expired) and ``ReturnValues=ALL_OLD`` so it can tell a fresh
7
+ claim from a lease reclaim; ``complete`` / ``release`` / ``renew`` are conditional on
8
+ ``claim_token`` so a reclaimed executor is fenced. ``lease_until`` doubles as the
9
+ COMPLETED record's expiry, so an expired record is treated as ABSENT on read.
10
+
11
+ **One difference from the other backends:** DynamoDB has no server clock in a
12
+ condition expression, so lease decisions use the CLIENT clock.
13
+ With NTP-synced hosts this is fine, but unlike Redis/Postgres/Mongo it does not give
14
+ the storage-clock guarantee against a badly skewed app clock (spec §5.7). A DynamoDB
15
+ TTL attribute reaps expired items (best-effort, up to ~48h), so correctness relies on
16
+ expiry-on-read, not on TTL timeliness.
17
+
18
+ Requires the ``dynamodb`` extra: ``pip install 'idemkit[dynamodb]'``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import secrets
24
+ import time
25
+ from typing import Any
26
+
27
+ from idemkit.core.exceptions import StorageError
28
+ from idemkit.core.state import ClaimResult, ClaimResultType, State, StoredRecord
29
+
30
+ DEFAULT_TABLE = "idemkit_records"
31
+ PK = "effective_key"
32
+
33
+ _POLL_INITIAL_SECONDS = 0.05
34
+ _POLL_CAP_SECONDS = 1.0
35
+ _CLAIM_RETRIES = 3
36
+
37
+
38
+ class DynamoBackend:
39
+ """Amazon DynamoDB idempotency backend."""
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ table: str = DEFAULT_TABLE,
45
+ endpoint_url: str | None = None,
46
+ region_name: str = "us-east-1",
47
+ create_table: bool = True,
48
+ lease_grace_seconds: float = 60.0,
49
+ connect_timeout: float | None = None,
50
+ read_timeout: float | None = None,
51
+ max_retries: int | None = None,
52
+ **client_kwargs: Any,
53
+ ) -> None:
54
+ """Configure the backend (lazy: no connection until first use).
55
+
56
+ ``endpoint_url`` targets DynamoDB Local / LocalStack for tests. ``create_table``
57
+ makes the table (on-demand billing, TTL on ``ttl``) on first use if it does not
58
+ exist; set it to ``False`` in production and provision the table out of band so
59
+ the runtime role needs no ``CreateTable`` permission.
60
+
61
+ ``lease_grace_seconds`` pads the DynamoDB ``ttl`` attribute past the logical
62
+ expiry (which is enforced on read via ``lease_until``). The ``ttl`` attribute
63
+ has one-second granularity, so without a pad a just-lapsed claim could be
64
+ TTL-reaped before the next attempt reclaims it, turning a ``lease_reclaimed``
65
+ into a ``new`` — the same grace Redis and Mongo apply.
66
+
67
+ ``connect_timeout`` (default 5s), ``read_timeout`` (default 10s), and
68
+ ``max_retries`` (default 3) fail fast like the other backends instead of
69
+ hanging on botocore's 60s default; each default is applied only if you do not
70
+ pass it. Extra keyword args go to the aioboto3 resource (e.g. credentials);
71
+ advanced callers can pass a full ``config=Config(...)`` (botocore) that
72
+ overrides the three timeout/retry knobs.
73
+ """
74
+ self._table_name = table
75
+ self._endpoint_url = endpoint_url
76
+ self._region_name = region_name
77
+ self._create_table = create_table
78
+ self._grace_seconds = lease_grace_seconds
79
+ self._connect_timeout = connect_timeout
80
+ self._read_timeout = read_timeout
81
+ self._max_retries = max_retries
82
+ self._client_kwargs = client_kwargs
83
+ self._cm: Any = None
84
+ self._resource: Any = None
85
+ self._table: Any = None
86
+ import asyncio
87
+
88
+ self._init_lock = asyncio.Lock()
89
+
90
+ async def _ensure_table(self) -> Any:
91
+ if self._table is not None:
92
+ return self._table
93
+ async with self._init_lock:
94
+ if self._table is not None:
95
+ return self._table
96
+ try:
97
+ import aioboto3
98
+ except ImportError as e:
99
+ raise ImportError(
100
+ "idemkit: install the `dynamodb` extra: pip install 'idemkit[dynamodb]'"
101
+ ) from e
102
+ from botocore.config import Config
103
+
104
+ session = aioboto3.Session()
105
+ # Fail fast (like Redis/Postgres) instead of hanging on the botocore
106
+ # default 60s timeouts; retry transient throttling with backoff. Each
107
+ # flat knob applies its default only if the caller did not set it; a full
108
+ # ``config=`` (advanced) overrides all three.
109
+ client_kwargs = dict(self._client_kwargs)
110
+ config = client_kwargs.pop("config", None)
111
+ if config is None:
112
+ config = Config(
113
+ connect_timeout=self._connect_timeout
114
+ if self._connect_timeout is not None
115
+ else 5,
116
+ read_timeout=self._read_timeout if self._read_timeout is not None else 10,
117
+ retries={
118
+ "max_attempts": self._max_retries if self._max_retries is not None else 3,
119
+ "mode": "standard",
120
+ },
121
+ )
122
+ self._cm = session.resource(
123
+ "dynamodb",
124
+ endpoint_url=self._endpoint_url,
125
+ region_name=self._region_name,
126
+ config=config,
127
+ **client_kwargs,
128
+ )
129
+ self._resource = await self._cm.__aenter__()
130
+ try:
131
+ if self._create_table:
132
+ await self._ensure_table_exists()
133
+ self._table = await self._resource.Table(self._table_name)
134
+ except Exception:
135
+ # Don't leak the aiohttp session if setup fails; a later call retries.
136
+ await self._cm.__aexit__(None, None, None)
137
+ self._cm = self._resource = self._table = None
138
+ raise
139
+ return self._table
140
+
141
+ async def _ensure_table_exists(self) -> None:
142
+ try:
143
+ table = await self._resource.create_table(
144
+ TableName=self._table_name,
145
+ KeySchema=[{"AttributeName": PK, "KeyType": "HASH"}],
146
+ AttributeDefinitions=[{"AttributeName": PK, "AttributeType": "S"}],
147
+ BillingMode="PAY_PER_REQUEST",
148
+ )
149
+ await table.wait_until_exists()
150
+ client = self._resource.meta.client
151
+ try:
152
+ await client.update_time_to_live(
153
+ TableName=self._table_name,
154
+ TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"},
155
+ )
156
+ except Exception:
157
+ pass # TTL best-effort; expiry-on-read is what enforces correctness
158
+ except Exception as e:
159
+ # Already exists (ResourceInUseException) is fine; anything else is fatal.
160
+ if "ResourceInUse" not in type(e).__name__ and "ResourceInUse" not in str(e):
161
+ raise
162
+
163
+ async def aclose(self) -> None:
164
+ """Close the DynamoDB resource."""
165
+ if self._cm is not None:
166
+ await self._cm.__aexit__(None, None, None)
167
+ self._cm = self._resource = self._table = None
168
+
169
+ async def __aenter__(self) -> DynamoBackend:
170
+ return self
171
+
172
+ async def __aexit__(self, *exc: object) -> None:
173
+ await self.aclose()
174
+
175
+ # ----- spec §4.1 backend Protocol -----
176
+
177
+ async def claim(
178
+ self,
179
+ effective_key: str,
180
+ fingerprint: str,
181
+ fingerprint_version: int,
182
+ lease_ttl_seconds: float,
183
+ ) -> ClaimResult:
184
+ table = await self._ensure_table()
185
+ from botocore.exceptions import ClientError
186
+
187
+ # The condition can fail because a live record is held (return ALREADY_*) or
188
+ # because a concurrent release deleted it in the same instant. In the second
189
+ # case the record is gone, so retry the claim a bounded number of times.
190
+ for _attempt in range(_CLAIM_RETRIES):
191
+ token = secrets.token_hex(16)
192
+ now = time.time()
193
+ lease_until = now + lease_ttl_seconds
194
+ try:
195
+ resp = await table.update_item(
196
+ Key={PK: effective_key},
197
+ UpdateExpression=(
198
+ "SET #s = :claimed, fingerprint = :fp, fingerprint_version = :fpv, "
199
+ "claim_token = :tok, claimed_at = :now, lease_until = :lu, #ttl = :ttl "
200
+ "REMOVE completed_at, response_status, response_headers, response_body"
201
+ ),
202
+ ConditionExpression="attribute_not_exists(#pk) OR lease_until < :now",
203
+ ExpressionAttributeNames={"#s": "state", "#pk": PK, "#ttl": "ttl"},
204
+ ExpressionAttributeValues={
205
+ ":claimed": "CLAIMED",
206
+ ":fp": fingerprint,
207
+ ":fpv": fingerprint_version,
208
+ ":tok": token,
209
+ ":now": _num(now),
210
+ ":lu": _num(lease_until),
211
+ ":ttl": int(lease_until + self._grace_seconds),
212
+ },
213
+ ReturnValues="ALL_OLD",
214
+ )
215
+ except ClientError as e:
216
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
217
+ already = await self._already(
218
+ table, effective_key, fingerprint, fingerprint_version, lease_ttl_seconds
219
+ )
220
+ if already is not None:
221
+ return already
222
+ continue # record vanished under us; retry the claim
223
+ raise StorageError(f"dynamodb claim failed: {e}") from e
224
+ except Exception as e:
225
+ raise StorageError(f"dynamodb claim failed: {e}") from e
226
+
227
+ record = self._record(
228
+ effective_key,
229
+ State.CLAIMED,
230
+ fingerprint,
231
+ fingerprint_version,
232
+ token,
233
+ now,
234
+ lease_until,
235
+ )
236
+ old = resp.get("Attributes")
237
+ if old and old.get("state") == "CLAIMED":
238
+ return ClaimResult(ClaimResultType.LEASE_RECLAIMED, record, token)
239
+ return ClaimResult(ClaimResultType.NEW_CLAIMED, record, token)
240
+
241
+ raise StorageError("dynamodb: claim contended repeatedly; retry")
242
+
243
+ async def _already(
244
+ self,
245
+ table: Any,
246
+ effective_key: str,
247
+ fingerprint: str,
248
+ fingerprint_version: int,
249
+ lease_ttl_seconds: float,
250
+ ) -> ClaimResult | None:
251
+ """Read the record that made the conditional claim fail. Returns ``None`` if
252
+ it vanished (caller retries the claim), or force-reclaims a corrupt one."""
253
+ try:
254
+ resp = await table.get_item(Key={PK: effective_key}, ConsistentRead=True)
255
+ except Exception as e:
256
+ raise StorageError(f"dynamodb read failed: {e}") from e
257
+ item = resp.get("Item")
258
+ if item is None:
259
+ return None # released between our failed claim and this read; retry
260
+ try:
261
+ record = self._item_to_record(item)
262
+ except Exception:
263
+ # §4.12: an unreadable record is treated as ABSENT and re-claimed fresh.
264
+ return await self._force_claim(
265
+ table, effective_key, fingerprint, fingerprint_version, lease_ttl_seconds
266
+ )
267
+ if record.state == State.COMPLETED:
268
+ return ClaimResult(ClaimResultType.ALREADY_COMPLETED, record)
269
+ return ClaimResult(ClaimResultType.ALREADY_CLAIMED, record)
270
+
271
+ async def _force_claim(
272
+ self,
273
+ table: Any,
274
+ effective_key: str,
275
+ fingerprint: str,
276
+ fingerprint_version: int,
277
+ lease_ttl_seconds: float,
278
+ ) -> ClaimResult:
279
+ """Overwrite a corrupt record with a fresh claim, unconditionally (§4.12)."""
280
+ token = secrets.token_hex(16)
281
+ now = time.time()
282
+ lease_until = now + lease_ttl_seconds
283
+ try:
284
+ await table.update_item(
285
+ Key={PK: effective_key},
286
+ UpdateExpression=(
287
+ "SET #s = :claimed, fingerprint = :fp, fingerprint_version = :fpv, "
288
+ "claim_token = :tok, claimed_at = :now, lease_until = :lu, #ttl = :ttl "
289
+ "REMOVE completed_at, response_status, response_headers, response_body"
290
+ ),
291
+ ExpressionAttributeNames={"#s": "state", "#ttl": "ttl"},
292
+ ExpressionAttributeValues={
293
+ ":claimed": "CLAIMED",
294
+ ":fp": fingerprint,
295
+ ":fpv": fingerprint_version,
296
+ ":tok": token,
297
+ ":now": _num(now),
298
+ ":lu": _num(lease_until),
299
+ ":ttl": int(lease_until + self._grace_seconds),
300
+ },
301
+ )
302
+ except Exception as e:
303
+ raise StorageError(f"dynamodb corrupt-record reclaim failed: {e}") from e
304
+ record = self._record(
305
+ effective_key, State.CLAIMED, fingerprint, fingerprint_version, token, now, lease_until
306
+ )
307
+ return ClaimResult(ClaimResultType.NEW_CLAIMED, record, token, recovered_from_corrupt=True)
308
+
309
+ async def complete(
310
+ self,
311
+ effective_key: str,
312
+ claim_token: str,
313
+ response_status: int,
314
+ response_headers: dict[str, str],
315
+ response_body: bytes,
316
+ expires_after_seconds: float,
317
+ ) -> bool:
318
+ table = await self._ensure_table()
319
+ now = time.time()
320
+ expiry = now + expires_after_seconds
321
+ from botocore.exceptions import ClientError
322
+
323
+ try:
324
+ await table.update_item(
325
+ Key={PK: effective_key},
326
+ UpdateExpression=(
327
+ "SET #s = :completed, completed_at = :now, lease_until = :exp, #ttl = :ttl, "
328
+ "response_status = :st, response_headers = :hd, response_body = :bd"
329
+ ),
330
+ ConditionExpression="#s = :claimed AND claim_token = :tok",
331
+ ExpressionAttributeNames={"#s": "state", "#ttl": "ttl"},
332
+ ExpressionAttributeValues={
333
+ ":completed": "COMPLETED",
334
+ ":claimed": "CLAIMED",
335
+ ":tok": claim_token,
336
+ ":now": _num(now),
337
+ ":exp": _num(expiry),
338
+ ":ttl": int(expiry + self._grace_seconds),
339
+ ":st": response_status,
340
+ ":hd": response_headers,
341
+ ":bd": response_body,
342
+ },
343
+ )
344
+ return True
345
+ except ClientError as e:
346
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
347
+ return False
348
+ raise StorageError(f"dynamodb complete failed: {e}") from e
349
+ except Exception as e:
350
+ raise StorageError(f"dynamodb complete failed: {e}") from e
351
+
352
+ async def release(self, effective_key: str, claim_token: str) -> bool:
353
+ table = await self._ensure_table()
354
+ from botocore.exceptions import ClientError
355
+
356
+ try:
357
+ await table.delete_item(
358
+ Key={PK: effective_key},
359
+ ConditionExpression="#s = :claimed AND claim_token = :tok",
360
+ ExpressionAttributeNames={"#s": "state"},
361
+ ExpressionAttributeValues={":claimed": "CLAIMED", ":tok": claim_token},
362
+ )
363
+ return True
364
+ except ClientError as e:
365
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
366
+ return False
367
+ raise StorageError(f"dynamodb release failed: {e}") from e
368
+ except Exception as e:
369
+ raise StorageError(f"dynamodb release failed: {e}") from e
370
+
371
+ async def renew(
372
+ self,
373
+ effective_key: str,
374
+ claim_token: str,
375
+ lease_ttl_seconds: float,
376
+ ) -> bool:
377
+ table = await self._ensure_table()
378
+ now = time.time()
379
+ lease_until = now + lease_ttl_seconds
380
+ from botocore.exceptions import ClientError
381
+
382
+ try:
383
+ await table.update_item(
384
+ Key={PK: effective_key},
385
+ UpdateExpression="SET lease_until = :lu, #ttl = :ttl",
386
+ ConditionExpression="#s = :claimed AND claim_token = :tok",
387
+ ExpressionAttributeNames={"#s": "state", "#ttl": "ttl"},
388
+ ExpressionAttributeValues={
389
+ ":claimed": "CLAIMED",
390
+ ":tok": claim_token,
391
+ ":lu": _num(lease_until),
392
+ ":ttl": int(lease_until + self._grace_seconds),
393
+ },
394
+ )
395
+ return True
396
+ except ClientError as e:
397
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
398
+ return False
399
+ raise StorageError(f"dynamodb renew failed: {e}") from e
400
+ except Exception as e:
401
+ raise StorageError(f"dynamodb renew failed: {e}") from e
402
+
403
+ async def wait_for_completion(
404
+ self,
405
+ effective_key: str,
406
+ timeout_seconds: float,
407
+ ) -> StoredRecord | None:
408
+ import asyncio
409
+
410
+ deadline = time.monotonic() + timeout_seconds
411
+ poll = _POLL_INITIAL_SECONDS
412
+ while True:
413
+ record = await self._get_record(effective_key)
414
+ if record is None:
415
+ return None
416
+ if record.state == State.COMPLETED:
417
+ return record
418
+ remaining = deadline - time.monotonic()
419
+ if remaining <= 0:
420
+ return None
421
+ await asyncio.sleep(min(poll, remaining))
422
+ poll = min(poll * 2, _POLL_CAP_SECONDS)
423
+
424
+ # ----- internals -----
425
+
426
+ async def _get_record(self, effective_key: str) -> StoredRecord | None:
427
+ table = await self._ensure_table()
428
+ try:
429
+ resp = await table.get_item(Key={PK: effective_key}, ConsistentRead=True)
430
+ except Exception as e:
431
+ raise StorageError(f"dynamodb read failed: {e}") from e
432
+ item = resp.get("Item")
433
+ if item is None:
434
+ return None
435
+ try:
436
+ return self._item_to_record(item)
437
+ except Exception:
438
+ return None
439
+
440
+ def _record(
441
+ self,
442
+ effective_key: str,
443
+ state: State,
444
+ fingerprint: str,
445
+ fingerprint_version: int,
446
+ token: str,
447
+ claimed_at: float,
448
+ lease_until: float,
449
+ ) -> StoredRecord:
450
+ return StoredRecord(
451
+ effective_key=effective_key,
452
+ state=state,
453
+ fingerprint=fingerprint,
454
+ fingerprint_version=fingerprint_version,
455
+ claim_token=token,
456
+ claimed_at=claimed_at,
457
+ lease_until=lease_until,
458
+ )
459
+
460
+ def _item_to_record(self, item: dict[str, Any]) -> StoredRecord:
461
+ body = item.get("response_body") or b""
462
+ if hasattr(body, "value"): # botocore Binary
463
+ body = body.value
464
+ return StoredRecord(
465
+ effective_key=item[PK],
466
+ state=State(item["state"]),
467
+ fingerprint=item["fingerprint"],
468
+ fingerprint_version=int(item["fingerprint_version"]),
469
+ claim_token=item["claim_token"],
470
+ claimed_at=float(item["claimed_at"]),
471
+ lease_until=float(item["lease_until"]),
472
+ completed_at=float(item["completed_at"])
473
+ if item.get("completed_at") is not None
474
+ else None,
475
+ response_status=int(item["response_status"])
476
+ if item.get("response_status") is not None
477
+ else None,
478
+ response_headers={
479
+ str(k): str(v) for k, v in (item.get("response_headers") or {}).items()
480
+ },
481
+ response_body=bytes(body),
482
+ )
483
+
484
+
485
+ def _num(value: float) -> Any:
486
+ """DynamoDB numbers must be Decimal (the resource layer rejects float)."""
487
+ from decimal import Decimal
488
+
489
+ return Decimal(str(value))