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,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)
|