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
idemkit/core/runner.py ADDED
@@ -0,0 +1,664 @@
1
+ """Surface-neutral idempotent-execution core, per spec §5.
2
+
3
+ This is the one correct core that HTTP, queue consumers, and AI tool calls all
4
+ sit on top of. It owns the distributed-correctness mechanism and nothing about
5
+ any particular transport:
6
+
7
+ key → atomic claim → in-flight wait → run once → store result → replay
8
+
9
+ The HTTP adapter (``core.engine.IdempotencyEngine``) is a thin user of this:
10
+ it maps a request to an effective key + fingerprint, calls ``decide``, runs the
11
+ handler, then calls ``complete`` / ``release``. Queue and AI adapters reuse the
12
+ exact same ``IdempotentCore`` with a different result shape and policy.
13
+
14
+ What lives here (surface-neutral):
15
+ * the claim → branch → wait state machine (spec §5.2, §5.3, §5.5);
16
+ * the cacheable vs reclaim decision plumbing (the *policy* is the caller's);
17
+ * fencing on the ``claim_token`` (spec §5.3);
18
+ * one observability event per terminal decision, tagged with the surface
19
+ (spec §5.8) so a single dashboard covers all three surfaces;
20
+ * the storage-error policy fork (``fail_closed`` / ``fail_open``, spec §5.7).
21
+
22
+ What does NOT live here: status codes, Problem Details, headers, fingerprint
23
+ construction, serializers. Those are per-surface and belong in the adapters.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import enum
30
+ import logging
31
+ import time
32
+ from collections import OrderedDict
33
+ from collections.abc import Awaitable, Callable
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Literal, TypeVar
36
+
37
+ from idemkit.backends.base import IdempotencyBackend
38
+ from idemkit.core.events import EventEmitter, IdempotencyEvent
39
+ from idemkit.core.exceptions import StorageError
40
+ from idemkit.core.state import ClaimResultType, Decision, StoredRecord
41
+
42
+ _logger = logging.getLogger(__name__)
43
+
44
+ _H = TypeVar("_H") # a handler's return type, for the heartbeat helper
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class StoredResult:
49
+ """The opaque result a completed operation stores and replays (spec §5.4).
50
+
51
+ The backend persists these in its HTTP-shaped columns — ``response_body``,
52
+ ``response_headers``, ``response_status`` — which Appendix A fixes and we do
53
+ not change. Each surface repurposes the three carriers:
54
+
55
+ * ``blob`` — HTTP response body; elsewhere the serialized return value (or
56
+ empty for a side-effect-only queue handler, where a completed record just
57
+ means "skip this redelivery").
58
+ * ``meta`` — HTTP response headers; elsewhere a place for a codec to record
59
+ what it serialized (e.g. ``{"codec": "json"}``).
60
+ * ``marker`` — HTTP status code; elsewhere a small sentinel (200 = "a result
61
+ is present"), kept so the existing backend columns stay populated.
62
+ """
63
+
64
+ blob: bytes
65
+ meta: dict[str, str] = field(default_factory=dict)
66
+ marker: int = 200
67
+
68
+
69
+ class CoreOutcome(enum.Enum):
70
+ """What ``IdempotentCore.decide`` tells a surface adapter to do."""
71
+
72
+ PROCEED = "proceed"
73
+ """Claim acquired. Run the handler, then call ``complete`` or ``release``."""
74
+
75
+ REPLAY = "replay"
76
+ """A completed record exists (or finished while we waited). Replay it."""
77
+
78
+ MISMATCH = "mismatch"
79
+ """A record exists under this key but its fingerprint disagreed. The surface
80
+ must refuse rather than replay the wrong result (HTTP 422/409)."""
81
+
82
+ CONFLICT = "conflict"
83
+ """Another executor holds a live claim and it did not complete within the
84
+ wait timeout (HTTP 423/409)."""
85
+
86
+ STORAGE_REFUSE = "storage_refuse"
87
+ """The backend errored and ``on_storage_error=fail_closed``. The surface must
88
+ refuse the operation (HTTP 503)."""
89
+
90
+ STORAGE_PASS = "storage_pass"
91
+ """The backend errored and ``on_storage_error=fail_open``. The surface should
92
+ run the handler this once WITHOUT idempotency protection."""
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class CoreDecision:
97
+ """Result of ``decide``: the outcome plus whatever the caller needs next."""
98
+
99
+ outcome: CoreOutcome
100
+ claim_token: str | None = None
101
+ stored: StoredResult | None = None
102
+ wait_duration_seconds: float = 0.0
103
+
104
+
105
+ class IdempotentCore:
106
+ """The surface-neutral state machine. One instance per adapter.
107
+
108
+ Holds only the knobs the mechanism needs; everything HTTP-/queue-/AI-shaped
109
+ stays in the adapter. The same ``EventEmitter`` instance is shared with the
110
+ adapter so all events (whoever emits them) reach the user's handlers.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ backend: IdempotencyBackend,
116
+ *,
117
+ emitter: EventEmitter,
118
+ backend_name: str,
119
+ surface: str = "http",
120
+ on_storage_error: Literal["fail_closed", "fail_open"] = "fail_closed",
121
+ lease_ttl_seconds: float = 30.0,
122
+ wait_timeout_seconds: float = 10.0,
123
+ expires_after_seconds: float = 86_400.0,
124
+ fingerprint_version: int = 1,
125
+ use_local_cache: bool = False,
126
+ local_cache_max_items: int = 1024,
127
+ response_hook: Callable[[StoredResult], StoredResult] | None = None,
128
+ ) -> None:
129
+ self.backend = backend
130
+ self.emitter = emitter
131
+ self.backend_name = backend_name
132
+ self.surface = surface
133
+ self.on_storage_error = on_storage_error
134
+ self.lease_ttl_seconds = lease_ttl_seconds
135
+ self.wait_timeout_seconds = wait_timeout_seconds
136
+ self.expires_after_seconds = expires_after_seconds
137
+ self.fingerprint_version = fingerprint_version
138
+ self.response_hook = response_hook
139
+ self.use_local_cache = use_local_cache
140
+ self.local_cache_max_items = local_cache_max_items
141
+ # effective_key -> (fingerprint, stored_result, expires_at_monotonic).
142
+ # Only COMPLETED results land here (spec §5.9): never an in-progress
143
+ # claim, whose state is volatile and owned elsewhere.
144
+ self._local_cache: OrderedDict[str, tuple[str, StoredResult, float]] = OrderedDict()
145
+
146
+ # ----- decision phase -----
147
+
148
+ async def decide(self, effective_key: str, fingerprint: str) -> CoreDecision:
149
+ """Claim the key and decide what the caller should do (spec §5.2-§5.5).
150
+
151
+ Emits exactly one terminal event (plus a preceding ``in_flight_wait`` on
152
+ the wait path). The caller never has to emit anything itself.
153
+ """
154
+ started = time.monotonic()
155
+
156
+ # Warm-path short-circuit (spec §5.9): a COMPLETED result this process
157
+ # already saw, with a matching fingerprint, replays without a backend
158
+ # round-trip. Correctness still rests on the backend — a stale or absent
159
+ # local entry only ever costs a read it could have skipped, never a wrong
160
+ # hit (a fingerprint mismatch falls through to the backend, which then
161
+ # reports the mismatch).
162
+ if self.use_local_cache:
163
+ cached = self._local_get(effective_key, fingerprint)
164
+ if cached is not None:
165
+ self._emit(Decision.REPLAYED, effective_key, started)
166
+ return CoreDecision(CoreOutcome.REPLAY, stored=self._replay(cached))
167
+
168
+ try:
169
+ claim = await self.backend.claim(
170
+ effective_key=effective_key,
171
+ fingerprint=fingerprint,
172
+ fingerprint_version=self.fingerprint_version,
173
+ lease_ttl_seconds=self.lease_ttl_seconds,
174
+ )
175
+ except StorageError:
176
+ return self._on_storage_failure(effective_key, started)
177
+ except Exception:
178
+ # An unexpected backend failure (including a ConfigurationError
179
+ # surfaced lazily on first use, e.g. a missing Postgres schema). Log
180
+ # it so the cause isn't lost behind a silent storage decision.
181
+ _logger.exception(
182
+ "idemkit: backend claim failed; applying on_storage_error=%s",
183
+ self.on_storage_error,
184
+ )
185
+ return self._on_storage_failure(effective_key, started)
186
+
187
+ if claim.result == ClaimResultType.NEW_CLAIMED:
188
+ decision = Decision.CORRUPT_RECORD if claim.recovered_from_corrupt else Decision.NEW
189
+ self._emit(decision, effective_key, started)
190
+ return CoreDecision(CoreOutcome.PROCEED, claim_token=claim.our_claim_token)
191
+
192
+ if claim.result == ClaimResultType.LEASE_RECLAIMED:
193
+ self._emit(Decision.LEASE_RECLAIMED, effective_key, started)
194
+ return CoreDecision(CoreOutcome.PROCEED, claim_token=claim.our_claim_token)
195
+
196
+ # ALREADY_CLAIMED / ALREADY_COMPLETED — the fingerprint must agree before
197
+ # we ever consider replaying (spec §5.1: a key hit with a different
198
+ # payload is a mismatch, never a wrong replay).
199
+ if (
200
+ claim.record.fingerprint != fingerprint
201
+ or claim.record.fingerprint_version != self.fingerprint_version
202
+ ):
203
+ self._emit(Decision.PAYLOAD_MISMATCH, effective_key, started)
204
+ return CoreDecision(CoreOutcome.MISMATCH)
205
+
206
+ if claim.result == ClaimResultType.ALREADY_COMPLETED:
207
+ self._emit(Decision.REPLAYED, effective_key, started)
208
+ stored = _stored_from_record(claim.record)
209
+ self._local_put(effective_key, fingerprint, stored)
210
+ return CoreDecision(CoreOutcome.REPLAY, stored=self._replay(stored))
211
+
212
+ # ALREADY_CLAIMED: someone else is mid-flight. Wait for them (spec §5.5).
213
+ self._emit(Decision.IN_FLIGHT_WAIT, effective_key, started)
214
+ wait_started = time.monotonic()
215
+ try:
216
+ record = await self.backend.wait_for_completion(
217
+ effective_key=effective_key,
218
+ timeout_seconds=self.wait_timeout_seconds,
219
+ )
220
+ except Exception:
221
+ _logger.exception(
222
+ "idemkit: backend wait_for_completion failed; applying on_storage_error=%s",
223
+ self.on_storage_error,
224
+ )
225
+ return self._on_storage_failure(effective_key, started)
226
+ wait_duration = time.monotonic() - wait_started
227
+
228
+ if record is not None:
229
+ self._emit(Decision.REPLAYED, effective_key, started, wait_duration=wait_duration)
230
+ stored = _stored_from_record(record)
231
+ self._local_put(effective_key, fingerprint, stored)
232
+ return CoreDecision(
233
+ CoreOutcome.REPLAY,
234
+ stored=self._replay(stored),
235
+ wait_duration_seconds=wait_duration,
236
+ )
237
+
238
+ # Timed out, or the in-flight claim was released without completing.
239
+ self._emit(Decision.CONFLICT, effective_key, started, wait_duration=wait_duration)
240
+ return CoreDecision(CoreOutcome.CONFLICT, wait_duration_seconds=wait_duration)
241
+
242
+ # ----- completion phase -----
243
+
244
+ async def complete(
245
+ self,
246
+ effective_key: str,
247
+ claim_token: str,
248
+ result: StoredResult,
249
+ expires_after_seconds: float | None = None,
250
+ ) -> None:
251
+ """Store the handler's result against our claim (spec §5.2 complete).
252
+
253
+ The caller decides cacheability and any per-surface transform (redaction,
254
+ size caps) BEFORE calling this. Here we only do the conditional, fenced
255
+ write and surface a lost race as ``lease_reclaimed_loss``.
256
+ """
257
+ ttl = (
258
+ expires_after_seconds
259
+ if expires_after_seconds is not None
260
+ else self.expires_after_seconds
261
+ )
262
+ # Drop any stale local entry; the next replay re-reads and re-caches with
263
+ # the fresh result (we don't have the fingerprint here to repopulate).
264
+ self._local_invalidate(effective_key)
265
+ try:
266
+ ok = await self.backend.complete(
267
+ effective_key=effective_key,
268
+ claim_token=claim_token,
269
+ response_status=result.marker,
270
+ response_headers=result.meta,
271
+ response_body=result.blob,
272
+ expires_after_seconds=ttl,
273
+ )
274
+ except Exception:
275
+ # The backend died trying to persist the result, after the handler
276
+ # already produced its side effect. Do NOT release: releasing would
277
+ # delete the CLAIMED record and let an immediate retry re-run the
278
+ # side effect. Leave the claim so the lease is the real safety net —
279
+ # duplicates see in-flight until it lapses, then one reclaims and
280
+ # re-runs (fail closed, §5.7). We could not cache the result; that is
281
+ # the honest cost of a storage failure at completion time.
282
+ _logger.warning(
283
+ "idemkit: backend.complete failed; the result was not cached. "
284
+ "Holding the claim so the lease (not an immediate retry) governs "
285
+ "re-execution."
286
+ )
287
+ # Emit a distinct event: the side effect ran but its result is not
288
+ # replayable, so a later retry re-runs it (at-least-once degradation).
289
+ self._emit_simple(Decision.COMPLETE_FAILED, effective_key)
290
+ return
291
+
292
+ if not ok:
293
+ # Our lease was reclaimed mid-handler and someone else now owns the
294
+ # key; our completion is fenced out and discarded (spec §5.3).
295
+ self._emit_simple(Decision.LEASE_RECLAIMED_LOSS, effective_key)
296
+
297
+ async def release(self, effective_key: str, claim_token: str) -> None:
298
+ """Release our claim so a retry can re-run (spec §5.6). Best-effort:
299
+ if the backend is unreachable, lease expiry reclaims the key anyway."""
300
+ self._local_invalidate(effective_key) # §5.9: invalidate on release
301
+ try:
302
+ await self.backend.release(effective_key, claim_token)
303
+ except Exception:
304
+ pass
305
+
306
+ # ----- run-once convenience (direct callers: queue / AI) -----
307
+
308
+ async def run_once(
309
+ self,
310
+ effective_key: str,
311
+ fingerprint: str,
312
+ handler: Callable[[], Awaitable[Any]],
313
+ *,
314
+ encode: Callable[[Any], StoredResult | None] | None = None,
315
+ is_cacheable: Callable[[StoredResult | None], bool] = lambda _r: True,
316
+ heartbeat: bool = False,
317
+ heartbeat_interval_seconds: float | None = None,
318
+ ) -> RunOnceResult:
319
+ """Run ``handler`` at most once for ``effective_key``, replaying the
320
+ stored result on any duplicate.
321
+
322
+ This is the building block the queue and AI adapters compose with their
323
+ own attempt-counting / ack logic. The HTTP middleware does NOT use it —
324
+ it needs the inverted ``decide`` / ``complete`` shape because it
325
+ structurally wraps the ASGI handler.
326
+
327
+ With ``encode=None`` the handler returns a ``StoredResult`` to cache (or
328
+ ``None`` for the side-effect-only case). With an ``encode`` callable the
329
+ handler returns its raw value and ``encode`` serializes it *after* the
330
+ protected run — so a serialization failure does NOT release the claim
331
+ (fail closed, §5.4); it returns ``ENCODE_FAILED`` and leaves the lease to
332
+ govern re-execution, rather than immediately re-running the side effect.
333
+
334
+ A handler that *raises* releases the claim so the operation can be
335
+ retried (that's the crash path, distinct from an encode failure).
336
+
337
+ ``heartbeat=True`` keeps the lease alive while the handler runs (spec
338
+ §5.3.1), so the lease can be short (fast crash recovery) without a slow
339
+ handler losing its claim. If a renewal fails the handler is cancelled
340
+ cooperatively and the result is ``LEASE_LOST`` — read the honest limits
341
+ on ``_run_under_heartbeat``.
342
+ """
343
+ decision = await self.decide(effective_key, fingerprint)
344
+
345
+ if decision.outcome is CoreOutcome.REPLAY:
346
+ return RunOnceResult(RunStatus.REPLAYED, decision.stored)
347
+ if decision.outcome is CoreOutcome.MISMATCH:
348
+ return RunOnceResult(RunStatus.MISMATCH, None)
349
+ if decision.outcome is CoreOutcome.CONFLICT:
350
+ return RunOnceResult(RunStatus.CONFLICT, None)
351
+ if decision.outcome is CoreOutcome.STORAGE_REFUSE:
352
+ raise StorageError("idemkit: storage unavailable and on_storage_error=fail_closed")
353
+
354
+ # PROCEED (claim held) or STORAGE_PASS (run unprotected, this once).
355
+ unprotected = decision.outcome is CoreOutcome.STORAGE_PASS
356
+ token = decision.claim_token
357
+
358
+ if heartbeat and token is not None and not unprotected:
359
+ interval = heartbeat_interval_seconds or _default_heartbeat_interval(
360
+ self.lease_ttl_seconds
361
+ )
362
+ try:
363
+ value, lease_lost = await self._run_under_heartbeat(
364
+ effective_key, token, handler, interval
365
+ )
366
+ except BaseException:
367
+ await self.release(effective_key, token)
368
+ raise
369
+ if lease_lost:
370
+ # We no longer hold the claim — the lease lapsed and another
371
+ # executor may already own the key. Do not complete (we'd be
372
+ # fenced anyway) and do not release (it isn't ours to release).
373
+ return RunOnceResult(RunStatus.LEASE_LOST, None)
374
+ return await self._finalize(effective_key, token, value, encode, is_cacheable)
375
+
376
+ try:
377
+ value = await handler()
378
+ except BaseException:
379
+ if token is not None and not unprotected:
380
+ await self.release(effective_key, token)
381
+ raise
382
+
383
+ if unprotected or token is None:
384
+ status = RunStatus.UNPROTECTED if unprotected else RunStatus.EXECUTED
385
+ # No claim to store against; still surface the (best-effort) encoded
386
+ # value so the caller can read it uniformly.
387
+ stored = _safe_encode(value, encode)
388
+ return RunOnceResult(status, stored)
389
+
390
+ return await self._finalize(effective_key, token, value, encode, is_cacheable)
391
+
392
+ async def _finalize(
393
+ self,
394
+ effective_key: str,
395
+ token: str,
396
+ value: Any,
397
+ encode: Callable[[Any], StoredResult | None] | None,
398
+ is_cacheable: Callable[[StoredResult | None], bool],
399
+ ) -> RunOnceResult:
400
+ """Serialize the handler's value and store or release the claim.
401
+
402
+ The side effect has already happened and we still hold the claim. A
403
+ serialization failure here is fail-closed: we keep the claim (no release)
404
+ so a retry can't immediately re-run the side effect (§5.4)."""
405
+ if encode is None:
406
+ stored: StoredResult | None = value
407
+ else:
408
+ try:
409
+ stored = encode(value)
410
+ except Exception:
411
+ _logger.error(
412
+ "idemkit: could not serialize the result; NOT caching and NOT "
413
+ "releasing the claim, so a retry won't immediately re-run the "
414
+ "side effect. Fix the codec or the return value (fail closed, "
415
+ "§5.4)."
416
+ )
417
+ return RunOnceResult(RunStatus.ENCODE_FAILED, None)
418
+
419
+ if is_cacheable(stored):
420
+ await self.complete(
421
+ effective_key,
422
+ token,
423
+ stored if stored is not None else StoredResult(b""),
424
+ )
425
+ else:
426
+ await self.release(effective_key, token)
427
+ return RunOnceResult(RunStatus.EXECUTED, stored)
428
+
429
+ async def execute_with_heartbeat(
430
+ self,
431
+ effective_key: str,
432
+ claim_token: str,
433
+ handler: Callable[[], Awaitable[_H]],
434
+ *,
435
+ interval_seconds: float | None = None,
436
+ ) -> tuple[_H | None, bool]:
437
+ """Run ``handler`` while renewing the lease (spec §5.3.1), returning
438
+ ``(result, lease_lost)``.
439
+
440
+ Public entry point for adapters (the AI tool decorator) that drive their
441
+ own claim/complete flow but still want lease renewal. ``run_once`` uses
442
+ the same machinery internally.
443
+ """
444
+ interval = interval_seconds or _default_heartbeat_interval(self.lease_ttl_seconds)
445
+ return await self._run_under_heartbeat(effective_key, claim_token, handler, interval)
446
+
447
+ async def _run_under_heartbeat(
448
+ self,
449
+ effective_key: str,
450
+ claim_token: str,
451
+ handler: Callable[[], Awaitable[_H]],
452
+ interval_seconds: float,
453
+ ) -> tuple[_H | None, bool]:
454
+ """Run ``handler`` while renewing the lease in the background (§5.3.1).
455
+
456
+ Returns ``(result, lease_lost)``. ``lease_lost`` is True when a renewal
457
+ could not be confirmed (a storage blip, or the lease was reclaimed): the
458
+ handler is then cancelled cooperatively so a well-behaved handler can
459
+ stop before a second executor proceeds.
460
+
461
+ Honest limits (do not oversell this): asyncio cancellation only lands at
462
+ an ``await``. A handler that awaits regularly observes the
463
+ ``CancelledError`` and can stop in time — for those, cancellation closes
464
+ the partition window. A handler blocked in a synchronous call (a sync DB
465
+ driver, ``requests``, a CPU-bound loop) will NOT see it until it next
466
+ awaits, possibly after the lease already lapsed; there this degrades to
467
+ the §5.7 partition hazard, and a money path still needs downstream
468
+ idempotency. The ``interval`` is sized below the lease so a failed renew
469
+ is detected with margin.
470
+ """
471
+ lease_lost = False
472
+ handler_task: asyncio.Task[_H] = asyncio.ensure_future(handler())
473
+
474
+ async def heartbeat() -> None:
475
+ nonlocal lease_lost
476
+ while True:
477
+ await asyncio.sleep(interval_seconds)
478
+ try:
479
+ ok = await self.backend.renew(
480
+ effective_key, claim_token, self.lease_ttl_seconds
481
+ )
482
+ except Exception:
483
+ _logger.warning(
484
+ "idemkit: lease renewal errored; cancelling handler so a "
485
+ "second executor doesn't race it (§5.3.1)."
486
+ )
487
+ ok = False
488
+ if not ok:
489
+ lease_lost = True
490
+ # Emit so a rising lease-lost rate (handlers outliving their
491
+ # lease) is alertable, not just a warning in the logs.
492
+ self._emit_simple(Decision.LEASE_LOST, effective_key)
493
+ handler_task.cancel()
494
+ return
495
+
496
+ beat_task: asyncio.Task[None] = asyncio.ensure_future(heartbeat())
497
+ try:
498
+ result = await handler_task
499
+ return result, lease_lost
500
+ except asyncio.CancelledError:
501
+ if lease_lost:
502
+ # We cancelled the handler ourselves because the lease was lost.
503
+ return None, True
504
+ raise # a genuine external cancellation; let it propagate.
505
+ finally:
506
+ beat_task.cancel()
507
+ if not handler_task.done():
508
+ handler_task.cancel()
509
+ await asyncio.gather(beat_task, handler_task, return_exceptions=True)
510
+
511
+ # ----- internals -----
512
+
513
+ def _on_storage_failure(self, effective_key: str, started: float) -> CoreDecision:
514
+ if self.on_storage_error == "fail_closed":
515
+ # Refused: the request was rejected, no side effect ran.
516
+ self._emit(Decision.STORAGE_ERROR, effective_key, started)
517
+ return CoreDecision(CoreOutcome.STORAGE_REFUSE)
518
+ # fail_open: this request is about to run WITHOUT idempotency protection.
519
+ # Emit a distinct decision so an operator can measure the unprotected-run
520
+ # count (the blast radius of the outage), separate from plain storage errors.
521
+ self._emit(Decision.RAN_UNPROTECTED, effective_key, started)
522
+ return CoreDecision(CoreOutcome.STORAGE_PASS)
523
+
524
+ def _replay(self, stored: StoredResult) -> StoredResult:
525
+ """Apply the response_hook to a result about to be replayed (spec §5.8)."""
526
+ if self.response_hook is None:
527
+ return stored
528
+ return self.response_hook(stored)
529
+
530
+ def _local_get(self, effective_key: str, fingerprint: str) -> StoredResult | None:
531
+ entry = self._local_cache.get(effective_key)
532
+ if entry is None:
533
+ return None
534
+ cached_fingerprint, stored, expires_at = entry
535
+ if time.monotonic() >= expires_at:
536
+ # TTL-checked on read: a stale entry is dropped, not served.
537
+ self._local_cache.pop(effective_key, None)
538
+ return None
539
+ if cached_fingerprint != fingerprint:
540
+ # Same key, different payload — let the backend report the mismatch.
541
+ return None
542
+ self._local_cache.move_to_end(effective_key) # LRU bump
543
+ return stored
544
+
545
+ def _local_put(self, effective_key: str, fingerprint: str, stored: StoredResult) -> None:
546
+ if not self.use_local_cache:
547
+ return
548
+ existing = self._local_cache.get(effective_key)
549
+ if existing is not None and existing[0] == fingerprint:
550
+ # Already cached for this fingerprint. Bump LRU but do NOT extend the
551
+ # expiry: the entry's lifetime is anchored near the original
552
+ # completion, not refreshed on every replay. Refreshing it would let
553
+ # a hot key's local entry outlive the backend record and serve a
554
+ # stale result after the backend expired and re-claimed (§5.9).
555
+ self._local_cache.move_to_end(effective_key)
556
+ return
557
+ expires_at = time.monotonic() + self.expires_after_seconds
558
+ self._local_cache[effective_key] = (fingerprint, stored, expires_at)
559
+ self._local_cache.move_to_end(effective_key)
560
+ while len(self._local_cache) > self.local_cache_max_items:
561
+ self._local_cache.popitem(last=False) # evict least-recently-used
562
+
563
+ def _local_invalidate(self, effective_key: str) -> None:
564
+ if self._local_cache:
565
+ self._local_cache.pop(effective_key, None)
566
+
567
+ def _emit(
568
+ self,
569
+ decision: Decision,
570
+ effective_key: str,
571
+ started: float,
572
+ wait_duration: float = 0.0,
573
+ ) -> None:
574
+ self.emitter.emit(
575
+ IdempotencyEvent(
576
+ decision=decision,
577
+ effective_key=effective_key,
578
+ backend_name=self.backend_name,
579
+ fingerprint_version=self.fingerprint_version,
580
+ latency_seconds=time.monotonic() - started,
581
+ wait_duration_seconds=wait_duration,
582
+ surface=self.surface,
583
+ )
584
+ )
585
+
586
+ def _emit_simple(self, decision: Decision, effective_key: str) -> None:
587
+ self.emitter.emit(
588
+ IdempotencyEvent(
589
+ decision=decision,
590
+ effective_key=effective_key,
591
+ backend_name=self.backend_name,
592
+ fingerprint_version=self.fingerprint_version,
593
+ latency_seconds=0.0,
594
+ surface=self.surface,
595
+ )
596
+ )
597
+
598
+
599
+ class RunStatus(enum.Enum):
600
+ """Terminal status of a ``run_once`` call."""
601
+
602
+ EXECUTED = "executed"
603
+ """The handler ran and its result was stored (or released if non-cacheable)."""
604
+
605
+ REPLAYED = "replayed"
606
+ """A duplicate; the stored result was returned without running the handler."""
607
+
608
+ MISMATCH = "mismatch"
609
+ """Key reused with a different fingerprint; the handler did NOT run."""
610
+
611
+ CONFLICT = "conflict"
612
+ """Another executor held the claim and didn't finish in time; did NOT run."""
613
+
614
+ UNPROTECTED = "unprotected"
615
+ """Storage was down with fail_open; the handler ran without dedup this once."""
616
+
617
+ LEASE_LOST = "lease_lost"
618
+ """The lease could not be renewed mid-handler and the handler was cancelled
619
+ (spec §5.3.1). The operation is unfinished from this executor's side; a
620
+ retry (or the broker's redelivery) will run it again."""
621
+
622
+ ENCODE_FAILED = "encode_failed"
623
+ """The handler ran (its side effect happened) but its result could not be
624
+ serialized. Fail closed (§5.4): the claim is held, not released, so a retry
625
+ doesn't immediately re-run the side effect. The result was not cached."""
626
+
627
+
628
+ @dataclass(frozen=True)
629
+ class RunOnceResult:
630
+ """What ``run_once`` returns: the status plus the stored/replayed payload."""
631
+
632
+ status: RunStatus
633
+ stored: StoredResult | None
634
+
635
+
636
+ def _safe_encode(
637
+ value: Any, encode: Callable[[Any], StoredResult | None] | None
638
+ ) -> StoredResult | None:
639
+ """Best-effort encode for the unprotected path (no claim to fail closed on)."""
640
+ if encode is None:
641
+ return value # type: ignore[no-any-return]
642
+ try:
643
+ return encode(value)
644
+ except Exception:
645
+ return None
646
+
647
+
648
+ def _default_heartbeat_interval(lease_ttl_seconds: float) -> float:
649
+ """A renewal cadence with margin below the lease (spec §5.3.1).
650
+
651
+ A third of the lease means roughly three chances to renew before it lapses,
652
+ so one dropped renewal doesn't immediately cost the claim. Floored so a
653
+ pathologically small lease still yields a positive sleep.
654
+ """
655
+ return max(lease_ttl_seconds / 3.0, 0.01)
656
+
657
+
658
+ def _stored_from_record(record: StoredRecord) -> StoredResult:
659
+ """Project a backend record's completion fields into a ``StoredResult``."""
660
+ return StoredResult(
661
+ blob=record.response_body,
662
+ meta=dict(record.response_headers),
663
+ marker=record.response_status if record.response_status is not None else 200,
664
+ )