Private
Public Access
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>
104 lines
4.5 KiB
Python
104 lines
4.5 KiB
Python
import time
|
|
import uuid
|
|
from collections.abc import Callable
|
|
|
|
from redis.asyncio import Redis
|
|
|
|
# #77: an entry that isn't renewed for this long stops counting. Chosen as 3x
|
|
# the app-level ping interval (chat.py's WS_PING_INTERVAL_SECONDS): a healthy
|
|
# connection renews roughly every ping, so two missed beats are tolerated
|
|
# before someone flips offline.
|
|
PRESENCE_TTL_SECONDS = 90
|
|
|
|
|
|
def _text(value: str | bytes) -> str:
|
|
return value.decode() if isinstance(value, bytes) else value
|
|
|
|
|
|
def _user_part(member: str) -> str:
|
|
return member.split(":", 1)[0]
|
|
|
|
|
|
class ExpiringPresenceSet:
|
|
""""Which users have at least one live connection" for one Redis key,
|
|
where every connection is its own entry with its own expiry.
|
|
|
|
This replaced per-user refcounts (#77): a refcount only goes down when a
|
|
connection's cleanup code actually runs, so any connection that ended
|
|
without it -- a server crash or reboot, a process killed mid-restart, a
|
|
client that vanished without the server ever noticing -- left a +1 that
|
|
nothing could ever subtract, showing that person online forever (and in
|
|
the per-room case, suppressing their push notifications). Here a dead
|
|
connection simply stops being renewed and ages out on its own, so there
|
|
is no state that can leak.
|
|
|
|
One sorted set per key: member `<user_id>:<connection_id>`, score = the
|
|
moment it expires. "Live" means score > now. Scans are O(connections) --
|
|
fine for a self-hosted team chat; only the global key grows with the
|
|
whole server rather than one room.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
redis: Redis,
|
|
ttl_seconds: float = PRESENCE_TTL_SECONDS,
|
|
clock: Callable[[], float] = time.time,
|
|
) -> None:
|
|
self._redis = redis
|
|
self._ttl = ttl_seconds
|
|
self._clock = clock
|
|
|
|
@staticmethod
|
|
def _member(user_id: uuid.UUID, conn_id: str) -> str:
|
|
return f"{user_id}:{conn_id}"
|
|
|
|
async def _live_members(self, key: str) -> list[str]:
|
|
members = await self._redis.zrangebyscore(key, f"({self._clock()}", "+inf")
|
|
return [_text(m) for m in members]
|
|
|
|
async def add(self, key: str, user_id: uuid.UUID, conn_id: str) -> bool:
|
|
"""Creates or renews this connection's entry. Returns True iff the
|
|
user had no live entry beforehand -- a genuine offline->online
|
|
transition worth telling anyone about. Renewing an entry that's still
|
|
live (the common case, every heartbeat) is a single cheap write."""
|
|
member = self._member(user_id, conn_id)
|
|
now = self._clock()
|
|
score = await self._redis.zscore(key, member)
|
|
came_online = False
|
|
if score is None or score <= now:
|
|
came_online = not await self.is_live(key, user_id)
|
|
await self._redis.zadd(key, {member: now + self._ttl})
|
|
# Lets a key nobody renews any more (an idle room) disappear on its
|
|
# own instead of sitting there holding only expired entries.
|
|
await self._redis.expire(key, int(self._ttl * 2))
|
|
return came_online
|
|
|
|
async def remove(self, key: str, user_id: uuid.UUID, conn_id: str) -> bool:
|
|
"""Drops this connection's entry. Returns True iff that left the user
|
|
with no live entry -- a genuine online->offline transition. False if
|
|
the entry was already gone: whoever removed it (see prune) already
|
|
reported the transition, and reporting it twice would be noise."""
|
|
removed = await self._redis.zrem(key, self._member(user_id, conn_id))
|
|
if not removed:
|
|
return False
|
|
return not await self.is_live(key, user_id)
|
|
|
|
async def is_live(self, key: str, user_id: uuid.UUID) -> bool:
|
|
prefix = f"{user_id}:"
|
|
return any(m.startswith(prefix) for m in await self._live_members(key))
|
|
|
|
async def live_user_ids(self, key: str) -> set[uuid.UUID]:
|
|
return {uuid.UUID(_user_part(m)) for m in await self._live_members(key)}
|
|
|
|
async def prune(self, key: str) -> set[uuid.UUID]:
|
|
"""Deletes expired entries. Returns the users whose last live entry
|
|
was among them -- the offline transitions nobody was told about,
|
|
since an expiry has no disconnect event to hang a broadcast on."""
|
|
now = self._clock()
|
|
expired = [_text(m) for m in await self._redis.zrangebyscore(key, "-inf", now)]
|
|
if not expired:
|
|
return set()
|
|
await self._redis.zremrangebyscore(key, "-inf", now)
|
|
gone = {_user_part(m) for m in expired} - {_user_part(m) for m in await self._live_members(key)}
|
|
return {uuid.UUID(u) for u in gone}
|