taskwire 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.
@@ -0,0 +1,575 @@
1
+ """`RedisStore` - the cross-process store, and the `bcx`-style backplane it publishes on.
2
+
3
+ Imports `redis`; nothing in `taskwire/` outside `contrib` may (TW-CORE-006).
4
+
5
+ **Five operations are Lua scripts** (TW-STORE-013): `commit`, `put_dialog`, `resolve_dialog`,
6
+ `withdraw_dialogs` and `release_result`. Each is a read-modify-write that has to be indivisible, and
7
+ Redis gives that only inside a script. Doing them as WATCH/MULTI would be a retry loop whose failure
8
+ mode is a lost dialog answer under exactly the concurrency the design is about.
9
+
10
+ The keyspace is fixed by TW-KEY-001 and nothing may be added to it:
11
+
12
+ | Key | Type | Contents |
13
+ |---|---|---|
14
+ | `tw:{ns}:{token}:progress` | string | JSON `Progress` |
15
+ | `tw:{ns}:{token}:dialogs` | hash | `id` -> JSON `DialogRequest` |
16
+ | `tw:{ns}:{token}:d:{did}:reply` | list | wakeup channel for a blocked worker |
17
+ | `tw:{ns}:{token}:cancel` | string | `"1"` when cancel requested |
18
+ | `tw:{ns}:{token}:rev` | integer | seeded per TW-REV-002, `INCR` on every commit |
19
+ | `tw:{ns}:index` | sorted set | token -> `created_at` ms, **shared operations only** |
20
+ | `tw:{ns}:asking` | set | tokens holding at least one **open** dialog |
21
+ | `twx:{ns}` | pub/sub | one channel per namespace |
22
+
23
+ There is deliberately **no `:seen` key, no watcher list and no per-tab bookkeeping** (TW-KEY-002),
24
+ and no per-token channel (TW-BP-001/002): a per-token subscription has a startup race, it leaks, and
25
+ it would make the fan-out unit smaller than the security unit.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import logging
33
+
34
+ from typing import Any
35
+
36
+ import redis.asyncio as aioredis
37
+
38
+ from ..models import (
39
+ DialogReply,
40
+ DialogRequest,
41
+ DialogState,
42
+ Envelope,
43
+ Progress,
44
+ ProgressState,
45
+ Result,
46
+ Snapshot,
47
+ )
48
+ from ..store import CANCELLED_BUTTON, Commit, DialogResolution, DialogVanished, split_key, TaskwireStore
49
+
50
+ logger = logging.getLogger("taskwire.redis")
51
+
52
+ _BLPOP_SECONDS = 5
53
+ """How long `await_dialog` blocks per iteration (TW-BP-008).
54
+
55
+ A liveness device inside the store, never a deadline offered to `ask()` - the loop it bounds has no
56
+ overall deadline. A lost notification costs this much latency rather than hanging forever, which is
57
+ why the loop re-reads the dialog document on every wakeup *and* on every expiry.
58
+ """
59
+
60
+ # --------------------------------------------------------------------------- Lua
61
+
62
+ _COMMIT = """
63
+ local progress_key, rev_key, index_key, cancel_key = KEYS[1], KEYS[2], KEYS[3], KEYS[4]
64
+ local body, ttl, now_ms, token, is_shared, created_at = ARGV[1], tonumber(ARGV[2]),
65
+ tonumber(ARGV[3]), ARGV[4], ARGV[5], ARGV[6]
66
+
67
+ local existing = redis.call('GET', progress_key)
68
+ local doc = cjson.decode(body)
69
+
70
+ if existing then
71
+ local previous = cjson.decode(existing)
72
+ -- TW-STORE-010: terminal is final. Display fields may still be corrected; the state may not.
73
+ if previous.state == 'done' or previous.state == 'failed' or previous.state == 'cancelled' then
74
+ doc.state = previous.state
75
+ doc.error = previous.error
76
+ doc.result = previous.result
77
+ end
78
+ -- TW-PROG-006: the write-once fields keep whatever the opening write put there.
79
+ doc.result_kind = previous.result_kind
80
+ doc.origin_session = previous.origin_session
81
+ doc.origin_connection = previous.origin_connection
82
+ doc.created_at = previous.created_at
83
+ end
84
+
85
+ -- TW-REV-002: max(now_ms, previous + 1). Never seeded from zero: a key that expired under a live
86
+ -- operation would otherwise resurrect below the client's last-seen value and every envelope from
87
+ -- then on would be dropped by client dedup - a bar frozen at 97% with nothing logged.
88
+ local previous_rev = tonumber(redis.call('GET', rev_key) or '0')
89
+ local rev = now_ms
90
+ if previous_rev + 1 > rev then rev = previous_rev + 1 end
91
+
92
+ redis.call('SET', rev_key, rev, 'EX', ttl)
93
+ redis.call('SET', progress_key, cjson.encode(doc), 'EX', ttl)
94
+ if redis.call('EXISTS', cancel_key) == 1 then redis.call('EXPIRE', cancel_key, ttl) end
95
+
96
+ -- TW-KEY-003: indexed only if shared, and the decision is read off the STORED document.
97
+ --
98
+ -- A terminal document is never in the register (TW-REG-007). De-indexing it here rather than in a
99
+ -- second call from the caller is what makes that true of every read: two steps would leave a window
100
+ -- in which a `failed` row is listed, and the register is read continuously.
101
+ if doc.state == 'done' or doc.state == 'failed' or doc.state == 'cancelled' then
102
+ redis.call('ZREM', index_key, token)
103
+ elseif doc.result_kind ~= nil and doc.result_kind ~= cjson.null then
104
+ redis.call('ZADD', index_key, created_at, token)
105
+ end
106
+
107
+ -- TW-STORE-014: the cancellation flag travels back with the rev, in one round trip.
108
+ return {rev, redis.call('EXISTS', cancel_key)}
109
+ """
110
+
111
+ _PUT_DIALOG = """
112
+ local dialogs_key, rev_key, asking_key, reply_key = KEYS[1], KEYS[2], KEYS[3], KEYS[4]
113
+ local did, body, ttl, now_ms, token = ARGV[1], ARGV[2], tonumber(ARGV[3]), tonumber(ARGV[4]), ARGV[5]
114
+
115
+ redis.call('HSET', dialogs_key, did, body)
116
+ redis.call('EXPIRE', dialogs_key, ttl)
117
+ -- TW-KEY-004: the reply list carries the operation's own TTL. One that died before its dialog would
118
+ -- silently convert an answered dialog into an unanswerable one.
119
+ redis.call('EXPIRE', reply_key, ttl)
120
+
121
+ local previous_rev = tonumber(redis.call('GET', rev_key) or '0')
122
+ local rev = now_ms
123
+ if previous_rev + 1 > rev then rev = previous_rev + 1 end
124
+ redis.call('SET', rev_key, rev, 'EX', ttl)
125
+
126
+ -- TW-KEY-005: maintained by the dialog scripts and by nothing else.
127
+ redis.call('SADD', asking_key, token)
128
+ return rev
129
+ """
130
+
131
+ _RESOLVE_DIALOG = """
132
+ local dialogs_key, rev_key, asking_key, reply_key = KEYS[1], KEYS[2], KEYS[3], KEYS[4]
133
+ local did, reply_body, now_ms, token = ARGV[1], ARGV[2], tonumber(ARGV[3]), ARGV[4]
134
+
135
+ local raw = redis.call('HGET', dialogs_key, did)
136
+ if not raw then return {'not_found', 0} end
137
+
138
+ local dialog = cjson.decode(raw)
139
+ if dialog.state == 'answered' then return {'already_answered', 0} end
140
+ if dialog.state == 'cancelled' then return {'cancelled', 0} end
141
+
142
+ dialog.state = 'answered'
143
+ dialog.reply = cjson.decode(reply_body)
144
+ redis.call('HSET', dialogs_key, did, cjson.encode(dialog))
145
+
146
+ local previous_rev = tonumber(redis.call('GET', rev_key) or '0')
147
+ local rev = now_ms
148
+ if previous_rev + 1 > rev then rev = previous_rev + 1 end
149
+ redis.call('SET', rev_key, rev, 'KEEPTTL')
150
+
151
+ -- Wake the blocked worker. RPUSH rather than PUBLISH: a message published before the worker reached
152
+ -- its wait would be lost, and the worker would then wait forever on a question already answered
153
+ -- (TW-BP-007).
154
+ redis.call('RPUSH', reply_key, reply_body)
155
+
156
+ -- Leave the asking set once no dialog under this token is still open.
157
+ local still_open = 0
158
+ local all = redis.call('HGETALL', dialogs_key)
159
+ for i = 2, #all, 2 do
160
+ if cjson.decode(all[i]).state == 'open' then still_open = 1 end
161
+ end
162
+ if still_open == 0 then redis.call('SREM', asking_key, token) end
163
+
164
+ return {'accepted', rev}
165
+ """
166
+
167
+ _WITHDRAW_DIALOGS = """
168
+ local dialogs_key, rev_key, asking_key = KEYS[1], KEYS[2], KEYS[3]
169
+ local now_ms, token, sentinel, reply_prefix = tonumber(ARGV[1]), ARGV[2], ARGV[3], ARGV[4]
170
+
171
+ local withdrawn = {}
172
+ local all = redis.call('HGETALL', dialogs_key)
173
+ for i = 1, #all, 2 do
174
+ local did, dialog = all[i], cjson.decode(all[i + 1])
175
+ if dialog.state == 'open' then
176
+ dialog.state = 'cancelled'
177
+ redis.call('HSET', dialogs_key, did, cjson.encode(dialog))
178
+ -- The sentinel is what wakes a worker blocked in ask(); with dialog timeouts gone it is the
179
+ -- only thing that can (TW-CANCEL-007).
180
+ redis.call('RPUSH', reply_prefix .. did .. ':reply', sentinel)
181
+ table.insert(withdrawn, did)
182
+ end
183
+ end
184
+
185
+ if #withdrawn > 0 then
186
+ -- One bump for the whole withdrawal: it is a single change of the operation's situation, and rev
187
+ -- labels the document rather than the dialogs.
188
+ local previous_rev = tonumber(redis.call('GET', rev_key) or '0')
189
+ local rev = now_ms
190
+ if previous_rev + 1 > rev then rev = previous_rev + 1 end
191
+ redis.call('SET', rev_key, rev, 'KEEPTTL')
192
+ redis.call('SREM', asking_key, token)
193
+ end
194
+ return withdrawn
195
+ """
196
+
197
+ _RELEASE_RESULT = """
198
+ local progress_key, rev_key, dialogs_key, index_key = KEYS[1], KEYS[2], KEYS[3], KEYS[4]
199
+ local now_ms, token = tonumber(ARGV[1]), ARGV[2]
200
+
201
+ local raw = redis.call('GET', progress_key)
202
+ if not raw then return nil end
203
+ local doc = cjson.decode(raw)
204
+ if doc.result == nil or doc.result == cjson.null then return nil end
205
+ if doc.state == 'done' or doc.state == 'failed' or doc.state == 'cancelled' then return nil end
206
+
207
+ -- A blocked worker is not a collectable result (TW-REST-008).
208
+ local all = redis.call('HGETALL', dialogs_key)
209
+ for i = 2, #all, 2 do
210
+ if cjson.decode(all[i]).state == 'open' then return nil end
211
+ end
212
+
213
+ local released = cjson.encode(doc.result)
214
+ doc.result = cjson.null
215
+ doc.state = 'done'
216
+ redis.call('SET', progress_key, cjson.encode(doc), 'KEEPTTL')
217
+
218
+ local previous_rev = tonumber(redis.call('GET', rev_key) or '0')
219
+ local rev = now_ms
220
+ if previous_rev + 1 > rev then rev = previous_rev + 1 end
221
+ redis.call('SET', rev_key, rev, 'KEEPTTL')
222
+
223
+ -- The write that ends an operation takes it out of the register, wherever that write happens
224
+ -- (TW-REG-007). This one ends it as surely as a terminal commit does.
225
+ redis.call('ZREM', index_key, token)
226
+ return released
227
+ """
228
+
229
+
230
+ class RedisStore(TaskwireStore):
231
+ """The cross-process store. Passes the exported conformance suite unchanged (TW-STORE-011).
232
+
233
+ "Unchanged" is the contract: `RedisStore` gets no weakened copy of the suite. If a rule in it
234
+ cannot be satisfied by a real backend then the rule is wrong and the specification changes.
235
+ """
236
+
237
+ def __init__(self, url: str = "redis://127.0.0.1:6379/0", *, client: Any = None) -> None:
238
+ self._redis = client if client is not None else aioredis.Redis.from_url(url, decode_responses=True)
239
+ self._commit = self._redis.register_script(_COMMIT)
240
+ self._put_dialog = self._redis.register_script(_PUT_DIALOG)
241
+ self._resolve_dialog = self._redis.register_script(_RESOLVE_DIALOG)
242
+ self._withdraw_dialogs = self._redis.register_script(_WITHDRAW_DIALOGS)
243
+ self._release_result = self._redis.register_script(_RELEASE_RESULT)
244
+
245
+ # ---------------------------------------------------------------- keys
246
+
247
+ @staticmethod
248
+ def _keys(key: str) -> dict[str, str]:
249
+ ns, token = split_key(key)
250
+ return {
251
+ "progress": f"tw:{ns}:{token}:progress",
252
+ "dialogs": f"tw:{ns}:{token}:dialogs",
253
+ "cancel": f"tw:{ns}:{token}:cancel",
254
+ "rev": f"tw:{ns}:{token}:rev",
255
+ "index": f"tw:{ns}:index",
256
+ "asking": f"tw:{ns}:asking",
257
+ "reply_prefix": f"tw:{ns}:{token}:d:",
258
+ "ns": ns,
259
+ "token": token,
260
+ }
261
+
262
+ @staticmethod
263
+ def _now_ms() -> int:
264
+ import time
265
+
266
+ return int(time.time() * 1000)
267
+
268
+ # ---------------------------------------------------------------- writes
269
+
270
+ async def commit(self, key: str, progress: Progress, ttl: float) -> Commit: # noqa: A002
271
+ """TW-STORE-014: one script call answers with the rev and the cancel flag it saw."""
272
+ k = self._keys(key)
273
+ created_ms = self._now_ms()
274
+ rev, cancelled = await self._commit(
275
+ keys=[k["progress"], k["rev"], k["index"], k["cancel"]],
276
+ args=[
277
+ json.dumps(progress.to_dict()),
278
+ int(ttl),
279
+ self._now_ms(),
280
+ k["token"],
281
+ "1" if progress.is_shared else "0",
282
+ created_ms,
283
+ ],
284
+ )
285
+ return Commit(rev=int(rev), cancelled=bool(int(cancelled)))
286
+
287
+ async def touch(self, key: str, ttl: float) -> bool: # noqa: A002
288
+ k = self._keys(key)
289
+ if not await self._redis.exists(k["progress"]):
290
+ return False
291
+ pipe = self._redis.pipeline()
292
+ for name in ("progress", "rev", "dialogs", "cancel"):
293
+ pipe.expire(k[name], int(ttl))
294
+ await pipe.execute()
295
+ return True
296
+
297
+ async def request_cancel(self, key: str) -> int: # noqa: A002
298
+ """TW-CANCEL-001: sticky, and never cleared.
299
+
300
+ Withdrawing the open dialogs in the same breath is TW-CANCEL-007: with no dialog timeouts,
301
+ the sentinel this pushes is the only thing that can wake a worker blocked in `ask()`.
302
+ """
303
+ k = self._keys(key)
304
+ if not await self._redis.exists(k["progress"]):
305
+ return 0
306
+ ttl = await self._redis.ttl(k["progress"])
307
+ await self._redis.set(k["cancel"], "1", ex=ttl if ttl and ttl > 0 else None)
308
+ await self.withdraw_dialogs(key)
309
+ rev = await self._redis.incr(k["rev"])
310
+ return int(rev)
311
+
312
+ async def drop(self, key: str) -> None: # noqa: A002
313
+ k = self._keys(key)
314
+ dids = await self._redis.hkeys(k["dialogs"])
315
+ pipe = self._redis.pipeline()
316
+ pipe.delete(k["progress"], k["dialogs"], k["cancel"], k["rev"])
317
+ for did in dids:
318
+ pipe.delete(f"{k['reply_prefix']}{did}:reply")
319
+ pipe.zrem(k["index"], k["token"])
320
+ pipe.srem(k["asking"], k["token"])
321
+ await pipe.execute()
322
+
323
+ async def migrate(self, from_ns: str, to_ns: str) -> int:
324
+ """TW-STORE-015. Idempotent: a token already under `to_ns` is left alone and its source dropped.
325
+
326
+ Uses the index and the asking set to enumerate, never a `SCAN` (TW-KEY-003).
327
+ """
328
+ moved = 0
329
+ tokens = set(await self._redis.zrange(f"tw:{from_ns}:index", 0, -1))
330
+ tokens |= set(await self._redis.smembers(f"tw:{from_ns}:asking"))
331
+ for token in tokens:
332
+ source, destination = f"{from_ns}:{token}", f"{to_ns}:{token}"
333
+ s, d = self._keys(source), self._keys(destination)
334
+ if await self._redis.exists(d["progress"]):
335
+ await self.drop(source)
336
+ continue
337
+ ttl = await self._redis.ttl(s["progress"])
338
+ pipe = self._redis.pipeline()
339
+ for name in ("progress", "dialogs", "cancel", "rev"):
340
+ if await self._redis.exists(s[name]):
341
+ pipe.rename(s[name], d[name])
342
+ score = await self._redis.zscore(s["index"], token)
343
+ if score is not None:
344
+ pipe.zadd(d["index"], {token: score})
345
+ pipe.zrem(s["index"], token)
346
+ if await self._redis.sismember(s["asking"], token):
347
+ pipe.sadd(d["asking"], token)
348
+ pipe.srem(s["asking"], token)
349
+ await pipe.execute()
350
+ if ttl and ttl > 0:
351
+ await self.touch(destination, ttl)
352
+ moved += 1
353
+ return moved
354
+
355
+ async def publish(self, ns: str, envelope: Envelope) -> None:
356
+ """TW-BP-001: one channel per namespace, `twx:{ns}`, and never one per token.
357
+
358
+ MUST NOT raise (TW-STORE-008): the state is already durable by the time this runs.
359
+ """
360
+ try:
361
+ await self._redis.publish(f"twx:{ns}", json.dumps(envelope.to_dict()))
362
+ except Exception: # noqa: BLE001 - a publish is a notification, never the copy of record
363
+ logger.debug("taskwire: publish failed for %s", ns, exc_info=True)
364
+
365
+ # ---------------------------------------------------------------- reads
366
+
367
+ async def snapshot(self, key: str) -> Snapshot | None: # noqa: A002
368
+ """TW-STORE-009: no read writes anything, TTL included."""
369
+ k = self._keys(key)
370
+ pipe = self._redis.pipeline()
371
+ pipe.get(k["progress"])
372
+ pipe.get(k["rev"])
373
+ pipe.hgetall(k["dialogs"])
374
+ pipe.exists(k["cancel"])
375
+ raw, rev, dialogs, cancelled = await pipe.execute()
376
+ if raw is None:
377
+ return None
378
+
379
+ progress = Progress.from_dict(json.loads(raw))
380
+ requests = [DialogRequest.from_dict(json.loads(body)) for body in (dialogs or {}).values()]
381
+ return Snapshot(
382
+ token=k["token"],
383
+ rev=int(rev or 0),
384
+ progress=self._derived(progress, requests),
385
+ dialogs=requests,
386
+ cancel_requested=bool(cancelled),
387
+ )
388
+
389
+ @staticmethod
390
+ def _derived(progress: Progress, dialogs: list[DialogRequest]) -> Progress:
391
+ """`waiting_input` on the way out, never written (TW-DLG-009 with TW-PROG-010)."""
392
+ from dataclasses import replace
393
+
394
+ if progress.is_terminal:
395
+ return progress
396
+ has_open = any(DialogState(d.state) is DialogState.OPEN for d in dialogs)
397
+ if not has_open and progress.result is None:
398
+ return progress
399
+ return replace(progress, state=ProgressState.WAITING_INPUT)
400
+
401
+ async def is_cancelled(self, key: str) -> bool: # noqa: A002
402
+ return bool(await self._redis.exists(self._keys(key)["cancel"]))
403
+
404
+ async def list_operations(self, ns: str) -> list[Snapshot]:
405
+ """TW-STORE-006: the shared index UNION anything asking, pruned lazily, never a `SCAN`."""
406
+ index_key, asking_key = f"tw:{ns}:index", f"tw:{ns}:asking"
407
+ indexed = list(await self._redis.zrange(index_key, 0, -1))
408
+ asking = list(await self._redis.smembers(asking_key))
409
+ tokens = list(dict.fromkeys(indexed + asking))
410
+
411
+ snapshots: list[Snapshot] = []
412
+ stale: list[str] = []
413
+ for token in tokens:
414
+ snapshot = await self.snapshot(f"{ns}:{token}")
415
+ if snapshot is None:
416
+ stale.append(token)
417
+ continue
418
+ snapshots.append(snapshot)
419
+
420
+ if stale:
421
+ # Lazy pruning of the index structures is what TW-STORE-006 requires of a listing read;
422
+ # it is not a read side effect on the documents, which are already gone.
423
+ pipe = self._redis.pipeline()
424
+ pipe.zrem(index_key, *stale)
425
+ pipe.srem(asking_key, *stale)
426
+ await pipe.execute()
427
+ return snapshots
428
+
429
+ # ---------------------------------------------------------------- dialogs
430
+
431
+ async def put_dialog(self, key: str, dialog: DialogRequest, ttl: float) -> int: # noqa: A002
432
+ k = self._keys(key)
433
+ rev = await self._put_dialog(
434
+ keys=[k["dialogs"], k["rev"], k["asking"], f"{k['reply_prefix']}{dialog.id}:reply"],
435
+ args=[dialog.id, json.dumps(dialog.to_dict()), int(ttl), self._now_ms(), k["token"]],
436
+ )
437
+ return int(rev)
438
+
439
+ async def resolve_dialog(self, key: str, did: str, reply: DialogReply) -> DialogResolution: # noqa: A002
440
+ k = self._keys(key)
441
+ outcome, _rev = await self._resolve_dialog(
442
+ keys=[k["dialogs"], k["rev"], k["asking"], f"{k['reply_prefix']}{did}:reply"],
443
+ args=[did, json.dumps(reply.to_dict()), self._now_ms(), k["token"]],
444
+ )
445
+ return DialogResolution(outcome)
446
+
447
+ async def withdraw_dialogs(self, key: str) -> list[str]: # noqa: A002
448
+ k = self._keys(key)
449
+ withdrawn = await self._withdraw_dialogs(
450
+ keys=[k["dialogs"], k["rev"], k["asking"]],
451
+ args=[
452
+ self._now_ms(),
453
+ k["token"],
454
+ json.dumps(DialogReply(button=CANCELLED_BUTTON).to_dict()),
455
+ k["reply_prefix"],
456
+ ],
457
+ )
458
+ return list(withdrawn or [])
459
+
460
+ async def await_dialog(self, key: str, did: str) -> DialogReply: # noqa: A002
461
+ """`BLPOP` with a bounded internal timeout, in a loop with **no overall deadline** (TW-BP-008).
462
+
463
+ Pub/sub would be wrong here and TW-BP-007 says why: a message published before the worker
464
+ reached its wait is simply lost, and the worker would then wait forever on a question that
465
+ was already answered. A list survives the race.
466
+ """
467
+ k = self._keys(key)
468
+ reply_key = f"{k['reply_prefix']}{did}:reply"
469
+ while True:
470
+ raw = await self._redis.hget(k["dialogs"], did)
471
+ if raw is None:
472
+ raise DialogVanished(f"taskwire: dialog {did} no longer exists")
473
+ dialog = DialogRequest.from_dict(json.loads(raw))
474
+ state = DialogState(dialog.state)
475
+ if state is DialogState.ANSWERED and dialog.reply is not None:
476
+ return dialog.reply
477
+ if state is DialogState.CANCELLED:
478
+ return DialogReply(button=CANCELLED_BUTTON)
479
+
480
+ popped = await self._redis.blpop([reply_key], timeout=_BLPOP_SECONDS)
481
+ if popped is not None:
482
+ return DialogReply.from_dict(json.loads(popped[1])) or DialogReply(button=CANCELLED_BUTTON)
483
+ # Expired: re-read and wait again. No overall deadline is consumed by this.
484
+
485
+ async def release_result(self, key: str) -> Result | None: # noqa: A002
486
+ k = self._keys(key)
487
+ released = await self._release_result(
488
+ keys=[k["progress"], k["rev"], k["dialogs"], k["index"]],
489
+ args=[self._now_ms(), k["token"]],
490
+ )
491
+ if released is None:
492
+ return None
493
+ return Result.from_dict(json.loads(released))
494
+
495
+ def make_reader(self) -> RedisReader:
496
+ """The lifecycle in `taskwire.reader` looks for this, so an application writes the same
497
+ startup code whichever store it configured (§5.4).
498
+ """
499
+ return RedisReader(self)
500
+
501
+ async def aclose(self) -> None:
502
+ """Close the Redis connection this store was built with."""
503
+ await self._redis.aclose()
504
+
505
+
506
+ # --------------------------------------------------------------------------- the reader
507
+
508
+
509
+ class RedisReader:
510
+ """The long-lived per-web-process consumer of `twx:{ns}` (TW-CORE-004, TW-BP-005).
511
+
512
+ It is the **only** caller of `transport.notify()` in a cross-process deployment, exactly as
513
+ `MemoryStore.publish` is in a single-process one. A web process subscribes only to the namespaces
514
+ it currently holds connections for and unsubscribes when it holds none (TW-BP-004) - that is the
515
+ only discrimination at the channel level, and it is per *process*, never per token or per tab.
516
+ """
517
+
518
+ def __init__(self, store: RedisStore) -> None:
519
+ self._store = store
520
+ self._pubsub: Any = None
521
+ self._task: asyncio.Task | None = None
522
+ self._namespaces: set[str] = set()
523
+
524
+ async def start(self) -> None:
525
+ """Open the pubsub connection and begin consuming. Idempotent: a started reader stays as is."""
526
+ if self._task is not None:
527
+ return
528
+ self._pubsub = self._store._redis.pubsub()
529
+ self._task = asyncio.get_running_loop().create_task(self._run())
530
+
531
+ async def stop(self) -> None:
532
+ """Cancel the consumer task and close the pubsub connection. The watched set is left alone."""
533
+ if self._task is not None:
534
+ self._task.cancel()
535
+ self._task = None
536
+ if self._pubsub is not None:
537
+ await self._pubsub.aclose()
538
+ self._pubsub = None
539
+
540
+ async def watch(self, ns: str) -> None:
541
+ """Subscribe this process to `twx:{ns}`.
542
+
543
+ Per **process**, never per token or per tab (TW-BP-004): a process subscribes to a namespace
544
+ because it holds a connection for it, and one subscription serves every connection it holds.
545
+ """
546
+ self._namespaces.add(ns)
547
+ if self._pubsub is not None:
548
+ await self._pubsub.subscribe(f"twx:{ns}")
549
+
550
+ async def unwatch(self, ns: str) -> None:
551
+ """Unsubscribe this process from `twx:{ns}`.
552
+
553
+ Called once the process holds no more connections for the namespace (TW-BP-004). Skipping it
554
+ costs no correctness - the envelopes reach nobody - and leaves every process subscribed to
555
+ every namespace it ever saw.
556
+ """
557
+ self._namespaces.discard(ns)
558
+ if self._pubsub is not None:
559
+ await self._pubsub.unsubscribe(f"twx:{ns}")
560
+
561
+ async def _run(self) -> None:
562
+ from .. import reader
563
+
564
+ while True:
565
+ try:
566
+ message = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
567
+ if message is None:
568
+ continue
569
+ ns = str(message["channel"]).removeprefix("twx:")
570
+ await reader.dispatch(ns, Envelope.from_dict(json.loads(message["data"])))
571
+ except asyncio.CancelledError: # pragma: no cover - normal shutdown
572
+ raise
573
+ except Exception: # noqa: BLE001 - a reader that dies stops every push in the process
574
+ logger.warning("taskwire: reader iteration failed", exc_info=True)
575
+ await asyncio.sleep(0.1)