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>
104 lines
4.2 KiB
Python
104 lines
4.2 KiB
Python
import uuid
|
|
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def _register_ws(ws_client, username: str) -> dict:
|
|
async def _seed():
|
|
async with ws_client.session_factory() as session:
|
|
await register_user(
|
|
session,
|
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
|
)
|
|
|
|
ws_client.portal.call(_seed)
|
|
resp = ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def test_message_fans_out_across_instances(ws_client_factory):
|
|
# Two independent app instances -- separate ConnectionManager, separate
|
|
# Redis pubsub subscription, separate everything except the Postgres and
|
|
# Redis they're both pointed at -- the same way two app-server processes
|
|
# behind Nginx would be. Proves delivery actually crosses Redis, not
|
|
# just in-process delivery within a single ConnectionManager.
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert bob_ws.receive_json()["type"] == "joined"
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
alice_ws.send_json(
|
|
{"type": "message", "room_id": room["id"], "content": "hi from instance 1"}
|
|
)
|
|
assert alice_ws.receive_json()["type"] == "message"
|
|
|
|
received = bob_ws.receive_json()
|
|
assert received["type"] == "message"
|
|
assert received["content"] == "hi from instance 1"
|
|
assert received["username"] == alice["username"]
|
|
|
|
|
|
def test_presence_is_shared_across_instances(ws_client_factory, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
|
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
instance2.post(
|
|
"/api/push/subscribe",
|
|
json={
|
|
"endpoint": f"https://push.example.com/ep-{bob['id']}",
|
|
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
|
|
},
|
|
)
|
|
|
|
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert bob_ws.receive_json()["type"] == "joined"
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
|
assert alice_ws.receive_json()["type"] == "message"
|
|
|
|
# bob is connected -- just on the other instance -- so he should
|
|
# get the broadcast via Redis, not a push notification. If
|
|
# presence were still process-local (pre-phase-5 behavior) he'd
|
|
# look offline to instance1 and get a redundant push.
|
|
assert bob_ws.receive_json()["type"] == "message"
|
|
|
|
# Sync barrier: the handler processes frames strictly
|
|
# sequentially, so a second (idempotent) join only acks once the
|
|
# "message" frame's full handling -- including the offline-push
|
|
# step -- has completed on instance1.
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
|
|
assert calls == []
|