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>
58 lines
1.6 KiB
Python
58 lines
1.6 KiB
Python
import asyncio
|
|
import contextlib
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
from redis.asyncio import Redis
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from app.config import settings
|
|
from app.routers import auth, health, invites, push, rooms
|
|
from app.ws.broadcaster import RoomBroadcaster
|
|
from app.ws.chat import router as ws_router
|
|
from app.ws.connection_manager import ConnectionManager
|
|
from app.ws.presence import Presence
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
|
app.state.presence = Presence(redis)
|
|
broadcaster = RoomBroadcaster(redis, app.state.connection_manager)
|
|
app.state.broadcaster = broadcaster
|
|
listener_task = asyncio.create_task(broadcaster.listen())
|
|
|
|
yield
|
|
|
|
listener_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await listener_task
|
|
await redis.aclose()
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
app = FastAPI(title="KeepItTalking", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=settings.session_secret,
|
|
same_site="lax",
|
|
https_only=settings.session_https_only,
|
|
max_age=settings.session_max_age_seconds,
|
|
)
|
|
|
|
app.state.connection_manager = ConnectionManager()
|
|
|
|
app.include_router(health.router)
|
|
app.include_router(auth.router)
|
|
app.include_router(rooms.router)
|
|
app.include_router(invites.router)
|
|
app.include_router(push.router)
|
|
app.include_router(ws_router)
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|