diff --git a/src/backend/core/services/presence.py b/src/backend/core/services/presence.py index a41f3700..7ae4f276 100644 --- a/src/backend/core/services/presence.py +++ b/src/backend/core/services/presence.py @@ -1,25 +1,11 @@ -"""Presence cache. - -Redis-backed memo of "this identity is currently connected to this room". - -This module is intentionally a *pure cache store* with no dependency on other -services, so that `participants_management` (which talks to LiveKit) can -import it without creating an import cycle. The composition of "check cache, -fall back to LiveKit" lives in -`ParticipantsManagement.check_if_in_meeting_cached`. - -Only positive answers are stored: a sticky negative would lock out someone -who joins right after a miss for the whole TTL. The TTL is a safety net in -case an invalidation webhook is lost. -""" +"""Presence cache.""" +from typing import List from uuid import UUID from django.conf import settings from django.core.cache import cache -from core.utils import CACHE_SCAN_ITERSIZE - class PresenceCache: """Store and invalidate (room, identity) presence entries.""" @@ -29,24 +15,65 @@ class PresenceCache: """Cache key for a (room, identity) presence entry.""" return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}" + @staticmethod + def _get_index_key(room_id: UUID | str) -> str: + """Raw Redis key of the per-room identity index (a native SET). + + Built through django-redis' make_key so it lives under the same + KEY_PREFIX/version namespace as the presence entries. + """ + return cache.client.make_key( + f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}" + ) + + @staticmethod + def _redis(write: bool = True): + """Raw redis-py client. + + SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is + the documented django-redis escape hatch. + """ + return cache.client.get_client(write=write) + + def _index_members(self, room_id: UUID | str) -> List[str]: + """All identities currently indexed for the room.""" + members = self._redis(write=False).smembers(self._get_index_key(room_id)) + return [ + member.decode() if isinstance(member, bytes) else member + for member in members + ] + def is_marked_present(self, room_id: UUID | str, identity: str) -> bool: """Return True if a positive presence entry exists in cache.""" return bool(cache.get(self._get_cache_key(room_id, identity))) def mark_present(self, room_id: UUID | str, identity: str) -> None: - """Record that `identity` is in `room_id`.""" + """Record that `identity` is in `room_id` and index it for the room.""" cache.set( self._get_cache_key(room_id, identity), True, timeout=settings.PRESENCE_CACHE_TIMEOUT, ) + index_key = self._get_index_key(room_id) + pipe = self._redis().pipeline(transaction=False) + pipe.sadd(index_key, identity) + pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT) + pipe.execute() def clear(self, room_id: UUID | str, identity: str) -> None: """Forget presence for one participant (e.g. on participant_left).""" cache.delete(self._get_cache_key(room_id, identity)) + self._redis().srem(self._get_index_key(room_id), identity) def clear_room(self, room_id: UUID | str) -> None: - """Forget presence for every participant of a room (on room_finished).""" - cache.delete_pattern( - self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE - ) + """Forget presence for every participant of a room (on room_finished). + + Deletes the indexed entries and the index itself with targeted + commands instead of a full-keyspace pattern scan. + """ + identities = self._index_members(room_id) + if identities: + cache.delete_many( + [self._get_cache_key(room_id, identity) for identity in identities] + ) + self._redis().delete(self._get_index_key(room_id)) diff --git a/src/backend/core/tests/services/test_presence.py b/src/backend/core/tests/services/test_presence.py index fc6a9cc4..4c597fea 100644 --- a/src/backend/core/tests/services/test_presence.py +++ b/src/backend/core/tests/services/test_presence.py @@ -90,19 +90,18 @@ def test_presence_clear_and_clear_room(): assert presence.is_marked_present(other_room, "a") is True -def test_presence_clear_room_scans_in_pages(): - """clear_room removes every match, even across several SCAN pages, - and only within the room.""" +def test_presence_clear_room_removes_many_entries_and_the_index(): + """clear_room removes every entry of the room through the index — never + a keyspace scan — and leaves other rooms untouched.""" room_id, other_room = str(uuid4()), str(uuid4()) presence = PresenceCache() for i in range(7): presence.mark_present(room_id, f"user-{i}") presence.mark_present(other_room, "user-0") - # An itersize smaller than the match count forces delete_pattern to - # page through several SCAN cursors rather than finish in one pass. - with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3): - presence.clear_room(room_id) + presence.clear_room(room_id) assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7)) assert presence.is_marked_present(other_room, "user-0") is True + assert presence._index_members(room_id) == [] + assert presence._index_members(other_room) == ["user-0"] diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 6038f449..8e2b2bef 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -499,6 +499,3 @@ def build_telephony_config(): "default_country": country, "international_phone_number": international, } - - -CACHE_SCAN_ITERSIZE = 500