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,370 @@
1
+ """`MuxwsTransport` - the WebSocket implementation of the protocol.
2
+
3
+ Imports `muxws`; nothing in `taskwire/` outside `contrib` may (TW-CORE-006). Core installs and runs
4
+ with it absent.
5
+
6
+ **What travels here is the push direction, plus `watch`.** The six request/response calls of
7
+ TW-REST-004 are the viewset's on this transport as much as on REST: `route_viewset` publishes them
8
+ over muxws too, and `fastapi_viewsets.mux_ws.process_command` dispatches an inbound command stream
9
+ into the same routes. `watch` is the one call that stays here, because it declares per-connection
10
+ state held on the Peer rather than acting on a resource - there is no `{token}` it could hang off.
11
+
12
+ **taskwire's required dependency on muxws is exactly two calls** (TW-WS-001):
13
+ `PeerRegistry.peers_for(session=...)` and `peer.notify(payload)`. Not `open()`, not `stream.send()`,
14
+ not `stream.cancel()`, and nothing about muxws's frame format, stream id space or codec selection
15
+ (TW-WS-002). That narrowness is the point: it is what lets muxws change everything else about itself
16
+ without touching this file, and what lets an application replace muxws entirely by writing forty
17
+ lines against `TaskwireTransport`.
18
+
19
+ **One envelope becomes one `peer.notify()`** - one stream opened and ended in a single frame
20
+ (TW-WS-003). A long-lived per-session stream MUST NOT be used: a ten-minute import would hold a
21
+ stream open for ten minutes, where this leaves none open behind it.
22
+
23
+ Swapping `NullTransport` for this changes latency and nothing else. The store is still the truth, the
24
+ REST endpoints still answer, and a client that only polls still sees every state - which is what
25
+ makes the swap safe to make and safe to undo.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import contextlib
32
+ import logging
33
+
34
+ from collections.abc import Awaitable, Callable
35
+ from typing import Any
36
+
37
+ from muxws import PeerRegistry
38
+ from muxws.errors import ConnectionLost
39
+
40
+ from ..delivery import Client, effective_delivery, may_be_suppressed, OperationRef, OVERRIDES_TAG
41
+ from ..models import Envelope, EnvelopeKind, Progress
42
+ from ..reader import unwatch_namespace, watch_namespace
43
+ from ..settings import settings
44
+ from ..transport import TaskwireTransport
45
+
46
+ logger = logging.getLogger("taskwire.muxws")
47
+
48
+ SESSION_TAG = "session"
49
+ """The peer tag `peers_for` indexes on.
50
+
51
+ It carries the **namespace**, which is the fan-out unit and also the security unit (TW-BP-001). There
52
+ is deliberately no per-token tag and no per-tab tag: a per-token subscription has a startup race, it
53
+ leaks, and it would make the fan-out unit smaller than the unit access is decided in (TW-BP-002,
54
+ TW-KEY-002).
55
+ """
56
+
57
+
58
+ class MuxwsTransport(TaskwireTransport):
59
+ """Push one envelope to every live peer of a namespace.
60
+
61
+ A transport that knows about individual connections MUST fan out over all of them (TW-TR-005);
62
+ the per-connection delivery decision applied at the last hop is the only judgement it may make.
63
+ """
64
+
65
+ def __init__(self, registry: PeerRegistry) -> None:
66
+ self._registry = registry
67
+
68
+ async def notify(self, session: str, envelope: Envelope) -> None:
69
+ """Best-effort delivery to every peer of `session`.
70
+
71
+ **Never raises and never blocks past `push_timeout`** (TW-TR-002, TW-TR-003, TW-INV-012). A
72
+ degradation in the accelerator must not become a stall in the operation: the state this
73
+ announces was written to the store before this ran, so every failure here costs latency and
74
+ nothing else.
75
+
76
+ The peer list is treated as a snapshot (TW-WS-004). A peer that raises `ConnectionLost` is
77
+ counted as a dropped push and iteration continues - a socket dying mid-fan-out must not cost
78
+ the peers after it in the list their envelope.
79
+ """
80
+ payload = envelope.to_dict()
81
+ try:
82
+ peers = self._registry.peers_for(**{SESSION_TAG: session})
83
+ except Exception: # noqa: BLE001 - a registry that cannot answer is a dropped push
84
+ logger.debug("taskwire: could not enumerate peers for %s", session, exc_info=True)
85
+ return
86
+
87
+ # A body that will not parse means the decision cannot be made, and the decision is a
88
+ # DISPLAY optimisation - so it fails open, toward delivering. Failing closed would let a
89
+ # malformed envelope silence a bar, which is the one outcome nobody could diagnose.
90
+ progress: Progress | None = None
91
+ if envelope.kind is EnvelopeKind.PROGRESS:
92
+ try:
93
+ progress = Progress.from_dict(envelope.body)
94
+ except (KeyError, ValueError, TypeError):
95
+ logger.debug("taskwire: unparseable progress body for %s; delivering it", envelope.token)
96
+ suppressible = progress is not None and may_be_suppressed(envelope.kind, progress)
97
+ reference = OperationRef(envelope.token, progress) if progress is not None else None
98
+
99
+ for peer in peers:
100
+ # TW-DEL-011: the decision is applied at the LAST HOP only. The channel, the publish, the
101
+ # subscribe, the store and the reader are all unchanged by it - which is what lets two
102
+ # sockets of one namespace hold opposite opinions about the same token (TW-DEL-017)
103
+ # without anything having to arbitrate between them.
104
+ if suppressible and reference is not None:
105
+ client = Client(session=session, connection=connection_id(peer), peer=peer)
106
+ if not effective_delivery(client, reference):
107
+ continue
108
+
109
+ # TW-PRIV-003: a private operation's progress is written only to the connection whose id
110
+ # equals `origin_connection`, terminal states included. With `origin_connection` null -
111
+ # every caller in a deployment with no socket - no socket receives it and the
112
+ # originating client learns from its own per-token poll. A latency degradation, never a
113
+ # functional one, and the same one the design already accepts for a dropped push.
114
+ if progress is not None and not progress.is_shared:
115
+ if progress.origin_connection is None or connection_id(peer) != progress.origin_connection:
116
+ continue
117
+
118
+ try:
119
+ # One notify per peer: one stream opened and ended in one frame (TW-WS-003). muxws
120
+ # allocates the stream id and taskwire never sees or stores one (TW-WS-005).
121
+ await asyncio.wait_for(peer.notify(payload), timeout=settings.push_timeout)
122
+ except ConnectionLost:
123
+ logger.debug("taskwire: peer of %s went away mid-fan-out", session)
124
+ except Exception: # noqa: BLE001 - TW-TR-002: every failure is a dropped push
125
+ logger.debug("taskwire: dropped a push to a peer of %s", session, exc_info=True)
126
+
127
+ if progress is not None and progress.is_terminal:
128
+ prune_override(peers, envelope.token)
129
+
130
+ async def is_online(self, session: str) -> bool:
131
+ """A hint, and never load-bearing (TW-TR-001).
132
+
133
+ Nothing may branch on this in a way that changes what gets written. A peer list is a snapshot
134
+ of a moment that has already passed by the time anyone acts on it.
135
+ """
136
+ try:
137
+ return bool(self._registry.peers_for(**{SESSION_TAG: session}))
138
+ except Exception: # noqa: BLE001 - unknown means "assume yes", never "skip the write"
139
+ return True
140
+
141
+
142
+ def tag_peer(peer: Any, session: str) -> None:
143
+ """Tag a peer with its namespace so `peers_for` can find it.
144
+
145
+ Call this once, at handshake, with the value `session_resolver` returned - never with a value the
146
+ client supplied (TW-AMB-008, TW-SEC-003). After a namespace migration a peer whose tag names the
147
+ old namespace must be closed with `goaway` rather than retagged (TW-AMB-012, TW-WS-007): the
148
+ client's next register read under the new namespace is the whole of the recovery, and retagging
149
+ would leave a socket authorised for a namespace nobody re-checked.
150
+ """
151
+ peer.tags[SESSION_TAG] = session
152
+
153
+
154
+ # --------------------------------------------------------------------------- the watch handler
155
+
156
+
157
+ CONNECTION_TAG = "taskwire_connection"
158
+ """The opaque, server-issued id naming one live socket (TW-DEL-016).
159
+
160
+ It is minted here and handed back only in the `watch` reply, over a socket the caller already
161
+ established. It is NOT stable across a reconnect and decides exactly two things: the
162
+ `progress_delivery` display flag, and which socket a private operation's progress is written to. It
163
+ reaches one store field, `origin_connection`, which is inert data recorded at start and never used
164
+ for authorization (TW-SEC-003).
165
+ """
166
+
167
+
168
+ def connection_id(peer: Any) -> str | None:
169
+ """This socket's id, minting one on first use."""
170
+ tags = getattr(peer, "tags", None)
171
+ if not isinstance(tags, dict):
172
+ return None
173
+ existing = tags.get(CONNECTION_TAG)
174
+ if isinstance(existing, str):
175
+ return existing
176
+ import uuid
177
+
178
+ minted = str(uuid.uuid4())
179
+ tags[CONNECTION_TAG] = minted
180
+ return minted
181
+
182
+
183
+ def handle_watch(peer: Any, message: dict[str, Any]) -> dict[str, Any]:
184
+ """The `watch` handler. Declares the **whole** override map, never a delta (TW-DEL-006).
185
+
186
+ `false` suppresses, `true` revives, absent defers to `push_filter`, and `{}` returns everything
187
+ to the predicate. Idempotent and last-writer-wins **per connection**.
188
+
189
+ Answers `{"v": 1, "status": int, "connection": str}` (TW-DEL-022): status `0` accepted, `1`
190
+ rejected for exceeding `max_overrides`, `2` malformed. A rejected declaration still carries the
191
+ connection id, since the socket is unchanged - and a client may send `{"overrides": {}}` purely
192
+ to obtain that id, which is what a reconnecting client does (TW-REG-020). An empty declaration is
193
+ not a restoration: it asserts exactly the state the new socket already has.
194
+
195
+ **This handler reads no store, in either direction** (TW-DEL-007). A `true` or `false` for a
196
+ token that does not exist is accepted and inert. Reading the store here would put a per-tab
197
+ display preference on the path of every socket's first message.
198
+ """
199
+ identity = connection_id(peer)
200
+ declared = message.get("overrides")
201
+
202
+ if not isinstance(declared, dict) or not all(
203
+ isinstance(token, str) and isinstance(value, bool) for token, value in declared.items()
204
+ ):
205
+ return {"v": 1, "status": 2, "connection": identity}
206
+
207
+ if len(declared) > settings.max_overrides:
208
+ # TW-DEL-008: rejected rather than truncated, leaving the previous map in force. A truncated
209
+ # map is one the user asked for and did not get, with no way to tell which half survived.
210
+ return {"v": 1, "status": 1, "connection": identity}
211
+
212
+ peer.tags[OVERRIDES_TAG] = dict(declared)
213
+ return {"v": 1, "status": 0, "connection": identity}
214
+
215
+
216
+ def prune_override(peers: list[Any], token: str) -> None:
217
+ """TW-DEL-023: an operation's override is deleted from every connection's map when it ends.
218
+
219
+ **This is behaviour, not tidiness.** Without it a long-lived socket's map grows monotonically
220
+ toward `max_overrides` and eventually rejects a declaration the user meant - and the rejection
221
+ would be about tokens that finished days ago. Called at the last hop that fans a terminal
222
+ envelope out.
223
+ """
224
+ for peer in peers:
225
+ tags = getattr(peer, "tags", None)
226
+ if isinstance(tags, dict) and isinstance(tags.get(OVERRIDES_TAG), dict):
227
+ tags[OVERRIDES_TAG].pop(token, None)
228
+
229
+
230
+ # --------------------------------------------------------------------------- the server handler
231
+
232
+
233
+ StreamHandler = Callable[[Any, Any], Awaitable[None]]
234
+ """What taskwire hands back for the application to compose: `(payload, stream) -> None`."""
235
+
236
+
237
+ async def serve_peer(peer: Any, session: str) -> StreamHandler:
238
+ """Subscribe this process to `session` and **return** taskwire's stream handler.
239
+
240
+ It is returned rather than installed, because a muxws peer has exactly one handler slot and
241
+ taskwire is not entitled to it. The application owns that slot and composes into it - taskwire's
242
+ six calls arrive as viewset commands through `process_command`, and only what that leaves
243
+ unhandled is taskwire's own:
244
+
245
+ ```python
246
+ async def on_stream(payload, stream):
247
+ if await process_command(payload, stream, connection=websocket):
248
+ return
249
+ await taskwire_stream(payload, stream)
250
+ ```
251
+
252
+ Without this the socket is half a socket: envelopes still push *down*, but `watch` reaches
253
+ nothing. A client that cannot `watch` never obtains a connection id, and without a connection id
254
+ `origin_connection` stays null, so a **private operation's progress reaches no socket at all**
255
+ (TW-PRIV-003) and `progress_delivery` falls back to the predicate alone. Muting would silently
256
+ do nothing.
257
+
258
+ `watch` is the only kind the returned handler answers, and the only one that can never be a
259
+ viewset endpoint: it declares per-connection state held on the Peer, not an action on a resource
260
+ (TW-DEL-006, TW-DEL-016). Everything else - both reads, cancel, collect, dismiss and a dialog
261
+ reply - is one of the six endpoints of TW-REST-004, reachable over the socket like every other
262
+ call.
263
+
264
+ `session` is the value `session_resolver` returned for this socket, and it is the only
265
+ authorization scope on this path (TW-SEC-003). It is **not** re-derived from anything the client
266
+ sent: a namespace that crossed a boundary travels as inert data (TW-AMB-008).
267
+ """
268
+
269
+ async def taskwire_stream(payload: Any, stream: Any) -> None:
270
+ kind = (payload or {}).get("kind") if isinstance(payload, dict) else None
271
+
272
+ if kind == "watch":
273
+ # TW-DEL-007: this reads no store, in either direction. An override for a token that
274
+ # does not exist is accepted and inert - putting a per-tab display preference on the
275
+ # path of every socket's first message would be a poor trade.
276
+ await stream.reply(handle_watch(peer, payload))
277
+ return
278
+
279
+ # TW-CORE-007: the wire vocabulary is closed. An unknown kind is answered rather than
280
+ # ignored, so a client learns immediately instead of waiting on a stream nobody will end.
281
+ await stream.reply({"v": 1, "status": 2, "detail": f"unknown kind {kind!r}"})
282
+
283
+ # TW-BP-004: this process now holds a connection for `session`, so its reader subscribes to that
284
+ # namespace's channel. A no-op in a single-process deployment, and the difference between a
285
+ # working backplane and a silent one in a cross-process deployment.
286
+ await watch_namespace(session)
287
+ return taskwire_stream
288
+
289
+
290
+ async def register_taskwire_muxws(peer: Any, session: str) -> StreamHandler:
291
+ """Mount taskwire on one connected muxws peer. **Call this once, at handshake.**
292
+
293
+ The counterpart of `register_taskwire_rest(router)`: one call, and the socket half is wired. It
294
+ tags the peer with its namespace so `peers_for` can fan out to it, subscribes this process to
295
+ that namespace's channel, and returns the handler to compose behind `process_command`.
296
+
297
+ ```python
298
+ from fastapi_viewsets.mux_ws import process_command
299
+ from muxws import accept, PeerRegistry
300
+ from taskwire.contrib.muxws import MuxwsTransport, register_taskwire_muxws
301
+
302
+ registry = PeerRegistry()
303
+ taskwire.configure(store=..., transport=MuxwsTransport(registry), session_resolver=...)
304
+
305
+ @app.websocket("/ws")
306
+ async def socket(websocket):
307
+ peer = await accept(websocket)
308
+ ns = configured.session_resolver(websocket) # the same resolver REST uses (TW-WS-007)
309
+ taskwire_stream = await register_taskwire_muxws(peer, ns)
310
+ registry.register(peer) # after the tag, never before
311
+
312
+ async def on_stream(payload, stream):
313
+ if await process_command(payload, stream, connection=websocket):
314
+ return
315
+ await taskwire_stream(payload, stream)
316
+
317
+ peer.on_stream(on_stream)
318
+ try:
319
+ await peer.serve()
320
+ finally:
321
+ await release_peer(peer, ns, registry)
322
+ ```
323
+
324
+ The order is load-bearing: `PeerRegistry.register` indexes a peer under the tags it holds **at
325
+ that moment** (WSM-REG-010), so a peer registered before it is tagged is indexed under nothing
326
+ and receives no push - with everything else about the deployment looking correct.
327
+
328
+ The registry stays the application's: it is the one taskwire was configured with, and joining the
329
+ fan-out is its business rather than this call's.
330
+ """
331
+ tag_peer(peer, session)
332
+ return await serve_peer(peer, session)
333
+
334
+
335
+ async def goaway_if_stale(peer: Any, current_session: str | None) -> bool:
336
+ """Close a socket whose session tag no longer names the current identity (TW-WS-007, TW-AMB-012).
337
+
338
+ After a login or a `migrate_namespace`, a peer tagged with the old namespace must be **closed
339
+ with `goaway`, not retagged**. Retagging would leave a socket authorised for a namespace nobody
340
+ re-checked, and the client's next register read under the new namespace is the whole of the
341
+ recovery - there is nothing to preserve by keeping the connection.
342
+
343
+ Returns True when it closed the peer.
344
+ """
345
+ tagged = getattr(peer, "tags", {}).get(SESSION_TAG)
346
+ if current_session is not None and tagged == current_session:
347
+ return False
348
+ await peer.close(reason="taskwire: session changed")
349
+ if isinstance(tagged, str):
350
+ await release_peer(peer, tagged)
351
+ return True
352
+
353
+
354
+ async def release_peer(peer: Any, session: str, registry: Any = None) -> None:
355
+ """Call when a socket closes. Unsubscribes the process once it holds no more of that namespace.
356
+
357
+ TW-BP-004's second half. Nothing breaks if it is skipped - the envelopes are simply delivered
358
+ to nobody - but every process ends up subscribed to every namespace any of them ever saw,
359
+ which is the cost the per-process rule exists to avoid.
360
+
361
+ **A vanished watcher never affects the operation itself** (TW-REG-008, TW-INV-015). A
362
+ disconnect is not a statement of intent: nothing here cancels, deletes or shortens anything,
363
+ and a 90,000-row import keeps running with nobody looking at it.
364
+ """
365
+ if registry is not None:
366
+ with contextlib.suppress(Exception):
367
+ registry.deregister(peer)
368
+ if registry.peers_for(**{SESSION_TAG: session}):
369
+ return # this process still holds another socket of that namespace
370
+ await unwatch_namespace(session)