idemkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. idemkit/__init__.py +101 -0
  2. idemkit/_version.py +1 -0
  3. idemkit/adapters/__init__.py +10 -0
  4. idemkit/adapters/ai.py +723 -0
  5. idemkit/adapters/asgi.py +533 -0
  6. idemkit/adapters/queue.py +413 -0
  7. idemkit/adapters/route.py +273 -0
  8. idemkit/adapters/wsgi.py +393 -0
  9. idemkit/backends/__init__.py +30 -0
  10. idemkit/backends/base.py +116 -0
  11. idemkit/backends/dynamodb.py +489 -0
  12. idemkit/backends/memory.py +325 -0
  13. idemkit/backends/mongo.py +395 -0
  14. idemkit/backends/postgres.py +682 -0
  15. idemkit/backends/redis.py +547 -0
  16. idemkit/cli.py +165 -0
  17. idemkit/conformance/__init__.py +25 -0
  18. idemkit/conformance/backend.py +257 -0
  19. idemkit/conformance/report.py +54 -0
  20. idemkit/contrib/__init__.py +30 -0
  21. idemkit/contrib/drf.py +181 -0
  22. idemkit/contrib/fastapi.py +141 -0
  23. idemkit/contrib/kafka.py +96 -0
  24. idemkit/contrib/logging.py +61 -0
  25. idemkit/contrib/mcp.py +113 -0
  26. idemkit/contrib/prometheus.py +79 -0
  27. idemkit/contrib/pubsub.py +98 -0
  28. idemkit/contrib/rabbitmq.py +138 -0
  29. idemkit/contrib/reconciliation.py +86 -0
  30. idemkit/contrib/sqs.py +117 -0
  31. idemkit/core/__init__.py +36 -0
  32. idemkit/core/codecs.py +229 -0
  33. idemkit/core/config.py +255 -0
  34. idemkit/core/engine.py +331 -0
  35. idemkit/core/events.py +63 -0
  36. idemkit/core/exception_cache.py +88 -0
  37. idemkit/core/exceptions.py +79 -0
  38. idemkit/core/fingerprint.py +153 -0
  39. idemkit/core/policy.py +143 -0
  40. idemkit/core/runner.py +664 -0
  41. idemkit/core/state.py +95 -0
  42. idemkit/core/sync_bridge.py +115 -0
  43. idemkit/problem_details.py +119 -0
  44. idemkit/py.typed +0 -0
  45. idemkit-0.1.0.dist-info/METADATA +446 -0
  46. idemkit-0.1.0.dist-info/RECORD +48 -0
  47. idemkit-0.1.0.dist-info/WHEEL +4 -0
  48. idemkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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)