From 76a24d47876a3df5f377efd26a67198da1b44fc6 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 24 Aug 2026 20:18:54 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(backend)=20replace=20blockin?= =?UTF-8?q?g=20Redis=20KEYS=20with=20cursor-based=20SCAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cache.keys()` runs Redis `KEYS`, a full-keyspace scan on Redis's single thread that blocks everything else, including session reads in the same cache. Its cost scales with total keys, not matches, and some managed providers disable `KEYS` entirely. The trusted-lobby feature made this urgent: the waiting-list endpoint scanned on every poll, and its polling audience grows from a few admins to potentially every authenticated participant. Switch to cursor-based `SCAN` via two `core.utils` helpers, deleting in bounded batches so cleanup of a large room cannot block either. A single seam also lets us forbid raw `cache.keys()` going forward. `SCAN` still iterates the keyspace incrementally on the polled path. If monitoring flags it, the follow-up is a per-room set index — out of scope here since it changes the lobby storage model. --- CHANGELOG.md | 1 + src/backend/core/services/lobby.py | 12 ++++------- src/backend/core/services/presence.py | 8 ++++--- src/backend/core/tests/services/test_lobby.py | 21 ++++++++++--------- .../core/tests/services/test_presence.py | 18 ++++++++++++++++ src/backend/core/utils.py | 3 +++ 6 files changed, 42 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ad274e..a5f2d543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to - ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0 - ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5 - ⚡️(frontend) apply frugal constraint to the active meeting audio track +- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN ## [1.28.0] - 2026-08-24 diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 8e61b97d..cc27da76 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -270,7 +270,7 @@ class LobbyService: """List all waiting participants for a room.""" pattern = self._get_cache_key(room_id, "*") - keys = cache.keys(pattern) + keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE)) if not keys: return [] @@ -345,13 +345,9 @@ class LobbyService: def clear_room_cache(self, room_id: UUID) -> None: """Clear all participant entries from the cache for a specific room.""" - pattern = self._get_cache_key(room_id, "*") - keys = cache.keys(pattern) - - if not keys: - return - - cache.delete_many(keys) + cache.delete_pattern( + self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE + ) def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None: """Clear a given participant entry from the cache for a specific room.""" diff --git a/src/backend/core/services/presence.py b/src/backend/core/services/presence.py index ec1b68d4..a41f3700 100644 --- a/src/backend/core/services/presence.py +++ b/src/backend/core/services/presence.py @@ -18,6 +18,8 @@ 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.""" @@ -45,6 +47,6 @@ class PresenceCache: def clear_room(self, room_id: UUID | str) -> None: """Forget presence for every participant of a room (on room_finished).""" - keys = cache.keys(self._get_cache_key(room_id, "*")) - if keys: - cache.delete_many(keys) + cache.delete_pattern( + self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE + ) diff --git a/src/backend/core/tests/services/test_lobby.py b/src/backend/core/tests/services/test_lobby.py index 79433a44..82c840fd 100644 --- a/src/backend/core/tests/services/test_lobby.py +++ b/src/backend/core/tests/services/test_lobby.py @@ -24,6 +24,7 @@ from core.services.lobby import ( LobbyParticipantStatus, LobbyService, ) +from core.services.presence import CACHE_SCAN_ITERSIZE from core.utils import NotificationError pytestmark = pytest.mark.django_db @@ -578,14 +579,14 @@ def test_get_participant_parsing_error( @mock.patch("core.services.lobby.cache") def test_list_waiting_participants_empty(mock_cache, lobby_service): """Test listing waiting participants when none exist.""" - mock_cache.keys.return_value = [] + mock_cache.iter_keys.return_value = [] room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) result = lobby_service.list_waiting_participants(room.id) assert result == [] pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" - mock_cache.keys.assert_called_once_with(pattern) + mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.get_many.assert_not_called() @@ -594,7 +595,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict): """Test listing waiting participants with valid data.""" room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" - mock_cache.keys.return_value = [cache_key] + mock_cache.iter_keys.return_value = [cache_key] mock_cache.get_many.return_value = {cache_key: participant_dict} result = lobby_service.list_waiting_participants(room.id) @@ -603,7 +604,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict): assert result[0]["status"] == "waiting" assert result[0]["username"] == "test-username" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" - mock_cache.keys.assert_called_once_with(pattern) + mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.get_many.assert_called_once_with([cache_key]) @@ -628,7 +629,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service): "color": "#654321", } - mock_cache.keys.return_value = [cache_key1, cache_key2] + mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.get_many.return_value = { cache_key1: participant1, cache_key2: participant2, @@ -646,7 +647,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service): assert all(p["status"] == "waiting" for p in result) pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" - mock_cache.keys.assert_called_once_with(pattern) + mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) @@ -655,7 +656,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service): """Test listing waiting participants with corrupted data.""" room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" - mock_cache.keys.return_value = [cache_key] + mock_cache.iter_keys.return_value = [cache_key] mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}} result = lobby_service.list_waiting_participants(room.id) @@ -680,7 +681,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service corrupted_participant = {"invalid": "data"} - mock_cache.keys.return_value = [cache_key1, cache_key2] + mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.get_many.return_value = { cache_key1: corrupted_participant, cache_key2: valid_participant, @@ -699,7 +700,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service # Verify both cache keys were queried pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" - mock_cache.keys.assert_called_once_with(pattern) + mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) @@ -723,7 +724,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service): "color": "#654321", } - mock_cache.keys.return_value = [cache_key1, cache_key2] + mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.get_many.return_value = { cache_key1: participant1, cache_key2: participant2, diff --git a/src/backend/core/tests/services/test_presence.py b/src/backend/core/tests/services/test_presence.py index 7dc08cdb..fc6a9cc4 100644 --- a/src/backend/core/tests/services/test_presence.py +++ b/src/backend/core/tests/services/test_presence.py @@ -88,3 +88,21 @@ def test_presence_clear_and_clear_room(): presence.clear_room(room_id) assert presence.is_marked_present(room_id, "b") is False 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.""" + 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) + + 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 diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 058fa328..5e113f91 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -512,3 +512,6 @@ def build_telephony_config(): "default_country": country, "international_phone_number": international, } + + +CACHE_SCAN_ITERSIZE = 500