Compare commits

..

11 Commits

Author SHA1 Message Date
lebaudantoine 42477e6592 fixup! wip to be discuss 2026-09-05 00:25:45 +02:00
lebaudantoine 12566aefc0 wip track the lobby participant not found 2026-09-05 00:25:22 +02:00
lebaudantoine a6933b53a4 wip log time taken by request 2026-09-04 23:59:12 +02:00
lebaudantoine 6c6a4c22d9 wip use an index approach to avoid O(db_size) complexity in the presence cache 2026-09-04 23:02:23 +02:00
lebaudantoine e546d73834 fixup! wip to be discuss 2026-09-04 22:44:51 +02:00
lebaudantoine 38a40f12c5 fixup! wip increase the lobby polling interval 2026-09-04 22:43:55 +02:00
lebaudantoine 69160f023c fixup! wip increase the lobby polling interval 2026-09-04 22:43:11 +02:00
lebaudantoine 514cb2999a wip use an index approach to avoid O(db_size) complexity 2026-09-04 19:46:55 +02:00
lebaudantoine 2e33052516 wip to be discuss 2026-09-04 14:19:54 +02:00
lebaudantoine b9ef6e6eb3 wip avoid redirect while calling /me on the backend 2026-09-04 14:13:15 +02:00
lebaudantoine 071d2b4de7 wip increase the lobby polling interval
goal is to reduce pressure on the backend (should be around ~60% capacity reduction on that endpoint)
2026-09-04 14:09:38 +02:00
19 changed files with 367 additions and 116 deletions
-2
View File
@@ -22,8 +22,6 @@ and this project adheres to
- 🐛(backend) allow any printable ASCII characters in user sub field #1673 - 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667 - 🐛(frontend) keep the sending resolution picked while the camera is off #1667
- 🐛(frontend) restore automatic lower-hand on speaking
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
## [1.30.0] - 2026-09-01 ## [1.30.0] - 2026-09-01
-1
View File
@@ -1,6 +1,5 @@
:root { :root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
} }
.Header-beforeLogo { .Header-beforeLogo {
@@ -14,3 +14,4 @@ accesslog = "-"
# Using '-' for the error log file makes gunicorn log errors to stderr # Using '-' for the error log file makes gunicorn log errors to stderr
errorlog = "-" errorlog = "-"
loglevel = "info" loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
-1
View File
@@ -34,7 +34,6 @@ Let's say you want to change the font of our application to a custom font. You c
:root { :root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
} }
``` ```
+105 -11
View File
@@ -79,6 +79,14 @@ class LobbyService:
Handles participant entry requests, status management, and notifications Handles participant entry requests, status management, and notifications
using cache for state management and LiveKit for real-time updates. using cache for state management and LiveKit for real-time updates.
Participant membership per room is tracked in a native Redis SET (the
"room index") so that listing and clearing a room's lobby never scans
the shared keyspace. The per-participant cache entries remain the source
of truth for state: their TTLs implement liveness (a waiter who stops
polling simply expires). The index only records which participant ids
may exist for a room; a stale id costs one cache miss and is pruned
lazily.
""" """
@staticmethod @staticmethod
@@ -86,6 +94,61 @@ class LobbyService:
"""Generate cache key for participant(s) data.""" """Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_index_key(room_id: UUID) -> str:
"""Raw Redis key of the per-room participant index (a native SET).
Built through django-redis' make_key so it lives under the same
KEY_PREFIX/version namespace as the participant entries.
"""
return cache.client.make_key(f"{settings.LOBBY_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_add(self, room_id: UUID, participant_id: str) -> None:
"""Record a participant id in the room index.
Refreshes a backstop TTL on the index so an abandoned room cannot
leak its set beyond the longest participant timeout.
"""
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, participant_id)
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
pipe.execute()
def _index_members(self, room_id: UUID) -> List[str]:
"""All participant ids 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 _index_touch(self, room_id: UUID) -> None:
"""Re-arm the room index backstop TTL.
Called whenever a participant entry is written or refreshed so the
index always outlives every entry it references — including a lone
waiter whose rolling WAITING TTL would otherwise outlast the
backstop set at enter() time.
"""
self._redis().expire(
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
)
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
"""Drop participant ids from the room index."""
if participant_ids:
self._redis().srem(self._get_index_key(room_id), *participant_ids)
@staticmethod @staticmethod
def _get_or_create_participant_id(request) -> str: def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request.""" """Extract unique participant identifier from the request."""
@@ -209,14 +272,16 @@ class LobbyService:
cache.touch( cache.touch(
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
) )
self._index_touch(room_id)
def enter( def enter(
self, room_id: UUID, participant_id: str, username: str self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant: ) -> LobbyParticipant:
"""Add participant to waiting lobby. """Add participant to waiting lobby.
Create a new participant entry in waiting status and notify room Create a new participant entry in waiting status, index the
participants of the new entry request. participant id for the room, and notify room participants of the
new entry request.
""" """
color = utils.generate_color(participant_id) color = utils.generate_color(participant_id)
@@ -245,6 +310,7 @@ class LobbyService:
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
self._index_add(room_id, participant_id)
return participant return participant
@@ -267,15 +333,31 @@ class LobbyService:
return None return None
def list_waiting_participants(self, room_id: UUID) -> List[dict]: def list_waiting_participants(self, room_id: UUID) -> List[dict]:
"""List all waiting participants for a room.""" """List all waiting participants for a room.
pattern = self._get_cache_key(room_id, "*") Reads the per-room index (O(participants of this room)) instead of
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE)) scanning the shared keyspace. Indexed ids whose cache entry has
expired are pruned lazily here: the entry TTL is the liveness
protocol, so a missing entry means the participant is gone.
"""
if not keys: member_ids = self._index_members(room_id)
if not member_ids:
return [] return []
data = cache.get_many(keys) keys_by_id = {
participant_id: self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
}
data = cache.get_many(list(keys_by_id.values()))
dead_ids = [
participant_id
for participant_id, cache_key in keys_by_id.items()
if cache_key not in data
]
self._index_remove(room_id, *dead_ids)
waiting_participants = [] waiting_participants = []
for cache_key, raw_participant in data.items(): for cache_key, raw_participant in data.items():
@@ -341,16 +423,28 @@ class LobbyService:
participant.status = status participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
self._index_touch(room_id)
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.
cache.delete_pattern( Deletes the indexed participant entries and the index itself with
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE targeted commands instead of a full-keyspace pattern scan.
) """
member_ids = self._index_members(room_id)
if member_ids:
cache.delete_many(
[
self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
]
)
self._redis().delete(self._get_index_key(room_id))
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."""
cache_key = self._get_cache_key(room_id, participant_id) cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key) cache.delete(cache_key)
self._index_remove(room_id, participant_id)
+48 -21
View File
@@ -1,25 +1,11 @@
"""Presence cache. """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.
"""
from typing import List
from uuid import UUID 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."""
@@ -29,24 +15,65 @@ class PresenceCache:
"""Cache key for a (room, identity) presence entry.""" """Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}" 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: def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache.""" """Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity))) return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None: 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( cache.set(
self._get_cache_key(room_id, identity), self._get_cache_key(room_id, identity),
True, True,
timeout=settings.PRESENCE_CACHE_TIMEOUT, 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: def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left).""" """Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity)) 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: 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).
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE 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))
@@ -589,6 +589,9 @@ def test_list_waiting_participants_success(settings):
"color": "#654321", "color": "#654321",
}, },
) )
lobby_service = LobbyService()
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
+126 -17
View File
@@ -2,7 +2,7 @@
Test lobby service. Test lobby service.
""" """
# pylint: disable=W0621,W0613, W0212, R0913 # pylint: disable=W0621,W0613, W0212, R0913, C0302
# ruff: noqa: PLR0913, PLR0917 # ruff: noqa: PLR0913, PLR0917
import uuid import uuid
@@ -24,7 +24,6 @@ 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
@@ -466,18 +465,22 @@ def test_request_entry_participant_with_role(
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id): def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
"""Test refreshing waiting status for a participant.""" """Test refreshing waiting status for a participant."""
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id) lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with( mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT "mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
) )
lobby_service._index_touch.assert_called_once_with(room.id)
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_success( def test_enter_success(
mock_index_add,
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
mock_cache, mock_cache,
@@ -508,13 +511,16 @@ def test_enter_success(
mock_notify.assert_called_once_with( mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"} room_name=str(room.pk), notification_data={"type": "participantWaiting"}
) )
mock_index_add.assert_called_once_with(room.id, participant_id)
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_with_notification_error( def test_enter_with_notification_error(
mock_index_add,
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
mock_cache, mock_cache,
@@ -541,6 +547,7 @@ def test_enter_with_notification_error(
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
mock_index_add.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@@ -579,14 +586,15 @@ 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.iter_keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._index_members = mock.Mock(return_value=[])
lobby_service._index_remove = mock.Mock()
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}_*" lobby_service._index_members.assert_called_once_with(room.id)
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) lobby_service._index_remove.assert_not_called()
mock_cache.get_many.assert_not_called() mock_cache.get_many.assert_not_called()
@@ -595,7 +603,8 @@ 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.iter_keys.return_value = [cache_key] lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
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,8 +612,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert len(result) == 1 assert len(result) == 1
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}_*" lobby_service._index_members.assert_called_once_with(room.id)
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) lobby_service._index_remove.assert_called_once_with(room.id)
mock_cache.get_many.assert_called_once_with([cache_key]) mock_cache.get_many.assert_called_once_with([cache_key])
@@ -629,7 +638,10 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
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,8 +658,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
# Verify all participants have waiting status # Verify all participants have waiting status
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}_*" lobby_service._index_members.assert_called_once_with(room.id)
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])
@@ -656,7 +667,8 @@ 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.iter_keys.return_value = [cache_key] lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
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)
@@ -681,7 +693,10 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
corrupted_participant = {"invalid": "data"} corrupted_participant = {"invalid": "data"}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
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,8 +714,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
mock_cache.delete.assert_called_once_with(cache_key1) mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried # Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
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])
@@ -724,7 +737,10 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -820,6 +836,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
mock_cache.get.return_value = participant_dict mock_cache.get.return_value = participant_dict
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
lobby_service._update_participant_status( lobby_service._update_participant_status(
room.id, room.id,
@@ -837,6 +854,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
mock_cache.set.assert_called_once_with( mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60 "mocked_cache_key", expected_data, timeout=60
) )
lobby_service._index_touch.assert_called_once_with(room.id)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id) lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -881,9 +899,13 @@ def test_clear_room_cache(settings, lobby_service):
timeout=settings.LOBBY_DENIED_TIMEOUT, timeout=settings.LOBBY_DENIED_TIMEOUT,
) )
for participant_id in ("participant1", "participant2", "participant3"):
lobby_service._index_add(room_id, participant_id)
lobby_service.clear_room_cache(room_id) lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == [] assert cache.keys(f"test-lobby_{room_id!s}_*") == []
assert lobby_service._index_members(room_id) == []
def test_clear_room_empty(settings, lobby_service): def test_clear_room_empty(settings, lobby_service):
@@ -910,10 +932,13 @@ def test_clear_participant_cache(lobby_service):
"color": "#123456", "color": "#123456",
} }
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT) cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
lobby_service._index_add(room_id, participant_id)
assert cache.get(cache_key) is not None assert cache.get(cache_key) is not None
assert participant_id in lobby_service._index_members(room_id)
lobby_service.clear_participant_cache(room_id, participant_id) lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None assert cache.get(cache_key) is None
assert participant_id not in lobby_service._index_members(room_id)
def test_clear_participant_cache_nonexistent(lobby_service): def test_clear_participant_cache_nonexistent(lobby_service):
@@ -927,3 +952,87 @@ def test_clear_participant_cache_nonexistent(lobby_service):
lobby_service.clear_participant_cache(room_id, participant_id) lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None assert cache.get(cache_key) is None
# Room index integration tests (real cache backend)
def test_index_add_members_remove_roundtrip(lobby_service):
"""The room index records, lists and forgets participant ids."""
room_id = uuid.uuid4()
assert lobby_service._index_members(room_id) == []
lobby_service._index_add(room_id, "participant1")
lobby_service._index_add(room_id, "participant2")
assert sorted(lobby_service._index_members(room_id)) == [
"participant1",
"participant2",
]
# The index carries a backstop TTL so abandoned rooms cannot leak it.
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
lobby_service._index_remove(room_id, "participant1")
assert lobby_service._index_members(room_id) == ["participant2"]
@mock.patch("core.utils.notify_participants")
def test_enter_registers_participant_in_room_index(
mock_notify, lobby_service, participant_id, username
):
"""Entering the lobby must index the participant id for the room."""
room_id = uuid.uuid4()
lobby_service.enter(room_id, participant_id, username)
assert lobby_service._index_members(room_id) == [participant_id]
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
"""Indexed ids whose cache entry expired are pruned and not listed."""
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
room_id = uuid.uuid4()
cache.set(
f"test-lobby-prune_{room_id!s}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
timeout=100,
)
lobby_service._index_add(room_id, "participant1")
# participant2 is indexed but its cache entry has expired.
lobby_service._index_add(room_id, "participant2")
result = lobby_service.list_waiting_participants(room_id)
assert [participant["id"] for participant in result] == ["participant1"]
assert lobby_service._index_members(room_id) == ["participant1"]
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
"""A lone waiter's polling must keep the room index alive.
Regression test: the index backstop TTL is only armed at enter() time,
so a participant whose rolling WAITING refreshes outlast it would keep
their entry alive while silently vanishing from the moderator list.
Refreshing the waiting status must therefore re-arm the index TTL.
"""
room_id = uuid.uuid4()
lobby_service._index_add(room_id, participant_id)
index_key = lobby_service._get_index_key(room_id)
redis_client = lobby_service._redis()
redis_client.expire(index_key, 10)
assert redis_client.ttl(index_key) <= 10
lobby_service.refresh_waiting_status(room_id, participant_id)
assert redis_client.ttl(index_key) > 10
assert lobby_service._index_members(room_id) == [participant_id]
@@ -90,19 +90,18 @@ def test_presence_clear_and_clear_room():
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(): def test_presence_clear_room_removes_many_entries_and_the_index():
"""clear_room removes every match, even across several SCAN pages, """clear_room removes every entry of the room through the index — never
and only within the room.""" a keyspace scan — and leaves other rooms untouched."""
room_id, other_room = str(uuid4()), str(uuid4()) room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache() presence = PresenceCache()
for i in range(7): for i in range(7):
presence.mark_present(room_id, f"user-{i}") presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0") presence.mark_present(other_room, "user-0")
# An itersize smaller than the match count forces delete_pattern to presence.clear_room(room_id)
# 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 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.is_marked_present(other_room, "user-0") is True
assert presence._index_members(room_id) == []
assert presence._index_members(other_room) == ["user-0"]
-3
View File
@@ -499,6 +499,3 @@ def build_telephony_config():
"default_country": country, "default_country": country,
"international_phone_number": international, "international_phone_number": international,
} }
CACHE_SCAN_ITERSIZE = 500
+1 -1
View File
@@ -865,7 +865,7 @@ class Base(Configuration):
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None "room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
) )
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue( LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None 6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
) )
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue( LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None 5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
+42 -6
View File
@@ -1,5 +1,5 @@
import { css, cva, RecipeVariantProps } from '@/styled-system/css' import { css, cva, RecipeVariantProps } from '@/styled-system/css'
import React, { useMemo } from 'react' import React, { useLayoutEffect, useMemo } from 'react'
const avatar = cva({ const avatar = cva({
base: { base: {
@@ -28,17 +28,24 @@ const avatar = cva({
}, },
}) })
// Instantiating a segmenter is expensive; create it once and reuse it.
const graphemeSegmenter = const graphemeSegmenter =
typeof Intl !== 'undefined' && 'Segmenter' in Intl typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: undefined : undefined
/**
* Returns the first user-perceived character. Some Unicode characters span
* multiple UTF-16 code units, so a naive index into the string can split them
* and yield a broken glyph.
*/
const getFirstGrapheme = (value: string): string => { const getFirstGrapheme = (value: string): string => {
if (!value) return '' if (!value) return ''
if (graphemeSegmenter) { if (graphemeSegmenter) {
const [first] = graphemeSegmenter.segment(value) const [first] = graphemeSegmenter.segment(value)
return first?.segment ?? '' return first?.segment ?? ''
} }
// Fallback: keeps single code points intact (including surrogate pairs).
return Array.from(value)[0] ?? '' return Array.from(value)[0] ?? ''
} }
@@ -59,6 +66,36 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo( export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => { ({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initials = useMemo(() => getInitials(name), [name]) const initials = useMemo(() => getInitials(name), [name])
const textRef = React.useRef<SVGTextElement>(null)
const [offsetY, setOffsetY] = React.useState(0)
// Optically center the initials: measure the ink bounding box of the
// rendered glyphs and shift them so the box's center sits at the middle
// of the viewBox. Works for any font, weight or glyph shape, unlike a
// hand-tuned dy offset. getBBox() is in local (pre-transform)
// coordinates, so applying the translation never changes the measure.
useLayoutEffect(() => {
const text = textRef.current
if (!text) return
const center = () => {
const box = text.getBBox()
// A hidden element measures as an empty box; keep the default then.
if (box.height === 0) return
setOffsetY(50 - (box.y + box.height / 2))
}
center()
// Glyph metrics can change once webfonts finish loading.
let cancelled = false
document.fonts?.ready.then(() => {
if (!cancelled) center()
})
return () => {
cancelled = true
}
}, [initials])
return ( return (
<div <div
style={{ backgroundColor: bgColor, ...style }} style={{ backgroundColor: bgColor, ...style }}
@@ -71,16 +108,15 @@ export const Avatar = React.memo(
className={css({ width: '100%', height: '100%', display: 'block' })} className={css({ width: '100%', height: '100%', display: 'block' })}
> >
<text <text
ref={textRef}
x="50" x="50"
y={50} y="50"
transform={`translate(0 ${offsetY})`}
textAnchor="middle" textAnchor="middle"
dominantBaseline="central"
fontSize="52" fontSize="52"
fontWeight="500" fontWeight="500"
fill="currentColor" fill="currentColor"
className={css({
transform:
'translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))',
})}
> >
{initials} {initials}
</text> </text>
@@ -18,7 +18,7 @@ export const fetchUser = (
} }
): Promise<ApiUser | false> => { ): Promise<ApiUser | false> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fetchApi<ApiUser>('/users/me') fetchApi<ApiUser>('/users/me/')
.then(resolve) .then(resolve)
.catch((error) => { .catch((error) => {
// we assume that a 401 means the user is not logged in // we assume that a 401 means the user is not logged in
@@ -1,5 +1,6 @@
import { ApiError } from '@/api/ApiError' import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { captureEvent } from '@/features/analytics/telemetry'
import { useMutation, type UseMutationOptions } from '@tanstack/react-query' import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
export interface EnterRoomParams { export interface EnterRoomParams {
@@ -17,13 +18,24 @@ export const enterRoom = async ({
allowEntry, allowEntry,
participantId, participantId,
}: EnterRoomParams): Promise<EnterRoomResponse> => { }: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, { try {
method: 'POST', return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
body: JSON.stringify({ method: 'POST',
participant_id: participantId, body: JSON.stringify({
allow_entry: allowEntry, participant_id: participantId,
}), allow_entry: allowEntry,
}) }),
})
} catch (error) {
if (error instanceof ApiError && error.statusCode === 404) {
captureEvent('lobby_entry_participant_gone', {
room_id: roomId,
allow_entry: allowEntry,
})
return { message: 'participant_gone' }
}
throw error
}
} }
export function useEnterRoom( export function useEnterRoom(
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { ApiError } from '@/api/ApiError' import { ApiError } from '@/api/ApiError'
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 4_000
export const LAZY_POLL_INTERVAL_MS = 10_000 export const LAZY_POLL_INTERVAL_MS = 15_000
export const LobbyProvider = () => { export const LobbyProvider = () => {
const room = useRoomContext() const room = useRoomContext()
@@ -34,10 +34,11 @@ export const LobbyProvider = () => {
refetchOnReconnect: false, refetchOnReconnect: false,
refetchInterval: (query) => { refetchInterval: (query) => {
if (!query.state.data?.participants?.length) return false if (!query.state.data?.participants?.length) return false
if (isParticipantsOpen) return POLL_INTERVAL_MS if (isParticipantsOpen)
return query.state.error ? POLL_INTERVAL_MS * 3 : POLL_INTERVAL_MS
return LAZY_POLL_INTERVAL_MS return LAZY_POLL_INTERVAL_MS
}, },
refetchIntervalInBackground: true, refetchIntervalInBackground: false,
}) })
// Triggers: each one-shot, idempotent, deduped by React Query if // Triggers: each one-shot, idempotent, deduped by React Query if
@@ -8,7 +8,7 @@ import {
} from '../api/requestEntry' } from '../api/requestEntry'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 3_000
export const useLobby = ({ export const useLobby = ({
roomId, roomId,
@@ -53,7 +53,9 @@ export const useLobby = ({
} }
return response return response
}, },
refetchInterval: POLL_INTERVAL_MS, retry: false,
refetchInterval: (query) =>
query.state.error ? POLL_INTERVAL_MS * 3 : POLL_INTERVAL_MS,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
enabled: status === ApiLobbyStatus.WAITING, enabled: status === ApiLobbyStatus.WAITING,
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react' import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
@@ -25,11 +25,11 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext() const room = useRoomContext()
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({ const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant, participant: room.localParticipant,
}) })
const isSpeaking = useIsSpeaking(room.localParticipant) const isSpeaking = room.localParticipant.isSpeaking
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false) const [hasShownToast, setHasShownToast] = useState(false)
@@ -57,10 +57,9 @@ export const HandToggle = ({
if (shouldShowToast && !speakingTimerRef.current) { if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => { speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true) setHasShownToast(true)
const onClose = () => { const onClose = () => {
lowerHand() if (isHandRaised) toggleRaisedHand()
resetToastState() resetToastState()
} }
showLowerHandToast(room.localParticipant, onClose) showLowerHandToast(room.localParticipant, onClose)
@@ -71,17 +70,7 @@ export const HandToggle = ({
speakingTimerRef.current = null speakingTimerRef.current = null
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand]) }, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise' const tooltipLabel = isHandRaised ? 'lower' : 'raise'
@@ -86,16 +86,5 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
} }
} }
const lowerHand = async () => { return { isHandRaised, toggleRaisedHand }
if (!isLocal(participant)) return
try {
await raiseHand(false)
} catch (e) {
reportError('generic_failure', e, {
context: 'lower_raised_hand',
})
}
}
return { isHandRaised, toggleRaisedHand, lowerHand }
} }
-4
View File
@@ -6,10 +6,6 @@ body,
height: 100%; height: 100%;
} }
:root {
--avatar-cap-height: 0.7;
}
html.font-lexend { html.font-lexend {
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
} }