Private
Public Access
Phase 5: Redis pub/sub for horizontal scaling
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>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
|
||||
ROOM_CHANNEL_PREFIX = "room:"
|
||||
|
||||
|
||||
class RoomBroadcaster:
|
||||
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5).
|
||||
|
||||
Publishes to a per-room Redis channel; every app instance -- including
|
||||
the one that published -- subscribes via a single pattern subscription
|
||||
and forwards to its own locally connected WebSocket clients via
|
||||
ConnectionManager. A single instance just talks to itself through Redis,
|
||||
so there's no separate code path for the 1-instance vs N-instance case.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis, manager: ConnectionManager) -> None:
|
||||
self._redis = redis
|
||||
self._manager = manager
|
||||
|
||||
async def publish(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||
await self._redis.publish(f"{ROOM_CHANNEL_PREFIX}{room_id}", json.dumps(payload))
|
||||
|
||||
async def listen(self) -> None:
|
||||
pubsub = self._redis.pubsub()
|
||||
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*")
|
||||
try:
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] != "pmessage":
|
||||
continue
|
||||
channel = message["channel"]
|
||||
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
|
||||
payload = json.loads(message["data"])
|
||||
await self._manager.broadcast(room_id, payload)
|
||||
finally:
|
||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*")
|
||||
await pubsub.aclose()
|
||||
Reference in New Issue
Block a user