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.
- taskwire/__init__.py +135 -0
- taskwire/ambient.py +161 -0
- taskwire/conformance.py +474 -0
- taskwire/contrib/__init__.py +17 -0
- taskwire/contrib/asgi.py +91 -0
- taskwire/contrib/celery.py +352 -0
- taskwire/contrib/fastapi.py +82 -0
- taskwire/contrib/muxws.py +370 -0
- taskwire/contrib/redis_store.py +575 -0
- taskwire/contrib/schema.py +204 -0
- taskwire/contrib/threads.py +43 -0
- taskwire/contrib/viewsets.py +292 -0
- taskwire/decorators.py +88 -0
- taskwire/delivery.py +123 -0
- taskwire/headers.py +36 -0
- taskwire/models.py +683 -0
- taskwire/py.typed +0 -0
- taskwire/reader.py +120 -0
- taskwire/register.py +182 -0
- taskwire/reporter.py +1162 -0
- taskwire/rest.py +312 -0
- taskwire/settings.py +126 -0
- taskwire/store.py +647 -0
- taskwire/transport.py +95 -0
- taskwire-0.1.0.dist-info/METADATA +138 -0
- taskwire-0.1.0.dist-info/RECORD +28 -0
- taskwire-0.1.0.dist-info/WHEEL +4 -0
- taskwire-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|