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