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,547 @@
|
|
|
1
|
+
"""Redis backend per spec §4.1 / §4.3 / §7.3.
|
|
2
|
+
|
|
3
|
+
Storage layout: one Redis string per effective_key, value is JSON-encoded
|
|
4
|
+
``StoredRecord``. The response body is base64-encoded inside the JSON to
|
|
5
|
+
keep the value safe for transport through Lua's string handling.
|
|
6
|
+
|
|
7
|
+
State transitions are atomic via Lua scripts that check ``claim_token``
|
|
8
|
+
under Redis's single-threaded execution. All scripts use Redis's ``TIME``
|
|
9
|
+
command for lease comparisons — never the app's clock (spec §4.4).
|
|
10
|
+
|
|
11
|
+
Lease vs key TTL: the Redis key TTL is set to ``lease_ttl + lease_grace``
|
|
12
|
+
so expired-lease records remain observable for the grace period and can
|
|
13
|
+
be explicitly *reclaimed* (emitting ``lease_reclaimed`` rather than ``new``
|
|
14
|
+
for observability), rather than silently disappearing as Redis-native
|
|
15
|
+
expiry. After the grace period, the key auto-deletes.
|
|
16
|
+
|
|
17
|
+
In-flight wait uses Pub/Sub on a single shared channel
|
|
18
|
+
``idemkit:completions`` with the effective_key as the payload. One
|
|
19
|
+
subscriber task per backend instance demultiplexes notifications to
|
|
20
|
+
per-key ``asyncio.Event`` waiters.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import base64
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import secrets
|
|
30
|
+
import time
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from idemkit.core.exceptions import StorageError
|
|
34
|
+
from idemkit.core.state import ClaimResult, ClaimResultType, State, StoredRecord
|
|
35
|
+
|
|
36
|
+
_logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
NOTIFY_CHANNEL = "idemkit:completions"
|
|
39
|
+
|
|
40
|
+
# In-flight wait poll schedule (spec §5.5): the bounded backed-off re-read that
|
|
41
|
+
# keeps a dropped Pub/Sub notification from hanging a waiter for the full timeout.
|
|
42
|
+
_POLL_INITIAL_SECONDS = 0.05
|
|
43
|
+
_POLL_CAP_SECONDS = 1.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Lua script: atomic claim, or detect existing record + reclaim if lease expired.
|
|
47
|
+
# Returns: {outcome_string, record_json}
|
|
48
|
+
# outcome ∈ {NEW_CLAIMED, LEASE_RECLAIMED, ALREADY_COMPLETED, ALREADY_CLAIMED}
|
|
49
|
+
_LUA_CLAIM = """
|
|
50
|
+
local key = KEYS[1]
|
|
51
|
+
local claim_token = ARGV[1]
|
|
52
|
+
local fingerprint = ARGV[2]
|
|
53
|
+
local fingerprint_version = tonumber(ARGV[3])
|
|
54
|
+
local lease_ttl_ms = tonumber(ARGV[4])
|
|
55
|
+
local grace_ms = tonumber(ARGV[5])
|
|
56
|
+
|
|
57
|
+
local now_t = redis.call('TIME')
|
|
58
|
+
local now_ms = tonumber(now_t[1]) * 1000 + math.floor(tonumber(now_t[2]) / 1000)
|
|
59
|
+
local key_ttl_ms = lease_ttl_ms + grace_ms
|
|
60
|
+
|
|
61
|
+
local function make_fresh_record()
|
|
62
|
+
local record = {
|
|
63
|
+
state = 'CLAIMED',
|
|
64
|
+
fingerprint = fingerprint,
|
|
65
|
+
fingerprint_version = fingerprint_version,
|
|
66
|
+
claim_token = claim_token,
|
|
67
|
+
claimed_at_ms = now_ms,
|
|
68
|
+
lease_until_ms = now_ms + lease_ttl_ms
|
|
69
|
+
}
|
|
70
|
+
return cjson.encode(record)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
local existing = redis.call('GET', key)
|
|
74
|
+
if not existing then
|
|
75
|
+
local new_json = make_fresh_record()
|
|
76
|
+
redis.call('SET', key, new_json, 'PX', key_ttl_ms)
|
|
77
|
+
return {'NEW_CLAIMED', new_json}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
local ok, rec = pcall(cjson.decode, existing)
|
|
81
|
+
if not ok or type(rec) ~= 'table' then
|
|
82
|
+
-- Corrupt record (§4.12): overwrite with fresh claim, signalling recovery
|
|
83
|
+
-- so the engine can emit idempotency.corrupt_record.
|
|
84
|
+
local new_json = make_fresh_record()
|
|
85
|
+
redis.call('SET', key, new_json, 'PX', key_ttl_ms)
|
|
86
|
+
return {'NEW_CLAIMED_FROM_CORRUPT', new_json}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if rec.state == 'COMPLETED' then
|
|
90
|
+
return {'ALREADY_COMPLETED', existing}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
-- CLAIMED: check the embedded lease_until_ms against Redis's TIME (§4.4).
|
|
94
|
+
if tonumber(rec.lease_until_ms) < now_ms then
|
|
95
|
+
local new_json = make_fresh_record()
|
|
96
|
+
redis.call('SET', key, new_json, 'PX', key_ttl_ms)
|
|
97
|
+
return {'LEASE_RECLAIMED', new_json}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return {'ALREADY_CLAIMED', existing}
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Lua script: conditional complete (CLAIMED + matching token only).
|
|
105
|
+
# Returns 1 on success, 0 if claim was reclaimed by another process.
|
|
106
|
+
_LUA_COMPLETE = """
|
|
107
|
+
local key = KEYS[1]
|
|
108
|
+
local claim_token = ARGV[1]
|
|
109
|
+
local response_status = tonumber(ARGV[2])
|
|
110
|
+
local response_headers_json = ARGV[3]
|
|
111
|
+
local response_body_b64 = ARGV[4]
|
|
112
|
+
local completed_ttl_ms = tonumber(ARGV[5])
|
|
113
|
+
local notify_channel = ARGV[6]
|
|
114
|
+
|
|
115
|
+
local existing = redis.call('GET', key)
|
|
116
|
+
if not existing then return 0 end
|
|
117
|
+
|
|
118
|
+
local ok, rec = pcall(cjson.decode, existing)
|
|
119
|
+
if not ok or type(rec) ~= 'table' then return 0 end
|
|
120
|
+
|
|
121
|
+
if rec.state ~= 'CLAIMED' or rec.claim_token ~= claim_token then
|
|
122
|
+
return 0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
local now_t = redis.call('TIME')
|
|
126
|
+
local now_ms = tonumber(now_t[1]) * 1000 + math.floor(tonumber(now_t[2]) / 1000)
|
|
127
|
+
|
|
128
|
+
rec.state = 'COMPLETED'
|
|
129
|
+
rec.completed_at_ms = now_ms
|
|
130
|
+
rec.response_status = response_status
|
|
131
|
+
rec.response_headers = cjson.decode(response_headers_json)
|
|
132
|
+
rec.response_body_b64 = response_body_b64
|
|
133
|
+
|
|
134
|
+
redis.call('SET', key, cjson.encode(rec), 'PX', completed_ttl_ms)
|
|
135
|
+
redis.call('PUBLISH', notify_channel, key)
|
|
136
|
+
return 1
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Lua script: conditional lease renewal (CLAIMED + matching token only), §5.3.1.
|
|
141
|
+
# Extends the embedded lease_until and the Redis key TTL. Returns 1 on success,
|
|
142
|
+
# 0 if the record is gone, completed, or reclaimed by another executor.
|
|
143
|
+
_LUA_RENEW = """
|
|
144
|
+
local key = KEYS[1]
|
|
145
|
+
local claim_token = ARGV[1]
|
|
146
|
+
local lease_ttl_ms = tonumber(ARGV[2])
|
|
147
|
+
local grace_ms = tonumber(ARGV[3])
|
|
148
|
+
|
|
149
|
+
local existing = redis.call('GET', key)
|
|
150
|
+
if not existing then return 0 end
|
|
151
|
+
|
|
152
|
+
local ok, rec = pcall(cjson.decode, existing)
|
|
153
|
+
if not ok or type(rec) ~= 'table' then return 0 end
|
|
154
|
+
|
|
155
|
+
if rec.state ~= 'CLAIMED' or rec.claim_token ~= claim_token then
|
|
156
|
+
return 0
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
local now_t = redis.call('TIME')
|
|
160
|
+
local now_ms = tonumber(now_t[1]) * 1000 + math.floor(tonumber(now_t[2]) / 1000)
|
|
161
|
+
|
|
162
|
+
rec.lease_until_ms = now_ms + lease_ttl_ms
|
|
163
|
+
redis.call('SET', key, cjson.encode(rec), 'PX', lease_ttl_ms + grace_ms)
|
|
164
|
+
return 1
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Lua script: conditional release.
|
|
169
|
+
# Returns 1 on success, 0 if claim was reclaimed.
|
|
170
|
+
_LUA_RELEASE = """
|
|
171
|
+
local key = KEYS[1]
|
|
172
|
+
local claim_token = ARGV[1]
|
|
173
|
+
local notify_channel = ARGV[2]
|
|
174
|
+
|
|
175
|
+
local existing = redis.call('GET', key)
|
|
176
|
+
if not existing then return 0 end
|
|
177
|
+
|
|
178
|
+
local ok, rec = pcall(cjson.decode, existing)
|
|
179
|
+
if not ok or type(rec) ~= 'table' then return 0 end
|
|
180
|
+
|
|
181
|
+
if rec.state ~= 'CLAIMED' or rec.claim_token ~= claim_token then
|
|
182
|
+
return 0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
redis.call('DEL', key)
|
|
186
|
+
redis.call('PUBLISH', notify_channel, key)
|
|
187
|
+
return 1
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class RedisBackend:
|
|
192
|
+
"""Production-grade Redis backend.
|
|
193
|
+
|
|
194
|
+
Supports Redis 6+ (Lua + Pub/Sub) and Redis Cluster (single-key
|
|
195
|
+
operations need no hash tag; Pub/Sub broadcasts cluster-wide).
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
client: Any,
|
|
201
|
+
*,
|
|
202
|
+
lease_grace_seconds: float = 60.0,
|
|
203
|
+
namespace: str = "",
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Construct with an existing async Redis client.
|
|
206
|
+
|
|
207
|
+
``client`` should be a ``redis.asyncio.Redis`` instance (or
|
|
208
|
+
protocol-compatible, e.g. ``fakeredis.aioredis.FakeRedis``).
|
|
209
|
+
|
|
210
|
+
``lease_grace_seconds`` extends the Redis key TTL past the lease so
|
|
211
|
+
expired-lease records can be observably *reclaimed* (emitting
|
|
212
|
+
``lease_reclaimed`` rather than ``new``) rather than vanishing as
|
|
213
|
+
native Redis expiry. After lease + grace, the key auto-deletes.
|
|
214
|
+
|
|
215
|
+
``namespace`` prefixes every Redis key and the Pub/Sub channel (as
|
|
216
|
+
``"{namespace}:{key}"``), so idemkit can share a Redis with other data
|
|
217
|
+
or run two isolated instances on one server. Default is empty (no
|
|
218
|
+
prefix).
|
|
219
|
+
"""
|
|
220
|
+
self._client = client
|
|
221
|
+
self._lease_grace_seconds = lease_grace_seconds
|
|
222
|
+
self._namespace = namespace
|
|
223
|
+
self._channel = f"{namespace}:{NOTIFY_CHANNEL}" if namespace else NOTIFY_CHANNEL
|
|
224
|
+
self._sha_claim: str | None = None
|
|
225
|
+
self._sha_complete: str | None = None
|
|
226
|
+
self._sha_release: str | None = None
|
|
227
|
+
self._sha_renew: str | None = None
|
|
228
|
+
self._waiters: dict[str, set[asyncio.Event]] = {}
|
|
229
|
+
self._subscriber_task: asyncio.Task[None] | None = None
|
|
230
|
+
self._subscriber_lock = asyncio.Lock()
|
|
231
|
+
self._subscriber_ready = asyncio.Event()
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def from_url(
|
|
235
|
+
cls,
|
|
236
|
+
url: str,
|
|
237
|
+
*,
|
|
238
|
+
lease_grace_seconds: float = 60.0,
|
|
239
|
+
namespace: str = "",
|
|
240
|
+
**kwargs: Any,
|
|
241
|
+
) -> RedisBackend:
|
|
242
|
+
"""Construct from a redis:// URL.
|
|
243
|
+
|
|
244
|
+
Lazy: the connection pool is opened on first call, not now. Extra
|
|
245
|
+
keyword arguments are forwarded to the redis client (e.g. ``password``,
|
|
246
|
+
``ssl_cert_reqs`` for a ``rediss://`` URL).
|
|
247
|
+
|
|
248
|
+
A default ``socket_connect_timeout`` is applied so an unreachable server
|
|
249
|
+
fails fast (fail-closed) instead of hanging a request; override it, or add
|
|
250
|
+
``socket_timeout`` for a command-level cap. Note ``socket_timeout`` also
|
|
251
|
+
governs the Pub/Sub subscriber's idle reads, so pair it with
|
|
252
|
+
``health_check_interval`` if you set it.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
import redis.asyncio as aioredis
|
|
256
|
+
except ImportError as e:
|
|
257
|
+
raise ImportError(
|
|
258
|
+
"idemkit: install the `redis` extra: pip install 'idemkit[redis]'"
|
|
259
|
+
) from e
|
|
260
|
+
kwargs.setdefault("socket_connect_timeout", 5.0)
|
|
261
|
+
client = aioredis.from_url(url, decode_responses=False, **kwargs)
|
|
262
|
+
return cls(client, lease_grace_seconds=lease_grace_seconds, namespace=namespace)
|
|
263
|
+
|
|
264
|
+
async def aclose(self) -> None:
|
|
265
|
+
"""Tear down the subscriber and close the client."""
|
|
266
|
+
if self._subscriber_task is not None:
|
|
267
|
+
self._subscriber_task.cancel()
|
|
268
|
+
try:
|
|
269
|
+
await self._subscriber_task
|
|
270
|
+
except (asyncio.CancelledError, Exception):
|
|
271
|
+
pass
|
|
272
|
+
self._subscriber_task = None
|
|
273
|
+
await self._client.aclose()
|
|
274
|
+
|
|
275
|
+
async def __aenter__(self) -> RedisBackend:
|
|
276
|
+
return self
|
|
277
|
+
|
|
278
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
279
|
+
await self.aclose()
|
|
280
|
+
|
|
281
|
+
# ----- spec §4.1 backend Protocol -----
|
|
282
|
+
|
|
283
|
+
async def claim(
|
|
284
|
+
self,
|
|
285
|
+
effective_key: str,
|
|
286
|
+
fingerprint: str,
|
|
287
|
+
fingerprint_version: int,
|
|
288
|
+
lease_ttl_seconds: float,
|
|
289
|
+
) -> ClaimResult:
|
|
290
|
+
claim_token = secrets.token_hex(16)
|
|
291
|
+
lease_ms = int(lease_ttl_seconds * 1000)
|
|
292
|
+
grace_ms = int(self._lease_grace_seconds * 1000)
|
|
293
|
+
try:
|
|
294
|
+
outcome_b, record_json_b = await self._eval(
|
|
295
|
+
_LUA_CLAIM,
|
|
296
|
+
"_sha_claim",
|
|
297
|
+
[self._key(effective_key)],
|
|
298
|
+
[claim_token, fingerprint, fingerprint_version, lease_ms, grace_ms],
|
|
299
|
+
)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
raise StorageError(f"redis claim failed: {e}") from e
|
|
302
|
+
|
|
303
|
+
outcome = _decode(outcome_b)
|
|
304
|
+
record_json = _decode(record_json_b)
|
|
305
|
+
record = self._record_from_json(effective_key, record_json)
|
|
306
|
+
|
|
307
|
+
if outcome == "NEW_CLAIMED":
|
|
308
|
+
return ClaimResult(ClaimResultType.NEW_CLAIMED, record, claim_token)
|
|
309
|
+
if outcome == "NEW_CLAIMED_FROM_CORRUPT":
|
|
310
|
+
return ClaimResult(
|
|
311
|
+
ClaimResultType.NEW_CLAIMED,
|
|
312
|
+
record,
|
|
313
|
+
claim_token,
|
|
314
|
+
recovered_from_corrupt=True,
|
|
315
|
+
)
|
|
316
|
+
if outcome == "LEASE_RECLAIMED":
|
|
317
|
+
return ClaimResult(ClaimResultType.LEASE_RECLAIMED, record, claim_token)
|
|
318
|
+
if outcome == "ALREADY_COMPLETED":
|
|
319
|
+
return ClaimResult(ClaimResultType.ALREADY_COMPLETED, record)
|
|
320
|
+
if outcome == "ALREADY_CLAIMED":
|
|
321
|
+
return ClaimResult(ClaimResultType.ALREADY_CLAIMED, record)
|
|
322
|
+
raise StorageError(f"redis claim unexpected outcome: {outcome!r}")
|
|
323
|
+
|
|
324
|
+
async def complete(
|
|
325
|
+
self,
|
|
326
|
+
effective_key: str,
|
|
327
|
+
claim_token: str,
|
|
328
|
+
response_status: int,
|
|
329
|
+
response_headers: dict[str, str],
|
|
330
|
+
response_body: bytes,
|
|
331
|
+
expires_after_seconds: float,
|
|
332
|
+
) -> bool:
|
|
333
|
+
body_b64 = base64.b64encode(response_body).decode("ascii")
|
|
334
|
+
try:
|
|
335
|
+
result = await self._eval(
|
|
336
|
+
_LUA_COMPLETE,
|
|
337
|
+
"_sha_complete",
|
|
338
|
+
[self._key(effective_key)],
|
|
339
|
+
[
|
|
340
|
+
claim_token,
|
|
341
|
+
response_status,
|
|
342
|
+
json.dumps(response_headers),
|
|
343
|
+
body_b64,
|
|
344
|
+
int(expires_after_seconds * 1000),
|
|
345
|
+
self._channel,
|
|
346
|
+
],
|
|
347
|
+
)
|
|
348
|
+
except Exception as e:
|
|
349
|
+
raise StorageError(f"redis complete failed: {e}") from e
|
|
350
|
+
return bool(int(result))
|
|
351
|
+
|
|
352
|
+
async def release(self, effective_key: str, claim_token: str) -> bool:
|
|
353
|
+
try:
|
|
354
|
+
result = await self._eval(
|
|
355
|
+
_LUA_RELEASE,
|
|
356
|
+
"_sha_release",
|
|
357
|
+
[self._key(effective_key)],
|
|
358
|
+
[claim_token, self._channel],
|
|
359
|
+
)
|
|
360
|
+
except Exception as e:
|
|
361
|
+
raise StorageError(f"redis release failed: {e}") from e
|
|
362
|
+
return bool(int(result))
|
|
363
|
+
|
|
364
|
+
async def renew(
|
|
365
|
+
self,
|
|
366
|
+
effective_key: str,
|
|
367
|
+
claim_token: str,
|
|
368
|
+
lease_ttl_seconds: float,
|
|
369
|
+
) -> bool:
|
|
370
|
+
lease_ms = int(lease_ttl_seconds * 1000)
|
|
371
|
+
grace_ms = int(self._lease_grace_seconds * 1000)
|
|
372
|
+
try:
|
|
373
|
+
result = await self._eval(
|
|
374
|
+
_LUA_RENEW,
|
|
375
|
+
"_sha_renew",
|
|
376
|
+
[self._key(effective_key)],
|
|
377
|
+
[claim_token, lease_ms, grace_ms],
|
|
378
|
+
)
|
|
379
|
+
except Exception as e:
|
|
380
|
+
raise StorageError(f"redis renew failed: {e}") from e
|
|
381
|
+
return bool(int(result))
|
|
382
|
+
|
|
383
|
+
async def wait_for_completion(
|
|
384
|
+
self,
|
|
385
|
+
effective_key: str,
|
|
386
|
+
timeout_seconds: float,
|
|
387
|
+
) -> StoredRecord | None:
|
|
388
|
+
await self._ensure_subscriber()
|
|
389
|
+
|
|
390
|
+
# Waiters are keyed by the STORAGE key (namespace-prefixed) so a Pub/Sub
|
|
391
|
+
# payload, which carries that same storage key, matches the right waiters.
|
|
392
|
+
storage_key = self._key(effective_key)
|
|
393
|
+
event = asyncio.Event()
|
|
394
|
+
self._waiters.setdefault(storage_key, set()).add(event)
|
|
395
|
+
|
|
396
|
+
# Subscribe-then-read (spec §4.3), then wait on a NOTIFICATION-OR-POLL
|
|
397
|
+
# schedule (spec §5.5). Redis Pub/Sub is at-most-once — a notification
|
|
398
|
+
# can be dropped on a connection blip, failover, or reshard, and the
|
|
399
|
+
# subscriber loop can die. The Pub/Sub event is the latency optimization;
|
|
400
|
+
# the bounded backed-off GET is the correctness floor, so a lost
|
|
401
|
+
# notification costs extra latency, never a hang to the full timeout.
|
|
402
|
+
deadline = time.monotonic() + timeout_seconds
|
|
403
|
+
poll = _POLL_INITIAL_SECONDS
|
|
404
|
+
try:
|
|
405
|
+
while True:
|
|
406
|
+
record = await self._get_record(effective_key)
|
|
407
|
+
if record is None:
|
|
408
|
+
return None
|
|
409
|
+
if record.state == State.COMPLETED:
|
|
410
|
+
return record
|
|
411
|
+
remaining = deadline - time.monotonic()
|
|
412
|
+
if remaining <= 0:
|
|
413
|
+
return None
|
|
414
|
+
event.clear()
|
|
415
|
+
try:
|
|
416
|
+
await asyncio.wait_for(event.wait(), timeout=min(poll, remaining))
|
|
417
|
+
except asyncio.TimeoutError:
|
|
418
|
+
pass
|
|
419
|
+
poll = min(poll * 2, _POLL_CAP_SECONDS)
|
|
420
|
+
finally:
|
|
421
|
+
waiters = self._waiters.get(storage_key)
|
|
422
|
+
if waiters is not None:
|
|
423
|
+
waiters.discard(event)
|
|
424
|
+
if not waiters:
|
|
425
|
+
self._waiters.pop(storage_key, None)
|
|
426
|
+
|
|
427
|
+
# ----- internals -----
|
|
428
|
+
|
|
429
|
+
def _key(self, effective_key: str) -> str:
|
|
430
|
+
"""The Redis key for a logical effective_key (namespace-prefixed)."""
|
|
431
|
+
return f"{self._namespace}:{effective_key}" if self._namespace else effective_key
|
|
432
|
+
|
|
433
|
+
async def _eval(
|
|
434
|
+
self,
|
|
435
|
+
script: str,
|
|
436
|
+
sha_attr: str,
|
|
437
|
+
keys: list[str],
|
|
438
|
+
args: list[Any],
|
|
439
|
+
) -> Any:
|
|
440
|
+
"""Execute a Lua script via EVALSHA with NOSCRIPT → EVAL fallback (§7.3)."""
|
|
441
|
+
sha: str | None = getattr(self, sha_attr)
|
|
442
|
+
if sha is None:
|
|
443
|
+
sha = await self._client.script_load(script)
|
|
444
|
+
setattr(self, sha_attr, sha)
|
|
445
|
+
try:
|
|
446
|
+
return await self._client.evalsha(sha, len(keys), *keys, *args)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
# redis-py raises NoScriptError on NOSCRIPT (script cache lost after
|
|
449
|
+
# restart / failover / cluster reshard). Reload and retry once (§7.3).
|
|
450
|
+
if type(e).__name__ == "NoScriptError" or "NOSCRIPT" in str(e):
|
|
451
|
+
sha = await self._client.script_load(script)
|
|
452
|
+
setattr(self, sha_attr, sha)
|
|
453
|
+
return await self._client.evalsha(sha, len(keys), *keys, *args)
|
|
454
|
+
raise
|
|
455
|
+
|
|
456
|
+
async def _get_record(self, effective_key: str) -> StoredRecord | None:
|
|
457
|
+
try:
|
|
458
|
+
raw = await self._client.get(self._key(effective_key))
|
|
459
|
+
except Exception as e:
|
|
460
|
+
raise StorageError(f"redis get failed: {e}") from e
|
|
461
|
+
if raw is None:
|
|
462
|
+
return None
|
|
463
|
+
try:
|
|
464
|
+
return self._record_from_json(effective_key, _decode(raw))
|
|
465
|
+
except Exception:
|
|
466
|
+
# Corrupt record (§4.12): treat as ABSENT
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
def _record_from_json(self, effective_key: str, record_json: str) -> StoredRecord:
|
|
470
|
+
data = json.loads(record_json)
|
|
471
|
+
response_body_b64 = data.get("response_body_b64", "") or ""
|
|
472
|
+
response_body = base64.b64decode(response_body_b64) if response_body_b64 else b""
|
|
473
|
+
return StoredRecord(
|
|
474
|
+
effective_key=effective_key,
|
|
475
|
+
state=State(data["state"]),
|
|
476
|
+
fingerprint=data["fingerprint"],
|
|
477
|
+
fingerprint_version=int(data["fingerprint_version"]),
|
|
478
|
+
claim_token=data["claim_token"],
|
|
479
|
+
claimed_at=float(data["claimed_at_ms"]) / 1000.0,
|
|
480
|
+
lease_until=float(data["lease_until_ms"]) / 1000.0,
|
|
481
|
+
completed_at=(
|
|
482
|
+
float(data["completed_at_ms"]) / 1000.0
|
|
483
|
+
if data.get("completed_at_ms") is not None
|
|
484
|
+
else None
|
|
485
|
+
),
|
|
486
|
+
response_status=data.get("response_status"),
|
|
487
|
+
response_headers=data.get("response_headers") or {},
|
|
488
|
+
response_body=response_body,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
async def _ensure_subscriber(self) -> None:
|
|
492
|
+
if self._subscriber_task is not None and not self._subscriber_task.done():
|
|
493
|
+
return
|
|
494
|
+
async with self._subscriber_lock:
|
|
495
|
+
if self._subscriber_task is not None and not self._subscriber_task.done():
|
|
496
|
+
return
|
|
497
|
+
self._subscriber_ready = asyncio.Event()
|
|
498
|
+
self._subscriber_task = asyncio.create_task(self._subscribe_loop())
|
|
499
|
+
try:
|
|
500
|
+
await asyncio.wait_for(self._subscriber_ready.wait(), timeout=2.0)
|
|
501
|
+
except asyncio.TimeoutError:
|
|
502
|
+
_logger.warning("idemkit: Redis subscriber did not become ready in 2s")
|
|
503
|
+
|
|
504
|
+
async def _subscribe_loop(self) -> None:
|
|
505
|
+
try:
|
|
506
|
+
pubsub = self._client.pubsub()
|
|
507
|
+
await pubsub.subscribe(self._channel)
|
|
508
|
+
self._subscriber_ready.set()
|
|
509
|
+
try:
|
|
510
|
+
async for message in pubsub.listen():
|
|
511
|
+
if message is None:
|
|
512
|
+
continue
|
|
513
|
+
mtype = message.get("type")
|
|
514
|
+
if mtype != "message":
|
|
515
|
+
continue
|
|
516
|
+
payload = message.get("data")
|
|
517
|
+
if isinstance(payload, bytes):
|
|
518
|
+
payload = payload.decode("utf-8", errors="ignore")
|
|
519
|
+
if not isinstance(payload, str):
|
|
520
|
+
continue
|
|
521
|
+
self._signal(payload)
|
|
522
|
+
finally:
|
|
523
|
+
try:
|
|
524
|
+
await pubsub.unsubscribe(self._channel)
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
try:
|
|
528
|
+
await pubsub.aclose()
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
except asyncio.CancelledError:
|
|
532
|
+
raise
|
|
533
|
+
except Exception:
|
|
534
|
+
_logger.exception("idemkit: Redis subscriber loop crashed")
|
|
535
|
+
|
|
536
|
+
def _signal(self, storage_key: str) -> None:
|
|
537
|
+
waiters = self._waiters.get(storage_key)
|
|
538
|
+
if not waiters:
|
|
539
|
+
return
|
|
540
|
+
for event in list(waiters):
|
|
541
|
+
event.set()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _decode(value: Any) -> str:
|
|
545
|
+
if isinstance(value, bytes):
|
|
546
|
+
return value.decode("utf-8")
|
|
547
|
+
return str(value)
|