From 43963c3d24a5c316a65893f07bd7848678702c31 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 4 Sep 2026 23:02:23 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(backend)=20refactor=20presen?= =?UTF-8?q?ce=20cache=20to=20bound=20key=20lookups=20per=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous presence cache lookup keyed off a scan over the whole cache, so its cost was O(db_size) rather than O(room_size). Combined with the recent switch to cursor-based `SCAN` at an inappropriate page size, this caused a lot of Redis round-trips and noticeably slowed down the backend pods under load. Refactor the presence cache to keep a per-room set of all its participant keys. Lookups now iterate that set instead of scanning the whole database. Complexity is now bounded by room size, not database size, which should restore the backend performance to its previous levels while keeping the lobby behavior unchanged. --- CHANGELOG.md | 1 + src/backend/core/services/presence.py | 69 +++++++++++++------ .../core/tests/services/test_presence.py | 13 ++-- src/backend/core/utils.py | 3 - 4 files changed, 55 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab56050..0a0dabcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to - ⚡️(frontend) increase lobby polling interval on both sides - ⚡️(frontend) add trailing slash on the /me endpoint call - ⚡️(backend) refactor lobby storage to bound key lookups per room +- ⚡️(backend) refactor presence cache to bound key lookups per room ## [1.30.0] - 2026-09-01 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