sillo-wire 0.1.0.dev1__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.
- _sillo_wire_bootstrap.py +104 -0
- sillo-stubs/py.typed +1 -0
- sillo-stubs/wire.pyi +48 -0
- sillo_wire/__init__.py +58 -0
- sillo_wire/backlog.py +140 -0
- sillo_wire/consumer.py +177 -0
- sillo_wire/envelope.py +92 -0
- sillo_wire/errors.py +30 -0
- sillo_wire/hub.py +315 -0
- sillo_wire/peer.py +231 -0
- sillo_wire/policy.py +42 -0
- sillo_wire/py.typed +0 -0
- sillo_wire/testing.py +86 -0
- sillo_wire-0.1.0.dev1.dist-info/METADATA +244 -0
- sillo_wire-0.1.0.dev1.dist-info/RECORD +18 -0
- sillo_wire-0.1.0.dev1.dist-info/WHEEL +4 -0
- sillo_wire-0.1.0.dev1.dist-info/licenses/LICENSE +27 -0
- sillo_wire.pth +1 -0
sillo_wire/hub.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Rooms, membership and fan-out.
|
|
2
|
+
|
|
3
|
+
A :class:`Hub` owns rooms and the peers in them. It is an ordinary object, not
|
|
4
|
+
a module of class methods over a global dict, so two hubs are two independent
|
|
5
|
+
worlds: a test gets a fresh one per case instead of remembering to flush shared
|
|
6
|
+
state, and an application that serves several tenants can keep their traffic
|
|
7
|
+
apart without a naming convention.
|
|
8
|
+
|
|
9
|
+
Fan-out is concurrent and non-blocking. Delivering to a room enqueues on every
|
|
10
|
+
peer at once and returns a :class:`~sillo_wire.envelope.DeliveryReport`; it
|
|
11
|
+
never waits on a socket, so one client that has stopped reading cannot hold up
|
|
12
|
+
the rest of the room.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import inspect
|
|
19
|
+
import typing
|
|
20
|
+
|
|
21
|
+
from sillo_wire.backlog import Backlog, MemoryBacklog
|
|
22
|
+
from sillo_wire.envelope import DeliveryReport, Envelope
|
|
23
|
+
from sillo_wire.errors import RoomNotFound
|
|
24
|
+
from sillo_wire.peer import Peer
|
|
25
|
+
|
|
26
|
+
__all__ = ["Hub"]
|
|
27
|
+
|
|
28
|
+
#: Called as ``listener(room, peer)`` when membership changes. Sync or async.
|
|
29
|
+
PresenceListener = typing.Callable[[str, Peer], typing.Any]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Hub:
|
|
33
|
+
"""A set of rooms and the peers subscribed to them.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
backlog: Where delivered envelopes are retained for replay. Defaults to
|
|
37
|
+
an in-memory one; pass :class:`~sillo_wire.backlog.NullBacklog` to
|
|
38
|
+
keep nothing.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__slots__ = ("_backlog", "_on_join", "_on_leave", "_rooms")
|
|
42
|
+
|
|
43
|
+
def __init__(self, backlog: Backlog | None = None) -> None:
|
|
44
|
+
self._rooms: dict[str, set[Peer]] = {}
|
|
45
|
+
self._backlog: Backlog = MemoryBacklog() if backlog is None else backlog
|
|
46
|
+
self._on_join: list[PresenceListener] = []
|
|
47
|
+
self._on_leave: list[PresenceListener] = []
|
|
48
|
+
|
|
49
|
+
# ── membership ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
async def join(self, peer: Peer, room: str) -> bool:
|
|
52
|
+
"""Subscribe *peer* to *room*. Returns whether it was newly added.
|
|
53
|
+
|
|
54
|
+
Starts the peer's writer if it is not already running, so a caller
|
|
55
|
+
never has to remember to.
|
|
56
|
+
"""
|
|
57
|
+
if not room:
|
|
58
|
+
raise ValueError("room name must not be empty")
|
|
59
|
+
|
|
60
|
+
members = self._rooms.setdefault(room, set())
|
|
61
|
+
if peer in members:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
members.add(peer)
|
|
65
|
+
peer.start()
|
|
66
|
+
await self._announce(self._on_join, room, peer)
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
async def leave(self, peer: Peer, room: str) -> bool:
|
|
70
|
+
"""Unsubscribe *peer* from *room*. Returns whether it was a member.
|
|
71
|
+
|
|
72
|
+
Leaving a room that does not exist, or that the peer is not in, is not
|
|
73
|
+
an error — a disconnect racing a cleanup produces both, routinely.
|
|
74
|
+
"""
|
|
75
|
+
members = self._rooms.get(room)
|
|
76
|
+
if members is None or peer not in members:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
members.discard(peer)
|
|
80
|
+
if not members:
|
|
81
|
+
del self._rooms[room]
|
|
82
|
+
await self._announce(self._on_leave, room, peer)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
async def leave_all(self, peer: Peer) -> list[str]:
|
|
86
|
+
"""Remove *peer* from every room. Returns the rooms it was in."""
|
|
87
|
+
left = [room for room, members in self._rooms.items() if peer in members]
|
|
88
|
+
for room in left:
|
|
89
|
+
await self.leave(peer, room)
|
|
90
|
+
return left
|
|
91
|
+
|
|
92
|
+
async def disconnect(self, peer: Peer) -> None:
|
|
93
|
+
"""Remove *peer* from every room and close its socket.
|
|
94
|
+
|
|
95
|
+
The one call a consumer's disconnect path needs.
|
|
96
|
+
"""
|
|
97
|
+
await self.leave_all(peer)
|
|
98
|
+
await peer.close()
|
|
99
|
+
|
|
100
|
+
# ── delivery ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async def broadcast(
|
|
103
|
+
self,
|
|
104
|
+
room: str,
|
|
105
|
+
payload: typing.Any,
|
|
106
|
+
*,
|
|
107
|
+
retain: bool = True,
|
|
108
|
+
) -> DeliveryReport:
|
|
109
|
+
"""Deliver *payload* to every peer in *room*.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
room: Which room to deliver to. An unknown room is an empty report,
|
|
113
|
+
not an error — rooms come and go with their last member.
|
|
114
|
+
payload: What to send.
|
|
115
|
+
retain: Whether to record the envelope in the backlog for replay.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
What happened, per peer.
|
|
119
|
+
"""
|
|
120
|
+
envelope = Envelope(payload=payload, room=room)
|
|
121
|
+
if retain:
|
|
122
|
+
await self._backlog.append(envelope)
|
|
123
|
+
|
|
124
|
+
members = self._rooms.get(room)
|
|
125
|
+
if not members:
|
|
126
|
+
return DeliveryReport()
|
|
127
|
+
|
|
128
|
+
report, stale = self._offer_all(members, envelope)
|
|
129
|
+
if stale:
|
|
130
|
+
members.difference_update(stale)
|
|
131
|
+
if not members:
|
|
132
|
+
del self._rooms[room]
|
|
133
|
+
return report
|
|
134
|
+
|
|
135
|
+
async def send_to(
|
|
136
|
+
self, identity: typing.Any, payload: typing.Any
|
|
137
|
+
) -> DeliveryReport:
|
|
138
|
+
"""Deliver *payload* to every peer carrying *identity*.
|
|
139
|
+
|
|
140
|
+
This is the "reach that user wherever they are" call — one person with
|
|
141
|
+
a phone and two tabs is three peers, and all three get it. Nothing is
|
|
142
|
+
retained: the message is addressed to a person, not to a room, so there
|
|
143
|
+
is no room whose history it belongs in.
|
|
144
|
+
"""
|
|
145
|
+
targets = {
|
|
146
|
+
peer
|
|
147
|
+
for members in self._rooms.values()
|
|
148
|
+
for peer in members
|
|
149
|
+
if peer.identity == identity
|
|
150
|
+
}
|
|
151
|
+
if not targets:
|
|
152
|
+
return DeliveryReport()
|
|
153
|
+
# Stale peers are not evicted here: a peer reached by identity may be
|
|
154
|
+
# in several rooms, and removing it from all of them is `prune`'s job.
|
|
155
|
+
report, _ = self._offer_all(targets, Envelope(payload=payload))
|
|
156
|
+
return report
|
|
157
|
+
|
|
158
|
+
def _offer_all(
|
|
159
|
+
self,
|
|
160
|
+
peers: typing.Iterable[Peer],
|
|
161
|
+
envelope: Envelope,
|
|
162
|
+
) -> tuple[DeliveryReport, list[Peer]]:
|
|
163
|
+
"""Enqueue *envelope* on each peer; report the outcome and the dead.
|
|
164
|
+
|
|
165
|
+
Synchronous on purpose. ``offer`` cannot block, so there is nothing to
|
|
166
|
+
await and no window in which the room's membership could change
|
|
167
|
+
underneath the loop — which is why the caller can act on the returned
|
|
168
|
+
stale list against the very set it passed in.
|
|
169
|
+
"""
|
|
170
|
+
delivered = dropped = failed = 0
|
|
171
|
+
stale: list[Peer] = []
|
|
172
|
+
|
|
173
|
+
for peer in list(peers):
|
|
174
|
+
if peer.closed:
|
|
175
|
+
failed += 1
|
|
176
|
+
stale.append(peer)
|
|
177
|
+
elif peer.offer(envelope):
|
|
178
|
+
delivered += 1
|
|
179
|
+
else:
|
|
180
|
+
dropped += 1
|
|
181
|
+
# An overflow policy of CLOSE marks the peer closed as it
|
|
182
|
+
# refuses the message, so it is evictable in the same pass.
|
|
183
|
+
if peer.closed:
|
|
184
|
+
stale.append(peer)
|
|
185
|
+
|
|
186
|
+
report = DeliveryReport(delivered=delivered, dropped=dropped, failed=failed)
|
|
187
|
+
return report, stale
|
|
188
|
+
|
|
189
|
+
# ── replay ───────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
async def replay(
|
|
192
|
+
self,
|
|
193
|
+
peer: Peer,
|
|
194
|
+
room: str,
|
|
195
|
+
*,
|
|
196
|
+
since: int = 0,
|
|
197
|
+
limit: int | None = None,
|
|
198
|
+
) -> int:
|
|
199
|
+
"""Send *peer* what it missed in *room*. Returns how many were sent.
|
|
200
|
+
|
|
201
|
+
The cursor a client sends back after reconnecting is the ``seq`` of the
|
|
202
|
+
last envelope it saw, which is why sequences are monotonic and never
|
|
203
|
+
reused.
|
|
204
|
+
"""
|
|
205
|
+
missed = await self._backlog.since(room, since)
|
|
206
|
+
if limit is not None:
|
|
207
|
+
missed = missed[-limit:] if limit > 0 else []
|
|
208
|
+
|
|
209
|
+
sent = 0
|
|
210
|
+
for envelope in missed:
|
|
211
|
+
if peer.offer(envelope):
|
|
212
|
+
sent += 1
|
|
213
|
+
return sent
|
|
214
|
+
|
|
215
|
+
async def history(self, room: str, limit: int = 50) -> list[Envelope]:
|
|
216
|
+
"""The most recent envelopes retained for *room*."""
|
|
217
|
+
return await self._backlog.latest(room, limit=limit)
|
|
218
|
+
|
|
219
|
+
async def clear_history(self, room: str | None = None) -> None:
|
|
220
|
+
"""Forget *room*'s backlog, or every room's."""
|
|
221
|
+
await self._backlog.clear(room)
|
|
222
|
+
|
|
223
|
+
# ── presence ─────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
def on_join(self, listener: PresenceListener) -> PresenceListener:
|
|
226
|
+
"""Register *listener*, called when a peer joins a room.
|
|
227
|
+
|
|
228
|
+
Returns the listener, so it works as a decorator.
|
|
229
|
+
"""
|
|
230
|
+
self._on_join.append(listener)
|
|
231
|
+
return listener
|
|
232
|
+
|
|
233
|
+
def on_leave(self, listener: PresenceListener) -> PresenceListener:
|
|
234
|
+
"""Register *listener*, called when a peer leaves a room."""
|
|
235
|
+
self._on_leave.append(listener)
|
|
236
|
+
return listener
|
|
237
|
+
|
|
238
|
+
async def _announce(
|
|
239
|
+
self, listeners: list[PresenceListener], room: str, peer: Peer
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Run presence listeners, tolerating both sync and async ones."""
|
|
242
|
+
for listener in listeners:
|
|
243
|
+
result = listener(room, peer)
|
|
244
|
+
if inspect.isawaitable(result):
|
|
245
|
+
await result
|
|
246
|
+
|
|
247
|
+
# ── introspection ────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def rooms(self) -> list[str]:
|
|
250
|
+
"""Every room with at least one peer in it."""
|
|
251
|
+
return list(self._rooms)
|
|
252
|
+
|
|
253
|
+
def members(self, room: str) -> list[Peer]:
|
|
254
|
+
"""The peers in *room*, or an empty list if there is no such room."""
|
|
255
|
+
return list(self._rooms.get(room, ()))
|
|
256
|
+
|
|
257
|
+
def identities(self, room: str) -> list[typing.Any]:
|
|
258
|
+
"""Distinct, non-``None`` identities present in *room*.
|
|
259
|
+
|
|
260
|
+
The presence roster: who is here, rather than how many sockets are.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
RoomNotFound: If *room* does not exist. Unlike a broadcast, asking
|
|
264
|
+
who is in a room that has never existed is a question with no
|
|
265
|
+
sensible empty answer — it is usually a typo.
|
|
266
|
+
"""
|
|
267
|
+
if room not in self._rooms:
|
|
268
|
+
raise RoomNotFound(room)
|
|
269
|
+
seen: list[typing.Any] = []
|
|
270
|
+
for peer in self._rooms[room]:
|
|
271
|
+
if peer.identity is not None and peer.identity not in seen:
|
|
272
|
+
seen.append(peer.identity)
|
|
273
|
+
return seen
|
|
274
|
+
|
|
275
|
+
def count(self, room: str | None = None) -> int:
|
|
276
|
+
"""Peers in *room*, or across every room when *room* is ``None``.
|
|
277
|
+
|
|
278
|
+
A peer in two rooms counts twice in the total, because the number being
|
|
279
|
+
reported is subscriptions rather than connections.
|
|
280
|
+
"""
|
|
281
|
+
if room is not None:
|
|
282
|
+
return len(self._rooms.get(room, ()))
|
|
283
|
+
return sum(len(members) for members in self._rooms.values())
|
|
284
|
+
|
|
285
|
+
async def prune(self) -> list[Peer]:
|
|
286
|
+
"""Evict closed and idle peers from every room. Returns those removed.
|
|
287
|
+
|
|
288
|
+
Nothing calls this on a timer — when it runs is an application's
|
|
289
|
+
decision, because the right cadence depends on how long a stale
|
|
290
|
+
subscription actually costs anything.
|
|
291
|
+
"""
|
|
292
|
+
removed: list[Peer] = []
|
|
293
|
+
for room in list(self._rooms):
|
|
294
|
+
for peer in list(self._rooms.get(room, ())):
|
|
295
|
+
if peer.closed or peer.is_idle():
|
|
296
|
+
await self.leave(peer, room)
|
|
297
|
+
if peer not in removed:
|
|
298
|
+
removed.append(peer)
|
|
299
|
+
return removed
|
|
300
|
+
|
|
301
|
+
async def close(self) -> None:
|
|
302
|
+
"""Close every peer and drop every room.
|
|
303
|
+
|
|
304
|
+
The rooms go first, before anything is awaited, so a broadcast racing
|
|
305
|
+
the shutdown finds an empty hub rather than a room whose peers are
|
|
306
|
+
half-closed.
|
|
307
|
+
"""
|
|
308
|
+
peers = {p for members in self._rooms.values() for p in members}
|
|
309
|
+
self._rooms.clear()
|
|
310
|
+
# Concurrently, for the same reason a broadcast is: closing a thousand
|
|
311
|
+
# sockets one after another makes shutdown as slow as the slowest one.
|
|
312
|
+
await asyncio.gather(*(peer.close() for peer in peers))
|
|
313
|
+
|
|
314
|
+
def __repr__(self) -> str:
|
|
315
|
+
return f"<Hub rooms={len(self._rooms)} peers={self.count()}>"
|
sillo_wire/peer.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""One connected client, with a bounded outbound queue.
|
|
2
|
+
|
|
3
|
+
The queue is the point. Writing straight to a socket inside a fan-out means the
|
|
4
|
+
slowest member of a room sets the pace for every other member: a client that
|
|
5
|
+
has stopped reading fills its kernel buffer, the write blocks, and everyone
|
|
6
|
+
behind it waits. Here a broadcast only ever *enqueues*, which cannot block, and
|
|
7
|
+
a writer task per peer drains the queue at whatever rate that peer manages.
|
|
8
|
+
|
|
9
|
+
What happens when a queue fills is a policy rather than a default -- see
|
|
10
|
+
:class:`~sillo_wire.policy.Overflow`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextlib
|
|
17
|
+
import time
|
|
18
|
+
import typing
|
|
19
|
+
import uuid
|
|
20
|
+
|
|
21
|
+
from sillo_wire.envelope import Encoding, Envelope
|
|
22
|
+
from sillo_wire.errors import PeerGone
|
|
23
|
+
from sillo_wire.policy import Overflow
|
|
24
|
+
|
|
25
|
+
__all__ = ["Peer"]
|
|
26
|
+
|
|
27
|
+
#: Default queue depth. Deep enough to absorb a burst, shallow enough that a
|
|
28
|
+
#: client which has genuinely stopped reading is noticed within a second or two
|
|
29
|
+
#: rather than after megabytes have accumulated on its behalf.
|
|
30
|
+
DEFAULT_CAPACITY = 64
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Peer:
|
|
34
|
+
"""A socket, its identity, and its outbound queue.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
socket: The connection to write to. Anything with the
|
|
38
|
+
``send_json`` / ``send_text`` / ``send_bytes`` trio works, which is
|
|
39
|
+
what makes a peer testable without a server.
|
|
40
|
+
encoding: How payloads are written.
|
|
41
|
+
identity: Who this connection belongs to, if anyone. Two peers may
|
|
42
|
+
share an identity — the same user with two tabs open — which is
|
|
43
|
+
what :meth:`~sillo_wire.hub.Hub.send_to` relies on.
|
|
44
|
+
capacity: Outbound queue depth.
|
|
45
|
+
overflow: What to do when the queue is full.
|
|
46
|
+
idle_timeout: Seconds of silence after which the peer is considered
|
|
47
|
+
stale. ``None`` disables the check.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
__slots__ = (
|
|
51
|
+
"_closed",
|
|
52
|
+
"_queue",
|
|
53
|
+
"_writer",
|
|
54
|
+
"capacity",
|
|
55
|
+
"created_at",
|
|
56
|
+
"encoding",
|
|
57
|
+
"id",
|
|
58
|
+
"identity",
|
|
59
|
+
"idle_timeout",
|
|
60
|
+
"last_sent_at",
|
|
61
|
+
"overflow",
|
|
62
|
+
"socket",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
socket: typing.Any,
|
|
68
|
+
*,
|
|
69
|
+
encoding: Encoding = Encoding.JSON,
|
|
70
|
+
identity: typing.Any = None,
|
|
71
|
+
capacity: int = DEFAULT_CAPACITY,
|
|
72
|
+
overflow: Overflow = Overflow.DROP_OLDEST,
|
|
73
|
+
idle_timeout: float | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
if capacity < 1:
|
|
76
|
+
raise ValueError("capacity must be at least 1")
|
|
77
|
+
|
|
78
|
+
self.socket = socket
|
|
79
|
+
self.encoding = encoding
|
|
80
|
+
self.identity = identity
|
|
81
|
+
self.capacity = capacity
|
|
82
|
+
self.overflow = overflow
|
|
83
|
+
self.idle_timeout = idle_timeout
|
|
84
|
+
|
|
85
|
+
self.id = uuid.uuid4()
|
|
86
|
+
self.created_at = time.monotonic()
|
|
87
|
+
self.last_sent_at = self.created_at
|
|
88
|
+
|
|
89
|
+
self._queue: asyncio.Queue[Envelope] = asyncio.Queue(maxsize=capacity)
|
|
90
|
+
self._writer: asyncio.Task[None] | None = None
|
|
91
|
+
self._closed = False
|
|
92
|
+
|
|
93
|
+
# ── lifecycle ────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def closed(self) -> bool:
|
|
97
|
+
"""Whether this peer has been closed, by either side."""
|
|
98
|
+
return self._closed
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def pending(self) -> int:
|
|
102
|
+
"""Messages queued and not yet written."""
|
|
103
|
+
return self._queue.qsize()
|
|
104
|
+
|
|
105
|
+
def start(self) -> None:
|
|
106
|
+
"""Begin draining the queue.
|
|
107
|
+
|
|
108
|
+
Idempotent, so a hub that joins the same peer to several rooms does not
|
|
109
|
+
start several writers for it.
|
|
110
|
+
"""
|
|
111
|
+
if self._writer is None and not self._closed:
|
|
112
|
+
self._writer = asyncio.create_task(self._drain())
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
"""Stop the writer and close the socket.
|
|
116
|
+
|
|
117
|
+
Safe to call twice, and safe to call on a socket that is already gone —
|
|
118
|
+
a disconnect racing a cleanup is the normal case, not an error.
|
|
119
|
+
"""
|
|
120
|
+
if self._closed:
|
|
121
|
+
return
|
|
122
|
+
self._closed = True
|
|
123
|
+
|
|
124
|
+
if self._writer is not None:
|
|
125
|
+
self._writer.cancel()
|
|
126
|
+
with contextlib.suppress(BaseException):
|
|
127
|
+
await self._writer
|
|
128
|
+
self._writer = None
|
|
129
|
+
|
|
130
|
+
# A socket that is already gone is the normal case here, not an error:
|
|
131
|
+
# close() is what runs when the client hung up.
|
|
132
|
+
with contextlib.suppress(Exception):
|
|
133
|
+
await self.socket.close()
|
|
134
|
+
|
|
135
|
+
# ── sending ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def offer(self, envelope: Envelope) -> bool:
|
|
138
|
+
"""Queue *envelope* without blocking. Returns whether it was accepted.
|
|
139
|
+
|
|
140
|
+
This is what a fan-out calls. It never awaits and never raises, so one
|
|
141
|
+
peer cannot affect the delivery of any other.
|
|
142
|
+
"""
|
|
143
|
+
if self._closed:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
self._queue.put_nowait(envelope)
|
|
148
|
+
return True
|
|
149
|
+
except asyncio.QueueFull:
|
|
150
|
+
return self._resolve_overflow(envelope)
|
|
151
|
+
|
|
152
|
+
def _resolve_overflow(self, envelope: Envelope) -> bool:
|
|
153
|
+
"""Apply :attr:`overflow` to a message that did not fit."""
|
|
154
|
+
if self.overflow is Overflow.DROP_NEWEST:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
if self.overflow is Overflow.CLOSE:
|
|
158
|
+
# Closing is asynchronous, and this path must not await. Marking
|
|
159
|
+
# it closed stops further offers immediately; the writer task sees
|
|
160
|
+
# the flag and shuts the socket down.
|
|
161
|
+
self._closed = True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
# DROP_OLDEST: make room by discarding the head, then retry once. The
|
|
165
|
+
# retry cannot fail — nothing else consumes from this queue between the
|
|
166
|
+
# two calls, because neither of them awaits.
|
|
167
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
168
|
+
self._queue.get_nowait()
|
|
169
|
+
self._queue.put_nowait(envelope)
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
async def send(self, payload: typing.Any) -> None:
|
|
173
|
+
"""Write *payload* to the socket now, bypassing the queue.
|
|
174
|
+
|
|
175
|
+
For replies to that one peer, where the caller wants the failure. A
|
|
176
|
+
broadcast uses :meth:`offer` instead.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
PeerGone: If the peer is closed, or the socket raises.
|
|
180
|
+
"""
|
|
181
|
+
if self._closed:
|
|
182
|
+
raise PeerGone(f"peer {self.id} is closed")
|
|
183
|
+
try:
|
|
184
|
+
await self._write(payload)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
raise PeerGone(f"peer {self.id} went away") from exc
|
|
187
|
+
|
|
188
|
+
async def _write(self, payload: typing.Any) -> None:
|
|
189
|
+
"""Put one payload on the wire in this peer's encoding."""
|
|
190
|
+
if self.encoding is Encoding.JSON:
|
|
191
|
+
await self.socket.send_json(payload)
|
|
192
|
+
elif self.encoding is Encoding.TEXT:
|
|
193
|
+
await self.socket.send_text(payload)
|
|
194
|
+
else:
|
|
195
|
+
await self.socket.send_bytes(payload)
|
|
196
|
+
self.last_sent_at = time.monotonic()
|
|
197
|
+
|
|
198
|
+
async def _drain(self) -> None:
|
|
199
|
+
"""Write queued envelopes until cancelled or the socket fails."""
|
|
200
|
+
while True:
|
|
201
|
+
envelope = await self._queue.get()
|
|
202
|
+
if self._closed:
|
|
203
|
+
break
|
|
204
|
+
try:
|
|
205
|
+
await self._write(envelope.payload)
|
|
206
|
+
except Exception:
|
|
207
|
+
# The socket is gone. Stop the writer rather than spinning on a
|
|
208
|
+
# dead connection; the hub notices through `closed` and evicts.
|
|
209
|
+
self._closed = True
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
# ── health ───────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def is_idle(self, *, now: float | None = None) -> bool:
|
|
215
|
+
"""Whether nothing has been written for longer than :attr:`idle_timeout`.
|
|
216
|
+
|
|
217
|
+
Distinct from a lifetime TTL on purpose: a connection that is being
|
|
218
|
+
used should not be evicted for having existed a long time, and one that
|
|
219
|
+
has gone quiet should be, however recently it connected.
|
|
220
|
+
"""
|
|
221
|
+
if self.idle_timeout is None:
|
|
222
|
+
return False
|
|
223
|
+
moment = time.monotonic() if now is None else now
|
|
224
|
+
return (moment - self.last_sent_at) > self.idle_timeout
|
|
225
|
+
|
|
226
|
+
def __repr__(self) -> str:
|
|
227
|
+
state = "closed" if self._closed else f"pending={self.pending}"
|
|
228
|
+
return (
|
|
229
|
+
f"<Peer {str(self.id)[:8]} {self.encoding.value} "
|
|
230
|
+
f"identity={self.identity!r} {state}>"
|
|
231
|
+
)
|
sillo_wire/policy.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""What to do when a peer cannot keep up.
|
|
2
|
+
|
|
3
|
+
A socket that is not being read drains into the kernel's send buffer, and once
|
|
4
|
+
that fills, a write blocks. In a fan-out that turns one slow client into a
|
|
5
|
+
stall for everybody behind it, so every peer gets a bounded queue and a policy
|
|
6
|
+
for what happens when the queue is full.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
|
|
13
|
+
__all__ = ["Overflow"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Overflow(enum.Enum):
|
|
17
|
+
"""What a peer does with a message it has no room for.
|
|
18
|
+
|
|
19
|
+
There is no good universal answer, only a choice about which property
|
|
20
|
+
matters more for the traffic in question.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
DROP_OLDEST = "drop_oldest"
|
|
24
|
+
"""Discard the queued message at the front and enqueue the new one.
|
|
25
|
+
|
|
26
|
+
Right for state that supersedes itself — a price tick, a cursor position, a
|
|
27
|
+
progress percentage. The client sees a gap but always sees *current*.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
DROP_NEWEST = "drop_newest"
|
|
31
|
+
"""Discard the message being sent and keep the queue as it is.
|
|
32
|
+
|
|
33
|
+
Right when order matters more than recency and the client will reconcile
|
|
34
|
+
from the backlog later.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
CLOSE = "close"
|
|
38
|
+
"""Disconnect the peer.
|
|
39
|
+
|
|
40
|
+
Right when a client that cannot keep up is a client that is broken, and
|
|
41
|
+
letting it reconnect is cheaper than reasoning about what it missed.
|
|
42
|
+
"""
|
sillo_wire/py.typed
ADDED
|
File without changes
|
sillo_wire/testing.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Helpers for testing code that uses :mod:`sillo_wire`.
|
|
2
|
+
|
|
3
|
+
Realtime code is awkward to test because the interesting behaviour is what a
|
|
4
|
+
*socket* received, and a socket is the one thing a unit test does not have.
|
|
5
|
+
:class:`FakeSocket` is that missing piece: it satisfies everything
|
|
6
|
+
:class:`~sillo_wire.peer.Peer` calls and records what it was given.
|
|
7
|
+
|
|
8
|
+
Nothing here is imported by the package itself, so it costs an application
|
|
9
|
+
nothing at run time.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import typing
|
|
16
|
+
|
|
17
|
+
from sillo_wire.peer import Peer
|
|
18
|
+
|
|
19
|
+
__all__ = ["FakeSocket", "drain"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeSocket:
|
|
23
|
+
"""A stand-in for a WebSocket connection that records what it was sent.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
delay: Seconds to sleep on each write. Non-zero simulates a client that
|
|
27
|
+
is slow to read, which is the case worth testing and the hardest to
|
|
28
|
+
reproduce with a real socket.
|
|
29
|
+
fail: Raise on every write, simulating a connection that has gone away.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("closed", "delay", "fail", "sent")
|
|
33
|
+
|
|
34
|
+
def __init__(self, *, delay: float = 0.0, fail: bool = False) -> None:
|
|
35
|
+
self.sent: list[typing.Any] = []
|
|
36
|
+
self.delay = delay
|
|
37
|
+
self.fail = fail
|
|
38
|
+
self.closed = False
|
|
39
|
+
|
|
40
|
+
async def _record(self, payload: typing.Any) -> None:
|
|
41
|
+
if self.delay:
|
|
42
|
+
await asyncio.sleep(self.delay)
|
|
43
|
+
if self.fail:
|
|
44
|
+
raise ConnectionResetError("fake socket is closed")
|
|
45
|
+
self.sent.append(payload)
|
|
46
|
+
|
|
47
|
+
async def send_json(self, payload: typing.Any) -> None:
|
|
48
|
+
"""Record a JSON payload."""
|
|
49
|
+
await self._record(payload)
|
|
50
|
+
|
|
51
|
+
async def send_text(self, payload: str) -> None:
|
|
52
|
+
"""Record a text payload."""
|
|
53
|
+
await self._record(payload)
|
|
54
|
+
|
|
55
|
+
async def send_bytes(self, payload: bytes) -> None:
|
|
56
|
+
"""Record a bytes payload."""
|
|
57
|
+
await self._record(payload)
|
|
58
|
+
|
|
59
|
+
async def close(self, code: int = 1000) -> None:
|
|
60
|
+
"""Mark the socket closed."""
|
|
61
|
+
self.closed = True
|
|
62
|
+
|
|
63
|
+
async def accept(self) -> None:
|
|
64
|
+
"""Accept the connection."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def drain(*peers: Peer, timeout: float = 1.0) -> None:
|
|
68
|
+
"""Wait until every peer's queue is empty.
|
|
69
|
+
|
|
70
|
+
A broadcast only enqueues, so a test that asserts on ``socket.sent``
|
|
71
|
+
straight afterwards is racing the writer task. This is the wait that makes
|
|
72
|
+
such an assertion deterministic.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
TimeoutError: If the queues have not emptied within *timeout*, which
|
|
76
|
+
means a writer is stuck rather than slow.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
async def _wait() -> None:
|
|
80
|
+
while any(peer.pending for peer in peers):
|
|
81
|
+
await asyncio.sleep(0)
|
|
82
|
+
# One more yield so the write that emptied the queue completes before
|
|
83
|
+
# the caller inspects what the socket received.
|
|
84
|
+
await asyncio.sleep(0)
|
|
85
|
+
|
|
86
|
+
await asyncio.wait_for(_wait(), timeout=timeout)
|