mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-29 19:57:14 +00:00
⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
`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.
This commit is contained in:
committed by
aleb_the_flash
parent
943b81676b
commit
76a24d4787
@@ -23,6 +23,7 @@ and this project adheres to
|
|||||||
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
|
- ⬆️(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) upgrade posthog-js from 1.404.1 to 1.409.5
|
||||||
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
|
- ⚡️(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
|
## [1.28.0] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ class LobbyService:
|
|||||||
"""List all waiting participants for a room."""
|
"""List all waiting participants for a room."""
|
||||||
|
|
||||||
pattern = self._get_cache_key(room_id, "*")
|
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:
|
if not keys:
|
||||||
return []
|
return []
|
||||||
@@ -345,13 +345,9 @@ class LobbyService:
|
|||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
pattern = self._get_cache_key(room_id, "*")
|
cache.delete_pattern(
|
||||||
keys = cache.keys(pattern)
|
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
||||||
|
)
|
||||||
if not keys:
|
|
||||||
return
|
|
||||||
|
|
||||||
cache.delete_many(keys)
|
|
||||||
|
|
||||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||||
"""Clear a given participant entry from the cache for a specific room."""
|
"""Clear a given participant entry from the cache for a specific room."""
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from uuid import UUID
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from core.utils import CACHE_SCAN_ITERSIZE
|
||||||
|
|
||||||
|
|
||||||
class PresenceCache:
|
class PresenceCache:
|
||||||
"""Store and invalidate (room, identity) presence entries."""
|
"""Store and invalidate (room, identity) presence entries."""
|
||||||
@@ -45,6 +47,6 @@ class PresenceCache:
|
|||||||
|
|
||||||
def clear_room(self, room_id: UUID | str) -> None:
|
def clear_room(self, room_id: UUID | str) -> None:
|
||||||
"""Forget presence for every participant of a room (on room_finished)."""
|
"""Forget presence for every participant of a room (on room_finished)."""
|
||||||
keys = cache.keys(self._get_cache_key(room_id, "*"))
|
cache.delete_pattern(
|
||||||
if keys:
|
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
||||||
cache.delete_many(keys)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from core.services.lobby import (
|
|||||||
LobbyParticipantStatus,
|
LobbyParticipantStatus,
|
||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
|
from core.services.presence import CACHE_SCAN_ITERSIZE
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -578,14 +579,14 @@ def test_get_participant_parsing_error(
|
|||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants when none exist."""
|
"""Test listing waiting participants when none exist."""
|
||||||
mock_cache.keys.return_value = []
|
mock_cache.iter_keys.return_value = []
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
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()
|
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."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
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}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
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]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
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])
|
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",
|
"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 = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
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)
|
assert all(p["status"] == "waiting" for p in result)
|
||||||
|
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
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])
|
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."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
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"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
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"}
|
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 = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: corrupted_participant,
|
cache_key1: corrupted_participant,
|
||||||
cache_key2: valid_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
|
# Verify both cache keys were queried
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
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])
|
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",
|
"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 = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
|
|||||||
@@ -88,3 +88,21 @@ def test_presence_clear_and_clear_room():
|
|||||||
presence.clear_room(room_id)
|
presence.clear_room(room_id)
|
||||||
assert presence.is_marked_present(room_id, "b") is False
|
assert presence.is_marked_present(room_id, "b") is False
|
||||||
assert presence.is_marked_present(other_room, "a") is True
|
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
|
||||||
|
|||||||
@@ -512,3 +512,6 @@ def build_telephony_config():
|
|||||||
"default_country": country,
|
"default_country": country,
|
||||||
"international_phone_number": international,
|
"international_phone_number": international,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CACHE_SCAN_ITERSIZE = 500
|
||||||
|
|||||||
Reference in New Issue
Block a user