mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-08 08:25:55 +00:00
⚡️(backend) refactor presence cache to bound key lookups per room
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.
This commit is contained in:
committed by
aleb_the_flash
parent
acedb21045
commit
f74d23c57e
@@ -29,6 +29,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
|
||||
|
||||
|
||||
@@ -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 FrozenSet
|
||||
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) -> FrozenSet[str]:
|
||||
"""All identities currently indexed for the room."""
|
||||
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||
return frozenset(
|
||||
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))
|
||||
|
||||
@@ -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) == frozenset([])
|
||||
assert presence._index_members(other_room) == frozenset(["user-0"])
|
||||
|
||||
@@ -499,6 +499,3 @@ def build_telephony_config():
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
|
||||
CACHE_SCAN_ITERSIZE = 500
|
||||
|
||||
Reference in New Issue
Block a user