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,39 @@
|
||||
import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
class Presence:
|
||||
"""Cross-instance "who's connected to this room," backed by a Redis hash
|
||||
per room (field = user_id, value = connection refcount).
|
||||
|
||||
Refcounted rather than a plain set so a user with two connections to the
|
||||
same room -- two tabs, or one per app instance -- doesn't get marked
|
||||
offline when only one of those connections closes.
|
||||
|
||||
Known limitation: a hard crash (not a clean disconnect) leaks that
|
||||
connection's increment forever, since there's no heartbeat/TTL here to
|
||||
reclaim it -- out of scope for this phase, same category of
|
||||
simplification as the "no server-side session revocation" note in the
|
||||
README.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
|
||||
def _key(self, room_id: uuid.UUID) -> str:
|
||||
return f"presence:{room_id}"
|
||||
|
||||
async def join(self, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
await self._redis.hincrby(self._key(room_id), str(user_id), 1)
|
||||
|
||||
async def leave(self, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
key = self._key(room_id)
|
||||
field = str(user_id)
|
||||
remaining = await self._redis.hincrby(key, field, -1)
|
||||
if remaining <= 0:
|
||||
await self._redis.hdel(key, field)
|
||||
|
||||
async def connected_user_ids(self, room_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
fields = await self._redis.hkeys(self._key(room_id))
|
||||
return {uuid.UUID(f) for f in fields}
|
||||
Reference in New Issue
Block a user