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,44 +2,45 @@ import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
|
||||
|
||||
class FocusPresence:
|
||||
"""Cross-instance "is this desktop-mode user's window currently
|
||||
unfocused," used only to widen desktop_notification/push eligibility
|
||||
beyond plain room-connection state (#59). A Redis hash (field =
|
||||
user_id, value = refcount of that user's currently-blurred desktop
|
||||
connections), parallel to Presence/GlobalPresence.
|
||||
beyond plain room-connection state (#59). One expiring entry per
|
||||
currently-blurred desktop connection (see ExpiringPresenceSet),
|
||||
parallel to Presence/GlobalPresence.
|
||||
|
||||
Absence from this hash is the default and means "focused." That default
|
||||
Absence from the set is the default and means "focused." That default
|
||||
is also exactly right for every browser-tab connection: only the
|
||||
desktop client ever sends focus/blur frames at all (see chat.py's
|
||||
"focus" envelope handling), so a browser user never appears here --
|
||||
their attention is already fully captured by Presence's room-connection
|
||||
state, which stays visibility-gated with no separate focus signal.
|
||||
|
||||
Refcounted for the same multi-connection reason as Presence/
|
||||
GlobalPresence, with the same known simplification: two desktop windows
|
||||
for one user, one focused and one blurred, count as "unfocused" here
|
||||
(refcount > 0) even though the user does have attention somewhere. That
|
||||
errs toward notifying rather than silently missing one, which is the
|
||||
safer failure mode for a notification.
|
||||
Same known simplification as before: two desktop windows for one user,
|
||||
one focused and one blurred, count as "unfocused" (any live blurred
|
||||
entry) even though the user does have attention somewhere. That errs
|
||||
toward notifying rather than silently missing one, which is the safer
|
||||
failure mode for a notification. Entries expire (#77) -- a desktop
|
||||
window that died while blurred used to leave its user "unfocused"
|
||||
forever.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
KEY = "presence:v2:unfocused"
|
||||
|
||||
def _key(self) -> str:
|
||||
return "presence:unfocused"
|
||||
def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
|
||||
self._store = store or ExpiringPresenceSet(redis)
|
||||
|
||||
async def mark_blurred(self, user_id: uuid.UUID) -> None:
|
||||
await self._redis.hincrby(self._key(), str(user_id), 1)
|
||||
async def mark_blurred(self, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def mark_focused(self, user_id: uuid.UUID) -> None:
|
||||
key = self._key()
|
||||
field = str(user_id)
|
||||
remaining = await self._redis.hincrby(key, field, -1)
|
||||
if remaining <= 0:
|
||||
await self._redis.hdel(key, field)
|
||||
async def mark_focused(self, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.remove(self.KEY, user_id, conn_id)
|
||||
|
||||
async def refresh(self, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def is_unfocused(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)
|
||||
Reference in new issue
Block a user