Private
Public Access
Splits the WebSocket layer into three pieces so one app instance and many
behave identically: ConnectionManager stays a purely local socket registry;
RoomBroadcaster publishes chat messages to a per-room Redis channel and
every instance (including the publisher) forwards received messages to its
own local sockets via a single psubscribe("room:*") listener started in
main.py's lifespan; Presence is a Redis-backed refcounted hash per room
tracking who's connected across all instances.
Presence replaces the old process-local connected_user_ids check that
Phase 4's offline-push logic used -- without it, a user connected on a
different instance would look offline and get a redundant push. Fixing
this was scoped in beyond the issue's literal ask (message fan-out only)
since it's a real correctness gap in a phase specifically about running
more than one instance; a known limitation (no heartbeat/TTL, so a hard
crash leaks a presence increment) is documented in the README instead of
solved here.
New tests/test_broadcast.py spins up two independent app instances sharing
one Postgres + Redis to prove delivery and presence both actually cross
the Redis boundary, not just work in-process. Manually verified the same
thing against two real uvicorn processes on different ports.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
34 lines
1.1 KiB
Python
34 lines
1.1 KiB
Python
import uuid
|
|
from collections import defaultdict
|
|
|
|
from fastapi import WebSocket
|
|
|
|
|
|
class ConnectionManager:
|
|
"""Local, single-process WebSocket socket registry.
|
|
|
|
Purely about delivering to sockets connected to *this* process --
|
|
cross-instance fan-out lives in RoomBroadcaster, and cross-instance
|
|
"who's connected" for push lives in Presence, both backed by Redis
|
|
(ARCHITECTURE.md phase 5).
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
|
|
|
|
def join(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
|
self._rooms[room_id].add(websocket)
|
|
|
|
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
|
self._rooms[room_id].discard(websocket)
|
|
if not self._rooms[room_id]:
|
|
del self._rooms[room_id]
|
|
|
|
def leave_all(self, websocket: WebSocket) -> None:
|
|
for room_id in list(self._rooms.keys()):
|
|
self.leave(room_id, websocket)
|
|
|
|
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
|
for websocket in list(self._rooms.get(room_id, ())):
|
|
await websocket.send_json(payload)
|