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
+26
-24
@@ -2,38 +2,40 @@ import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
|
||||
|
||||
class Presence:
|
||||
"""Cross-instance "who's connected to this room," backed by a Redis hash
|
||||
per room (field = user_id, value = connection refcount).
|
||||
"""Cross-instance "who's connected to this room," one expiring set per
|
||||
room (see ExpiringPresenceSet).
|
||||
|
||||
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.
|
||||
This is what the offline-push check reads, so a leaked entry used to mean
|
||||
a user who'd gone away was still treated as "watching the room" and never
|
||||
got a push notification (#77). Entries now expire unless the connection
|
||||
keeps renewing them, so that can't outlive a dead connection by more than
|
||||
the TTL. Keys are versioned (`v2`) so the old refcount hashes
|
||||
(`presence:{room_id}`), with whatever they leaked, are never read.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
|
||||
self._store = store or ExpiringPresenceSet(redis)
|
||||
|
||||
def _key(self, room_id: uuid.UUID) -> str:
|
||||
return f"presence:{room_id}"
|
||||
return f"presence:v2:room:{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:
|
||||
async def join(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> 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)
|
||||
# Per-room offline transitions aren't broadcast anywhere, so unlike
|
||||
# the global key there's nothing to lose by pruning here -- it just
|
||||
# keeps a busy room's set from accumulating expired entries.
|
||||
await self._store.prune(key)
|
||||
await self._store.add(key, user_id, conn_id)
|
||||
|
||||
async def refresh(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.add(self._key(room_id), user_id, conn_id)
|
||||
|
||||
async def leave(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.remove(self._key(room_id), user_id, conn_id)
|
||||
|
||||
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}
|
||||
return await self._store.live_user_ids(self._key(room_id))
|
||||
Reference in new issue
Block a user