Private
Public Access
Expire presence so users can't get stuck online (#77)
Presence was a per-user refcount in Redis, decremented only when a connection's cleanup ran. Anything that ended a connection without cleanup (a crash, an unclean restart, a laptop asleep with the page frozen) leaked its count forever, so those users never showed offline. Production had 19 counts for 2 live connections. Presence is now one expiring entry per connection (90s TTL) for global, per-room and focus presence, in new presence:v2:* keys. Clients renew theirs by sending any frame, which the existing 30s ping/pong already does. The server closes a human connection that sends nothing for 90s (code 4408), which catches a frozen page whose browser still answers transport pings. Bot connections can't be expected to answer pings, so the server renews theirs itself while the handler is alive. An expiry has no disconnect event to announce it, so a sweeper on each worker (one per 15s overall, via a Redis lock) prunes expired entries and broadcasts the offline transition. The old presence:* hashes are deleted at startup. Tests cover the expiry store, the sweeper, and the websocket cases. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ff7f2ec0e
commit
dda85a2b78
11 files changed
+843
-97
No files matched your search
+21
-3
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import pathlib
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -11,6 +12,7 @@ from redis.asyncio import Redis
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session_factory
|
||||
from app.routers import (
|
||||
admin,
|
||||
auth,
|
||||
@@ -31,6 +33,9 @@ from app.ws.connection_manager import ConnectionManager
|
||||
from app.ws.focus_presence import FocusPresence
|
||||
from app.ws.global_presence import GlobalPresence
|
||||
from app.ws.presence import Presence
|
||||
from app.ws.presence_sweeper import purge_legacy_presence, run_presence_sweeper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# backend/app/main.py -> backend/ -> repo root -- matches both the local
|
||||
# monorepo layout and the production layout (/srv/ds-chat/backend,
|
||||
@@ -65,11 +70,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# restarts on failure instead.
|
||||
listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
|
||||
|
||||
# #77: presence entries now expire, and an expiry has no disconnect event
|
||||
# to announce it -- this loop is what notices. The purge clears the old
|
||||
# refcount hashes (and the counts that had leaked into them); it's
|
||||
# best-effort, since Redis being down at startup shouldn't stop the app.
|
||||
try:
|
||||
await purge_legacy_presence(app.state.redis)
|
||||
except Exception:
|
||||
logger.warning("Could not purge legacy presence keys", exc_info=True)
|
||||
sweeper_task = asyncio.create_task(run_presence_sweeper(app))
|
||||
|
||||
yield
|
||||
|
||||
listener_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await listener_task
|
||||
for task in (sweeper_task, listener_task):
|
||||
task.cancel()
|
||||
for task in (sweeper_task, listener_task):
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await app.state.redis.aclose()
|
||||
|
||||
|
||||
@@ -85,6 +102,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
app.state.connection_manager = ConnectionManager()
|
||||
app.state.session_factory = async_session_factory
|
||||
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.presence = Presence(app.state.redis)
|
||||
app.state.global_presence = GlobalPresence(app.state.redis)
|
||||
|
||||
Reference in new issue
Block a user