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
@@ -2,50 +2,55 @@ import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
|
||||
|
||||
class GlobalPresence:
|
||||
"""Cross-instance "does this user have the app open at all right now,"
|
||||
independent of which (if any) room they currently have open -- backing
|
||||
the online/offline presence dot shown wherever a user's avatar renders.
|
||||
A single Redis hash (field = user_id, value = connection refcount),
|
||||
parallel to but separate from Presence's per-room hashes.
|
||||
|
||||
Refcounted for the same reason as Presence: multiple tabs/instances for
|
||||
one user shouldn't flip them offline until the last connection closes.
|
||||
Each connection has its own expiring entry (see ExpiringPresenceSet), so
|
||||
a user with several tabs/devices stays online until the last one goes,
|
||||
and a connection that dies without cleaning up ages out instead of
|
||||
leaving them online forever (#77). The key is versioned (`v2`) because
|
||||
the old refcount hash lives at `presence:global` and its leaked counts
|
||||
must not be read as live entries.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
KEY = "presence:v2:global"
|
||||
|
||||
def _key(self) -> str:
|
||||
return "presence:global"
|
||||
def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
|
||||
self._store = store or ExpiringPresenceSet(redis)
|
||||
|
||||
async def connect(self, user_id: uuid.UUID) -> bool:
|
||||
"""Returns True iff this was the user's first open connection --
|
||||
a genuine offline->online transition worth telling anyone about."""
|
||||
count = await self._redis.hincrby(self._key(), str(user_id), 1)
|
||||
return count == 1
|
||||
async def connect(self, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Returns True iff this made the user newly online -- a genuine
|
||||
offline->online transition worth telling anyone about."""
|
||||
return await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def disconnect(self, user_id: uuid.UUID) -> bool:
|
||||
"""Returns True iff this was the user's last open connection -- a
|
||||
async def refresh(self, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Heartbeat. Same as connect() on purpose: a connection whose entry
|
||||
had already expired (a long stall) comes back online here, and the
|
||||
caller needs to announce that exactly like a fresh connect."""
|
||||
return await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def disconnect(self, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Returns True iff that was the user's last live connection -- a
|
||||
genuine online->offline transition."""
|
||||
key = self._key()
|
||||
field = str(user_id)
|
||||
remaining = await self._redis.hincrby(key, field, -1)
|
||||
if remaining <= 0:
|
||||
await self._redis.hdel(key, field)
|
||||
return True
|
||||
return False
|
||||
return await self._store.remove(self.KEY, user_id, conn_id)
|
||||
|
||||
async def sweep(self) -> set[uuid.UUID]:
|
||||
"""Drops expired entries and returns the users that went offline as a
|
||||
result -- the transitions no disconnect ever reported."""
|
||||
return await self._store.prune(self.KEY)
|
||||
|
||||
async def is_online(self, user_id: uuid.UUID) -> bool:
|
||||
return await self._redis.hexists(self._key(), str(user_id))
|
||||
return await self._store.is_live(self.KEY, user_id)
|
||||
|
||||
async def online_user_ids(self, user_ids: list[uuid.UUID]) -> set[uuid.UUID]:
|
||||
if not user_ids:
|
||||
return set()
|
||||
values = await self._redis.hmget(self._key(), [str(u) for u in user_ids])
|
||||
return {uid for uid, v in zip(user_ids, values) if v is not None}
|
||||
return set(user_ids) & await self._store.live_user_ids(self.KEY)
|
||||
|
||||
async def all_online_user_ids(self) -> set[uuid.UUID]:
|
||||
fields = await self._redis.hkeys(self._key())
|
||||
return {uuid.UUID(f) for f in fields}
|
||||
return await self._store.live_user_ids(self.KEY)
|
||||
Reference in new issue
Block a user