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>
40 lines
1.4 KiB
Python
40 lines
1.4 KiB
Python
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}
|