perf: hand a joiner the room instead of asking everyone to answer
A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1ae4251a34
commit
48e154d53c
4 changed files with 84 additions and 28 deletions
|
|
@ -3,6 +3,17 @@ import uuid
|
|||
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
# Who is connected, per project: {group: {cid: {...presence...}}}. Purely a
|
||||
# cache of what has already been relayed, so a joiner can be handed the room in
|
||||
# one message instead of asking everyone in it to answer (which costs one
|
||||
# broadcast per member, i.e. N^2 messages for a room of N — at 100 that loses
|
||||
# most of the roster to the channel layer's queue limits). Nothing about a party
|
||||
# is decided here; the browsers still work that out from the roster (tl.party).
|
||||
#
|
||||
# Process-local, like the in-memory channel layer this runs on. If that ever
|
||||
# moves to Redis for multiple workers, this has to move with it.
|
||||
ROOMS = {}
|
||||
|
||||
|
||||
class SceneConsumer(AsyncWebsocketConsumer):
|
||||
"""One connection per open project. Two things ride it.
|
||||
|
|
@ -12,14 +23,17 @@ class SceneConsumer(AsyncWebsocketConsumer):
|
|||
which is the broadcast source.
|
||||
|
||||
Presence: who's viewing, which ad-hoc party they're in, and where that party
|
||||
is looking. Peers gossip it between themselves and work the party out
|
||||
locally (tl.party); all this does is hand out a connection id, stamp the
|
||||
sender's server-side identity onto every message — so nobody can post as
|
||||
somebody else — and relay. Nothing about a party is stored anywhere.
|
||||
is looking. Peers gossip it between themselves; all this does is hand out a
|
||||
connection id, stamp the sender's server-side identity onto every message —
|
||||
so nobody can post as somebody else — and relay.
|
||||
"""
|
||||
|
||||
RELAYED = ("state", "nav")
|
||||
|
||||
@property
|
||||
def room(self):
|
||||
return ROOMS.setdefault(self.group, {})
|
||||
|
||||
async def connect(self):
|
||||
self.pk = self.scope["url_route"]["kwargs"]["pk"]
|
||||
self.group = f"project_{self.pk}"
|
||||
|
|
@ -28,13 +42,19 @@ class SceneConsumer(AsyncWebsocketConsumer):
|
|||
self.username = user.get_username() if user and user.is_authenticated else None
|
||||
await self.channel_layer.group_add(self.group, self.channel_name)
|
||||
await self.accept()
|
||||
await self.send(text_data=json.dumps(
|
||||
{"kind": "welcome", "cid": self.cid, "user": self.username}))
|
||||
# the room answers a join with their own state, so rosters fill in both ways
|
||||
|
||||
me = {"cid": self.cid, "user": self.username, "party": None, "joinable": True}
|
||||
others = list(self.room.values())
|
||||
self.room[self.cid] = me
|
||||
await self.send(text_data=json.dumps({"kind": "welcome", **me}))
|
||||
await self.send(text_data=json.dumps({"kind": "roster", "peers": others}))
|
||||
await self._relay({"kind": "join"})
|
||||
|
||||
async def disconnect(self, code):
|
||||
if hasattr(self, "cid"):
|
||||
self.room.pop(self.cid, None)
|
||||
if not self.room:
|
||||
ROOMS.pop(self.group, None)
|
||||
await self._relay({"kind": "leave"})
|
||||
await self.channel_layer.group_discard(self.group, self.channel_name)
|
||||
|
||||
|
|
@ -43,8 +63,12 @@ class SceneConsumer(AsyncWebsocketConsumer):
|
|||
msg = json.loads(text_data or "{}")
|
||||
except ValueError:
|
||||
return
|
||||
if isinstance(msg, dict) and msg.get("kind") in self.RELAYED:
|
||||
await self._relay(msg)
|
||||
if not isinstance(msg, dict) or msg.get("kind") not in self.RELAYED:
|
||||
return
|
||||
if msg.get("kind") == "state" and self.cid in self.room:
|
||||
self.room[self.cid].update(party=msg.get("party"),
|
||||
joinable=bool(msg.get("joinable")))
|
||||
await self._relay(msg)
|
||||
|
||||
async def _relay(self, msg):
|
||||
await self.channel_layer.group_send(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue