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,325 @@
|
|
|
1
|
+
"""In-memory backend per spec §4.1 / §4.13.
|
|
2
|
+
|
|
3
|
+
Single-process only — NOT distributed. Intended for development and tests.
|
|
4
|
+
``IdempotencyMiddleware`` logs a warning if this backend is used outside a
|
|
5
|
+
test environment.
|
|
6
|
+
|
|
7
|
+
Storage model: per-effective-key ``asyncio.Lock`` (here approximated by a
|
|
8
|
+
single coarse lock since asyncio is single-threaded; the coarse lock is
|
|
9
|
+
correct and sufficient for in-process use) plus a dict keyed by effective
|
|
10
|
+
key. Per-key ``asyncio.Event`` instances signal completion to waiters
|
|
11
|
+
(spec §4.3 subscribe-before-read).
|
|
12
|
+
|
|
13
|
+
Per spec §4.13, eviction is per-key TTL only — no LRU. When ``max_size`` is
|
|
14
|
+
reached, new claims are rejected with ``MemoryBackendFull``, which the
|
|
15
|
+
middleware maps to a ``storage_error`` event (spec §4.16). This is the
|
|
16
|
+
explicit design choice: any LRU that can evict ``CLAIMED`` records risks
|
|
17
|
+
duplicate execution under load.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import secrets
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
|
|
28
|
+
from idemkit.core.state import (
|
|
29
|
+
ClaimResult,
|
|
30
|
+
ClaimResultType,
|
|
31
|
+
State,
|
|
32
|
+
StoredRecord,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# In-flight wait poll schedule (spec §5.5): re-read at a backed-off cadence so a
|
|
38
|
+
# lost completion notification can't hang a waiter for the full timeout.
|
|
39
|
+
_POLL_INITIAL_SECONDS = 0.05
|
|
40
|
+
_POLL_CAP_SECONDS = 1.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MemoryBackendFull(RuntimeError):
|
|
44
|
+
"""Raised when ``InMemoryBackend`` is at ``max_size`` and rejects a new claim."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ManualClock:
|
|
48
|
+
"""An advanceable clock for deterministic lease/TTL tests (spec §9.4).
|
|
49
|
+
|
|
50
|
+
Pass to ``InMemoryBackend(clock=ManualClock())`` and call ``advance(seconds)``
|
|
51
|
+
to expire leases and completed-TTLs without real sleeps::
|
|
52
|
+
|
|
53
|
+
clock = ManualClock()
|
|
54
|
+
backend = InMemoryBackend(clock=clock)
|
|
55
|
+
... # claim + complete
|
|
56
|
+
clock.advance(86_401) # past expires_after_seconds
|
|
57
|
+
... # next claim now sees the record as expired
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, start: float = 0.0) -> None:
|
|
61
|
+
self._t = start
|
|
62
|
+
|
|
63
|
+
def __call__(self) -> float:
|
|
64
|
+
return self._t
|
|
65
|
+
|
|
66
|
+
def advance(self, seconds: float) -> None:
|
|
67
|
+
self._t += seconds
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InMemoryBackend:
|
|
71
|
+
"""Reference in-memory implementation of the backend Protocol."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
max_size: int = 10_000,
|
|
77
|
+
default_expires_after_seconds: float = 86_400.0,
|
|
78
|
+
clock: Callable[[], float] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._max_size = max_size
|
|
81
|
+
self._default_completed_ttl = default_expires_after_seconds
|
|
82
|
+
self._records: dict[str, StoredRecord] = {}
|
|
83
|
+
self._waiters: dict[str, asyncio.Event] = {}
|
|
84
|
+
self._lock = asyncio.Lock()
|
|
85
|
+
# The storage clock that drives lease expiry and TTLs. Inject a ManualClock
|
|
86
|
+
# in tests to advance time deterministically instead of sleeping (spec §9.4).
|
|
87
|
+
# (The in-flight wait timeout still uses real time, so it always elapses.)
|
|
88
|
+
self._clock: Callable[[], float] = clock or time.monotonic
|
|
89
|
+
|
|
90
|
+
async def aclose(self) -> None:
|
|
91
|
+
"""No-op: the in-memory backend holds no external resources. Provided so
|
|
92
|
+
it is a drop-in for the Redis/Postgres backends in ``async with`` and
|
|
93
|
+
``aclose()`` teardown code."""
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
async def __aenter__(self) -> InMemoryBackend:
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
100
|
+
await self.aclose()
|
|
101
|
+
|
|
102
|
+
async def claim(
|
|
103
|
+
self,
|
|
104
|
+
effective_key: str,
|
|
105
|
+
fingerprint: str,
|
|
106
|
+
fingerprint_version: int,
|
|
107
|
+
lease_ttl_seconds: float,
|
|
108
|
+
) -> ClaimResult:
|
|
109
|
+
async with self._lock:
|
|
110
|
+
self._gc_expired_unlocked()
|
|
111
|
+
existing = self._records.get(effective_key)
|
|
112
|
+
now = self._clock()
|
|
113
|
+
|
|
114
|
+
if existing is None:
|
|
115
|
+
if len(self._records) >= self._max_size:
|
|
116
|
+
_logger.warning(
|
|
117
|
+
"idemkit.InMemoryBackend at max_size=%d; rejecting claim. "
|
|
118
|
+
"Increase max_size or switch to a distributed backend.",
|
|
119
|
+
self._max_size,
|
|
120
|
+
)
|
|
121
|
+
raise MemoryBackendFull(f"InMemoryBackend at max_size={self._max_size}")
|
|
122
|
+
return self._fresh_claim(
|
|
123
|
+
effective_key, fingerprint, fingerprint_version, lease_ttl_seconds, now
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if existing.state == State.COMPLETED:
|
|
127
|
+
# Expired completed records are dropped by _gc_expired_unlocked above
|
|
128
|
+
# so reaching here means a valid completed record exists.
|
|
129
|
+
return ClaimResult(
|
|
130
|
+
result=ClaimResultType.ALREADY_COMPLETED,
|
|
131
|
+
record=existing,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# state == CLAIMED
|
|
135
|
+
if existing.lease_until < now:
|
|
136
|
+
# Lease expired; reclaim atomically. Wake any pre-existing waiter
|
|
137
|
+
# that was watching the old claim — they will re-read state and
|
|
138
|
+
# see the new record (different claim_token) and behave correctly.
|
|
139
|
+
old_event = self._waiters.pop(effective_key, None)
|
|
140
|
+
if old_event is not None:
|
|
141
|
+
old_event.set()
|
|
142
|
+
result = self._fresh_claim(
|
|
143
|
+
effective_key,
|
|
144
|
+
fingerprint,
|
|
145
|
+
fingerprint_version,
|
|
146
|
+
lease_ttl_seconds,
|
|
147
|
+
now,
|
|
148
|
+
)
|
|
149
|
+
return ClaimResult(
|
|
150
|
+
result=ClaimResultType.LEASE_RECLAIMED,
|
|
151
|
+
record=result.record,
|
|
152
|
+
our_claim_token=result.our_claim_token,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return ClaimResult(
|
|
156
|
+
result=ClaimResultType.ALREADY_CLAIMED,
|
|
157
|
+
record=existing,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _fresh_claim(
|
|
161
|
+
self,
|
|
162
|
+
effective_key: str,
|
|
163
|
+
fingerprint: str,
|
|
164
|
+
fingerprint_version: int,
|
|
165
|
+
lease_ttl_seconds: float,
|
|
166
|
+
now: float,
|
|
167
|
+
) -> ClaimResult:
|
|
168
|
+
"""Write a brand-new CLAIMED record. Caller holds self._lock."""
|
|
169
|
+
claim_token = secrets.token_hex(16) # 128 bits, spec §4.1
|
|
170
|
+
record = StoredRecord(
|
|
171
|
+
effective_key=effective_key,
|
|
172
|
+
state=State.CLAIMED,
|
|
173
|
+
fingerprint=fingerprint,
|
|
174
|
+
fingerprint_version=fingerprint_version,
|
|
175
|
+
claim_token=claim_token,
|
|
176
|
+
claimed_at=now,
|
|
177
|
+
lease_until=now + lease_ttl_seconds,
|
|
178
|
+
)
|
|
179
|
+
self._records[effective_key] = record
|
|
180
|
+
return ClaimResult(
|
|
181
|
+
result=ClaimResultType.NEW_CLAIMED,
|
|
182
|
+
record=record,
|
|
183
|
+
our_claim_token=claim_token,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def complete(
|
|
187
|
+
self,
|
|
188
|
+
effective_key: str,
|
|
189
|
+
claim_token: str,
|
|
190
|
+
response_status: int,
|
|
191
|
+
response_headers: dict[str, str],
|
|
192
|
+
response_body: bytes,
|
|
193
|
+
expires_after_seconds: float,
|
|
194
|
+
) -> bool:
|
|
195
|
+
async with self._lock:
|
|
196
|
+
existing = self._records.get(effective_key)
|
|
197
|
+
if (
|
|
198
|
+
existing is None
|
|
199
|
+
or existing.state != State.CLAIMED
|
|
200
|
+
or existing.claim_token != claim_token
|
|
201
|
+
):
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
now = self._clock()
|
|
205
|
+
self._records[effective_key] = StoredRecord(
|
|
206
|
+
effective_key=existing.effective_key,
|
|
207
|
+
state=State.COMPLETED,
|
|
208
|
+
fingerprint=existing.fingerprint,
|
|
209
|
+
fingerprint_version=existing.fingerprint_version,
|
|
210
|
+
claim_token=existing.claim_token,
|
|
211
|
+
claimed_at=existing.claimed_at,
|
|
212
|
+
lease_until=now + expires_after_seconds, # spec §4.8 TTL from completed_at
|
|
213
|
+
completed_at=now,
|
|
214
|
+
response_status=response_status,
|
|
215
|
+
response_headers=dict(response_headers),
|
|
216
|
+
response_body=response_body,
|
|
217
|
+
)
|
|
218
|
+
self._signal_waiters_unlocked(effective_key)
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
async def release(self, effective_key: str, claim_token: str) -> bool:
|
|
222
|
+
async with self._lock:
|
|
223
|
+
existing = self._records.get(effective_key)
|
|
224
|
+
if (
|
|
225
|
+
existing is None
|
|
226
|
+
or existing.state != State.CLAIMED
|
|
227
|
+
or existing.claim_token != claim_token
|
|
228
|
+
):
|
|
229
|
+
return False
|
|
230
|
+
del self._records[effective_key]
|
|
231
|
+
self._signal_waiters_unlocked(effective_key)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
async def renew(
|
|
235
|
+
self,
|
|
236
|
+
effective_key: str,
|
|
237
|
+
claim_token: str,
|
|
238
|
+
lease_ttl_seconds: float,
|
|
239
|
+
) -> bool:
|
|
240
|
+
"""Push out the lease of a still-held claim (spec §5.3.1)."""
|
|
241
|
+
async with self._lock:
|
|
242
|
+
existing = self._records.get(effective_key)
|
|
243
|
+
if (
|
|
244
|
+
existing is None
|
|
245
|
+
or existing.state != State.CLAIMED
|
|
246
|
+
or existing.claim_token != claim_token
|
|
247
|
+
):
|
|
248
|
+
return False
|
|
249
|
+
now = self._clock()
|
|
250
|
+
# Replace with a copy carrying the new lease deadline. We do not
|
|
251
|
+
# require the old lease to still be live: this op and claim() both
|
|
252
|
+
# hold self._lock, so they serialize — if a reclaim had already
|
|
253
|
+
# happened, claim_token would differ and we'd have returned False.
|
|
254
|
+
self._records[effective_key] = StoredRecord(
|
|
255
|
+
effective_key=existing.effective_key,
|
|
256
|
+
state=existing.state,
|
|
257
|
+
fingerprint=existing.fingerprint,
|
|
258
|
+
fingerprint_version=existing.fingerprint_version,
|
|
259
|
+
claim_token=existing.claim_token,
|
|
260
|
+
claimed_at=existing.claimed_at,
|
|
261
|
+
lease_until=now + lease_ttl_seconds,
|
|
262
|
+
)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
async def wait_for_completion(
|
|
266
|
+
self,
|
|
267
|
+
effective_key: str,
|
|
268
|
+
timeout_seconds: float,
|
|
269
|
+
) -> StoredRecord | None:
|
|
270
|
+
# Subscribe BEFORE re-reading state, per spec §4.3, then wait on a
|
|
271
|
+
# NOTIFICATION-OR-POLL schedule (§5.5): the event is the latency
|
|
272
|
+
# optimization, the bounded backed-off re-read is the correctness floor.
|
|
273
|
+
# An in-process Event can't really be "lost", but keeping the same loop
|
|
274
|
+
# shape as the distributed backends is what makes the contract uniform.
|
|
275
|
+
async with self._lock:
|
|
276
|
+
event = self._waiters.setdefault(effective_key, asyncio.Event())
|
|
277
|
+
|
|
278
|
+
deadline = time.monotonic() + timeout_seconds
|
|
279
|
+
poll = _POLL_INITIAL_SECONDS
|
|
280
|
+
while True:
|
|
281
|
+
async with self._lock:
|
|
282
|
+
existing = self._records.get(effective_key)
|
|
283
|
+
if existing is not None and existing.state == State.COMPLETED:
|
|
284
|
+
return existing
|
|
285
|
+
if existing is None:
|
|
286
|
+
# Released/reclaimed; the caller should retry the claim.
|
|
287
|
+
return None
|
|
288
|
+
remaining = deadline - time.monotonic()
|
|
289
|
+
if remaining <= 0:
|
|
290
|
+
return None
|
|
291
|
+
event.clear()
|
|
292
|
+
try:
|
|
293
|
+
await asyncio.wait_for(event.wait(), timeout=min(poll, remaining))
|
|
294
|
+
except asyncio.TimeoutError:
|
|
295
|
+
pass
|
|
296
|
+
poll = min(poll * 2, _POLL_CAP_SECONDS)
|
|
297
|
+
|
|
298
|
+
def _signal_waiters_unlocked(self, effective_key: str) -> None:
|
|
299
|
+
"""Wake everyone waiting on this key. Caller holds self._lock."""
|
|
300
|
+
event = self._waiters.pop(effective_key, None)
|
|
301
|
+
if event is not None:
|
|
302
|
+
event.set()
|
|
303
|
+
|
|
304
|
+
def _gc_expired_unlocked(self) -> None:
|
|
305
|
+
"""Lazy GC: drop expired CLAIMED and COMPLETED records.
|
|
306
|
+
|
|
307
|
+
Caller holds self._lock. Runs on every claim attempt; cheap because
|
|
308
|
+
it's a single dict scan keyed on the small live set.
|
|
309
|
+
"""
|
|
310
|
+
now = self._clock()
|
|
311
|
+
expired: list[str] = []
|
|
312
|
+
for key, record in self._records.items():
|
|
313
|
+
if record.state == State.CLAIMED and record.lease_until < now:
|
|
314
|
+
# CLAIMED lease expired — eligible for reclaim, but until a
|
|
315
|
+
# new claim comes in we keep the record (claim() handles it).
|
|
316
|
+
# We don't proactively delete here because doing so would lose
|
|
317
|
+
# the claim_token used to reject a delayed completion from
|
|
318
|
+
# the original owner. The reclaim path in claim() handles
|
|
319
|
+
# the swap.
|
|
320
|
+
continue
|
|
321
|
+
if record.state == State.COMPLETED and record.lease_until < now:
|
|
322
|
+
expired.append(key)
|
|
323
|
+
for key in expired:
|
|
324
|
+
del self._records[key]
|
|
325
|
+
self._waiters.pop(key, None)
|