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