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:
ksmithandClaude Sonnet 5.5 committed 2026-10-13 19:21:39 -06:00
1 parent 7ff7f2ec0e
commit dda85a2b78
11 files changed
+843 -97

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
import os
import time
import uuid
import pytest_asyncio
from redis.asyncio import Redis
from app.ws.expiring_presence import ExpiringPresenceSet
class Clock:
def __init__(self) -> None:
self.now = time.time()
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
@pytest_asyncio.fixture
async def redis_client():
client = Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
yield client
await client.aclose()
@pytest_asyncio.fixture
async def setup(redis_client):
clock = Clock()
store = ExpiringPresenceSet(redis_client, ttl_seconds=90, clock=clock)
key = f"presence:v2:test:{uuid.uuid4()}"
yield store, clock, key
await redis_client.delete(key)
async def test_first_connection_is_a_transition_and_a_second_is_not(setup):
store, _, key = setup
user = uuid.uuid4()
assert await store.add(key, user, "tab1") is True
assert await store.add(key, user, "tab2") is False
assert await store.live_user_ids(key) == {user}
async def test_renewing_a_live_entry_is_not_a_transition(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(30)
assert await store.add(key, user, "tab1") is False
async def test_entry_stops_counting_after_the_ttl(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(89)
assert await store.is_live(key, user) is True
clock.advance(2)
assert await store.is_live(key, user) is False
assert await store.live_user_ids(key) == set()
async def test_renewal_keeps_a_connection_alive_past_the_original_expiry(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
for _ in range(5):
clock.advance(60)
await store.add(key, user, "tab1")
assert await store.is_live(key, user) is True
async def test_coming_back_after_expiry_is_a_transition_again(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(200)
assert await store.add(key, user, "tab1") is True
async def test_one_live_connection_keeps_the_user_online_when_another_expires(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "old-tab")
clock.advance(60)
await store.add(key, user, "new-tab")
clock.advance(60) # old-tab is now 120s stale, new-tab 60s
assert await store.is_live(key, user) is True
async def test_remove_reports_only_the_last_connection_leaving(setup):
store, _, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
await store.add(key, user, "tab2")
assert await store.remove(key, user, "tab1") is False
assert await store.remove(key, user, "tab2") is True
async def test_remove_of_an_entry_that_is_already_gone_reports_nothing(setup):
# Whoever removed it first (e.g. prune) already reported the transition.
store, _, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
await store.remove(key, user, "tab1")
assert await store.remove(key, user, "tab1") is False
async def test_remove_of_an_expired_but_unpruned_entry_still_reports_offline(setup):
# Nobody has been told this user went offline yet (no prune has run), so
# their eventual clean close is the one place left to report it.
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(200)
assert await store.remove(key, user, "tab1") is True
async def test_users_are_independent(setup):
store, _, key = setup
alice, bob = uuid.uuid4(), uuid.uuid4()
await store.add(key, alice, "a")
await store.add(key, bob, "b")
await store.remove(key, alice, "a")
assert await store.live_user_ids(key) == {bob}
async def test_prune_reports_users_who_lost_their_last_live_entry(setup):
store, clock, key = setup
gone, still_here = uuid.uuid4(), uuid.uuid4()
await store.add(key, gone, "tab1")
await store.add(key, still_here, "old")
clock.advance(60)
await store.add(key, still_here, "new")
clock.advance(40) # gone/tab1 and still_here/old are expired; still_here/new is not
assert await store.prune(key) == {gone}
async def test_prune_is_idempotent_and_removes_what_it_reports(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(200)
assert await store.prune(key) == {user}
assert await store.prune(key) == set()
async def test_prune_with_nothing_expired_does_nothing(setup):
store, _, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
assert await store.prune(key) == set()
assert await store.is_live(key, user) is True