Files
ds-chat/backend/app/ws/chat.py
T
ksmithandClaude Sonnet 5 0b995ef75f 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>
2026-08-14 07:13:06 -06:00

149 lines
5.4 KiB
Python

import uuid
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Room, RoomMembership, User
from app.services.message_service import create_message
from app.services.push_service import send_push_to_user
from app.ws.presence import Presence
router = APIRouter(tags=["ws"])
WS_UNAUTHENTICATED = 4401
class ClientEnvelope(BaseModel):
type: str
room_id: uuid.UUID | None = None
content: str | None = None
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
return result.scalar_one_or_none() is not None
async def _notify_offline_members(
db: AsyncSession,
presence: Presence,
room_id: uuid.UUID,
sender: User,
content: str,
) -> None:
result = await db.execute(
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
)
member_ids = {row[0] for row in result.all()}
offline_ids = member_ids - await presence.connected_user_ids(room_id)
if not offline_ids:
return
room = await db.get(Room, room_id)
payload = {
"title": f"#{room.name}" if room else "New message",
"body": f"{sender.username}: {content}"[:120],
"room_id": str(room_id),
}
for user_id in offline_ids:
await send_push_to_user(db, user_id, payload)
@router.websocket("/ws/chat")
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
user_id_raw = websocket.session.get("user_id")
if not user_id_raw:
await websocket.close(code=WS_UNAUTHENTICATED)
return
user = await db.get(User, uuid.UUID(user_id_raw))
if user is None:
await websocket.close(code=WS_UNAUTHENTICATED)
return
await websocket.accept()
manager = websocket.app.state.connection_manager
presence: Presence = websocket.app.state.presence
broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set()
try:
while True:
raw = await websocket.receive_json()
try:
envelope = ClientEnvelope.model_validate(raw)
except ValidationError:
await websocket.send_json({"type": "error", "detail": "Malformed message"})
continue
if envelope.type == "join":
if envelope.room_id is None:
await websocket.send_json({"type": "error", "detail": "room_id required"})
continue
if not await _is_room_member(db, envelope.room_id, user.id):
await websocket.send_json(
{"type": "error", "detail": "Not a member of this room"}
)
continue
manager.join(envelope.room_id, websocket)
await presence.join(envelope.room_id, user.id)
joined_rooms.add(envelope.room_id)
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
elif envelope.type == "leave":
if envelope.room_id is None:
await websocket.send_json({"type": "error", "detail": "room_id required"})
continue
manager.leave(envelope.room_id, websocket)
await presence.leave(envelope.room_id, user.id)
joined_rooms.discard(envelope.room_id)
elif envelope.type == "message":
if envelope.room_id is None or not envelope.content:
await websocket.send_json(
{"type": "error", "detail": "room_id and content required"}
)
continue
if envelope.room_id not in joined_rooms or not await _is_room_member(
db, envelope.room_id, user.id
):
await websocket.send_json(
{"type": "error", "detail": "Not a member of this room"}
)
continue
message = await create_message(db, envelope.room_id, user.id, envelope.content)
await broadcaster.publish(
envelope.room_id,
{
"type": "message",
"id": str(message.id),
"room_id": str(message.room_id),
"user_id": str(message.user_id),
"username": user.username,
"content": message.content,
"created_at": message.created_at.isoformat(),
},
)
await _notify_offline_members(
db, presence, envelope.room_id, user, envelope.content
)
else:
await websocket.send_json(
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
)
except WebSocketDisconnect:
pass
finally:
manager.leave_all(websocket)
for room_id in joined_rooms:
await presence.leave(room_id, user.id)