Skip to main content

o_sfu_core/engine/room/manager/
mod.rs

1//! Current-room registry and lifecycle coordinator.
2//!
3//! [`RoomManager`] publishes one current room per issuer, admits WebSocket
4//! sessions and runs background room work. Mutations acquire a lifecycle lease
5//! without holding the directory lock. Empty-room removal waits for every
6//! accepted lease to finish.
7
8#[cfg(any(test, feature = "testing-transport"))]
9use std::sync::Mutex;
10use std::{collections::BTreeSet, future::Future, sync::Arc, time::Duration};
11
12use o_sfu_telemetry::schema::event as telemetry_event;
13use tokio::sync::RwLock;
14use tracing::{info, warn};
15
16#[cfg(any(test, feature = "testing-transport"))]
17pub use super::placement::JoinPlacementTestGate;
18use super::{
19    Room, RoomConfig, RoomJoinError, RoomManagerJoinError, RoomRuntimePolicy,
20    RoomUserStatsSnapshot,
21    directory::{RoomDirectory, RoomDirectoryEntry, RoomLifecycleLease},
22    effects::batch::RoomEffectContext,
23    factory::RoomFactory,
24    membership::JoinUserRequest,
25    placement::JoinAdmissionTurn,
26    source_policy::SourcePolicyTurn,
27};
28use crate::engine::{
29    ConnectionId, RoomInstanceId, UserId,
30    media_transport::{MediaTransport, TransportSessionKey},
31    metrics::{RoomGaugeValues, RuntimeMetrics},
32    room::instance::RoomManagerServeError,
33};
34
35#[cfg(any(test, feature = "testing-transport"))]
36#[path = "TESTS/support.rs"]
37mod test_support;
38
39/// operator-facing room stats assembled from directory and transport snapshots
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RuntimeRoomStatsSnapshot {
42    /// room publication timestamp in RFC 3339 UTC format
43    pub create_date: String,
44    /// room uuid returned by `/v1/channel`
45    pub uuid: String,
46    /// first create request address or `unknown` when unavailable
47    pub remote_address: String,
48    /// live user, stream and bitrate stats read after the directory snapshot
49    pub users_stats: RoomUserStatsSnapshot,
50    /// room creation flag exposed by `/v1/stats`
51    pub web_rtc_enabled: bool,
52}
53
54/// committed admission result returned after join-side effects run
55#[derive(Debug, Clone)]
56pub struct RoomUserAdmission {
57    /// current room that accepted the session
58    pub room: Arc<Room>,
59    /// room-local connection id assigned to the admitted user
60    pub connection_id: ConnectionId,
61    /// transport key used by [`crate::prelude::SfuCore`] to build
62    /// [`crate::prelude::MediaSession`]
63    pub transport_session_key: TransportSessionKey,
64}
65
66/// current room directory row used by diagnostics views
67#[derive(Debug, Clone)]
68pub struct RuntimeRoomDirectorySnapshot {
69    /// current room for this directory row
70    pub room: Arc<Room>,
71    /// room publication timestamp in RFC 3339 UTC format
72    pub create_date: String,
73    /// first create request address or `unknown` when unavailable
74    pub remote_address: String,
75}
76
77fn retrieve_room_reservation(
78    directory: &RoomDirectory,
79    issuer: &str,
80    key: &str,
81    config: &RoomConfig,
82) -> Result<Option<Arc<Room>>, RoomManagerServeError> {
83    let Some(entry) = directory.entry_by_issuer(issuer) else {
84        return Ok(None);
85    };
86    if !entry.room.definition.matches_reservation(key, config) {
87        warn!(
88            event = telemetry_event::ROOM_RESERVATION_CONFLICT,
89            room_id = entry.room.uuid(),
90            issuer,
91            config = ?config,
92            "conflicting reservation request"
93        );
94        return Err(RoomManagerServeError::ConflictingReservation);
95    }
96    entry.lifecycle.renew_reservation();
97    Ok(Some(entry.room))
98}
99
100/// Coordinates current room admission and lifecycle.
101#[derive(Debug)]
102pub struct RoomManager {
103    directory: RwLock<RoomDirectory>,
104    factory: RoomFactory,
105    reservation_ttl: Duration,
106    #[cfg(any(test, feature = "testing-transport"))]
107    join_placement_gate: Mutex<Option<Arc<JoinPlacementTestGate>>>,
108}
109
110impl RoomManager {
111    /// builds a room manager with an empty directory
112    ///
113    #[must_use]
114    pub fn new(
115        runtime_policy: RoomRuntimePolicy,
116        metrics: Arc<RuntimeMetrics>,
117        reservation_ttl: Duration,
118    ) -> Self {
119        let factory = RoomFactory::new(runtime_policy, metrics);
120        Self {
121            directory: RwLock::new(RoomDirectory::default()),
122            factory,
123            reservation_ttl,
124            #[cfg(any(test, feature = "testing-transport"))]
125            join_placement_gate: Mutex::new(None),
126        }
127    }
128
129    /// Returns the current room for `issuer` or publishes a new reservation.
130    ///
131    /// The first reservation fixes `key`, `config` and `remote_address`.
132    /// Matching requests return the same room and renew an outstanding
133    /// reservation without rearming one retired by a successful join.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`RoomManagerServeError::ConflictingReservation`] when the
138    /// current room has a different `key` or `config`.
139    pub async fn serve_room(
140        &self,
141        issuer: &str,
142        key: &str,
143        config: &RoomConfig,
144        remote_address: Option<&str>,
145    ) -> Result<Arc<Room>, RoomManagerServeError> {
146        {
147            let directory = self.directory.read().await;
148            if let Some(room) = retrieve_room_reservation(&directory, issuer, key, config)? {
149                return Ok(room);
150            }
151        }
152        let mut directory = self.directory.write().await;
153        if let Some(room) = retrieve_room_reservation(&directory, issuer, key, config)? {
154            return Ok(room);
155        }
156        let room = self.factory.create(issuer, key, config);
157        directory.insert(Arc::clone(&room), remote_address, self.reservation_ttl);
158        drop(directory);
159        info!(
160            event = telemetry_event::ROOM_CREATED,
161            room_id = room.uuid(),
162            remote_address = remote_address.unwrap_or("unknown"),
163            web_rtc_enabled = config.web_rtc_enabled,
164            "room created"
165        );
166        Ok(room)
167    }
168
169    /// returns the current room for a public room uuid
170    pub async fn get_by_uuid(&self, uuid: &str) -> Option<Arc<Room>> {
171        let directory = self.directory.read().await;
172        directory.get_by_uuid(uuid)
173    }
174
175    /// builds `/v1/stats` rows from one directory snapshot
176    ///
177    /// the directory lock is released before transport stats are read, so the
178    /// returned rows are best-effort runtime observations rather than a global
179    /// transaction across room and media state
180    pub async fn stats_snapshots(
181        &self,
182        media_transport: &MediaTransport,
183    ) -> Vec<RuntimeRoomStatsSnapshot> {
184        let entries = self.directory_entries().await;
185        let mut snapshots = Vec::with_capacity(entries.len());
186        for entry in entries {
187            snapshots.push(self.entry_stats_snapshot(entry, media_transport).await);
188        }
189        snapshots
190    }
191
192    /// returns current directory rows for room diagnostics
193    pub async fn directory_snapshots(&self) -> Vec<RuntimeRoomDirectorySnapshot> {
194        self.directory_entries()
195            .await
196            .into_iter()
197            .map(|entry| RuntimeRoomDirectorySnapshot {
198                room: entry.room,
199                create_date: entry.create_date,
200                remote_address: entry.remote_address,
201            })
202            .collect()
203    }
204
205    /// Returns counts from rooms in one directory snapshot.
206    ///
207    /// Room states are read sequentially after the directory lock is released.
208    /// Removed rooms may contribute once. New rooms appear on the next call.
209    pub async fn room_gauges(&self) -> RoomGaugeValues {
210        let rooms = self.directory.read().await.rooms();
211        let mut gauges = RoomGaugeValues {
212            rooms: rooms.len(),
213            ..RoomGaugeValues::default()
214        };
215        for room in rooms {
216            let state = room.state.read().await;
217            let media = state.media_counts();
218            gauges.users = gauges.users.saturating_add(state.user_count());
219            gauges.publications = gauges.publications.saturating_add(media.publications);
220            gauges.subscriptions = gauges.subscriptions.saturating_add(media.subscriptions);
221            gauges.recording_rooms = gauges
222                .recording_rooms
223                .saturating_add(usize::from(state.recording_state().recording == Some(true)));
224        }
225        gauges
226    }
227
228    /// returns one current directory row for room diagnostics
229    pub async fn directory_snapshot(&self, room_id: &str) -> Option<RuntimeRoomDirectorySnapshot> {
230        let entry = self.entry(room_id).await?;
231        Some(RuntimeRoomDirectorySnapshot {
232            room: entry.room,
233            create_date: entry.create_date,
234            remote_address: entry.remote_address,
235        })
236    }
237
238    /// recalculates packet-selection policy for rooms dirtied by media activity
239    ///
240    /// empty input is a no-op. rooms that left the current directory before the
241    /// drain are skipped. committed route work is executed after each policy
242    /// plan so transport routing and accepted selector state stay in sync
243    pub async fn sync_source_packet_selection_policies_for_runtime_ids(
244        &self,
245        room_instance_ids: &BTreeSet<RoomInstanceId>,
246        media_transport: &MediaTransport,
247    ) {
248        if room_instance_ids.is_empty() {
249            return;
250        }
251        let rooms = self
252            .directory_entries_for_instance_ids(room_instance_ids)
253            .await;
254        if rooms.is_empty() {
255            return;
256        }
257        let active_speaker_sources = media_transport.active_speaker_source_snapshot().await;
258        for room in rooms {
259            SourcePolicyTurn::packet_selection()
260                .execute(&room, Some(media_transport), Some(&active_speaker_sources))
261                .await;
262        }
263    }
264
265    /// Admits one WebSocket connection into a current room.
266    ///
267    /// Returns after join-side room effects complete.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`RoomManagerJoinError::MissingRoom`] when `room_id` is not
272    /// current. Returns [`RoomManagerJoinError::RoomFull`] when a new user
273    /// exceeds room capacity. Returns [`RoomManagerJoinError::RouterState`] when
274    /// router placement cannot commit.
275    ///
276    /// # Panics
277    ///
278    /// Panics when existing relay state refers to an uncommitted source
279    /// placement.
280    pub async fn join_user(
281        &self,
282        room_id: &str,
283        request: JoinUserRequest,
284        media_transport: &MediaTransport,
285    ) -> Result<RoomUserAdmission, RoomManagerJoinError> {
286        let mutation = self
287            .begin_current_room_mutation(room_id)
288            .await
289            .ok_or(RoomManagerJoinError::MissingRoom)?;
290        let room = Arc::clone(&mutation.room);
291
292        let admission = JoinAdmissionTurn::from_factory(request, media_transport, &self.factory);
293        #[cfg(any(test, feature = "testing-transport"))]
294        let admission = admission.with_gate(self.join_placement_gate_for_test());
295        let join_commit = match room
296            .commit_admission(admission, RoomEffectContext::runtime(media_transport))
297            .await
298        {
299            Ok(commit) => commit,
300            Err(err) => {
301                self.finish_session_mutation(room_id, mutation, false).await;
302                return Err(match err {
303                    RoomJoinError::RoomFull => RoomManagerJoinError::RoomFull,
304                    RoomJoinError::RouterState => RoomManagerJoinError::RouterState,
305                });
306            }
307        };
308        // Retire the reservation only after membership commits. Failed admission
309        // must leave its deadline intact for the reaper.
310        mutation.lease.clear_expiration();
311        let receipt = room
312            .finalize_admission(join_commit, RoomEffectContext::runtime(media_transport))
313            .await;
314
315        self.finish_session_mutation(room_id, mutation, false).await;
316        Ok(RoomUserAdmission {
317            room,
318            connection_id: receipt.connection_id,
319            transport_session_key: receipt.transport_session_key,
320        })
321    }
322
323    /// closes one room connection and then re-checks empty-room removal
324    ///
325    /// returns `false` when the room is missing or the connection was not
326    /// removed by this call. the empty current room can still be removed after
327    /// stale or already-completed teardown
328    pub async fn close_session(
329        &self,
330        room_id: &str,
331        user_id: &UserId,
332        connection_id: ConnectionId,
333        media_transport: &MediaTransport,
334    ) -> bool {
335        let Some((_room, did_remove_active_session)) = self
336            .run_current_room_mutation(
337                room_id,
338                |room| async move {
339                    room.remove_user(user_id, connection_id, media_transport)
340                        .await
341                },
342                true,
343            )
344            .await
345        else {
346            return false;
347        };
348        did_remove_active_session
349    }
350
351    /// disconnects selected users from a current room and removes it if empty
352    ///
353    /// missing rooms are ignored because the caller's disconnect intent is
354    /// already satisfied
355    pub async fn disconnect_users(
356        &self,
357        room_id: &str,
358        user_ids: &[UserId],
359        media_transport: &MediaTransport,
360    ) {
361        let _ = self
362            .run_current_room_mutation(
363                room_id,
364                |room| async move {
365                    room.disconnect_users(user_ids, media_transport).await;
366                },
367                true,
368            )
369            .await;
370    }
371
372    /// Claims and removes expired reservations with no active room mutations.
373    pub async fn check_expired_room_reservations(&self) {
374        let mut directory = self.directory.write().await;
375        for entry in directory.entries() {
376            if entry.lifecycle.claim_expired_reservation() {
377                directory.remove_if_current(entry.room.uuid(), &entry.room);
378                info!(
379                    event = telemetry_event::ROOM_RESERVATION_EXPIRED,
380                    room_id = entry.room.uuid(),
381                    "room reservation expired"
382                );
383            }
384        }
385        drop(directory);
386    }
387
388    #[cfg(test)]
389    pub(super) async fn with_current_room<T, F, Fut>(&self, room_id: &str, action: F) -> Option<T>
390    where
391        F: FnOnce(Arc<Room>) -> Fut,
392        Fut: Future<Output = T>,
393    {
394        self.run_current_room_mutation(room_id, action, false)
395            .await
396            .map(|(_, output)| output)
397    }
398
399    async fn run_current_room_mutation<T, F, Fut>(
400        &self,
401        room_id: &str,
402        action: F,
403        remove_if_empty: bool,
404    ) -> Option<(Arc<Room>, T)>
405    where
406        F: FnOnce(Arc<Room>) -> Fut,
407        Fut: Future<Output = T>,
408    {
409        let mutation = self.begin_current_room_mutation(room_id).await?;
410        let room = Arc::clone(&mutation.room);
411        let output = action(Arc::clone(&room)).await;
412        self.finish_session_mutation(room_id, mutation, remove_if_empty)
413            .await;
414        Some((room, output))
415    }
416
417    async fn finish_session_mutation(
418        &self,
419        room_id: &str,
420        mutation: CurrentRoomMutation,
421        remove_if_empty: bool,
422    ) {
423        let CurrentRoomMutation { room, lease } = mutation;
424        // Read emptiness after this mutation. A later accepted mutation can
425        // supply the final removal proof. Dropping the final lease supplies no
426        // proof and cancels the pending claim.
427        if lease.finish(remove_if_empty, room.is_empty().await) {
428            self.directory
429                .write()
430                .await
431                .remove_if_current(room_id, &room);
432        }
433    }
434
435    /// accepts work only against the directory-current room row
436    ///
437    /// the returned mutation holds no directory lock. if the row is replaced
438    /// between snapshot and current check, the lease is cancelled and the caller
439    /// sees `None`
440    async fn begin_current_room_mutation(&self, room_id: &str) -> Option<CurrentRoomMutation> {
441        let entry = self.entry(room_id).await?;
442        let lease = entry.lifecycle.begin()?;
443        let room = entry.room;
444        if self.is_current_entry(room_id, &room).await {
445            return Some(CurrentRoomMutation { room, lease });
446        }
447        lease.cancel();
448        None
449    }
450
451    async fn entry(&self, room_id: &str) -> Option<RoomDirectoryEntry> {
452        let directory = self.directory.read().await;
453        directory.entry(room_id)
454    }
455
456    async fn directory_entries(&self) -> Vec<RoomDirectoryEntry> {
457        let directory = self.directory.read().await;
458        directory.entries()
459    }
460
461    async fn directory_entries_for_instance_ids(
462        &self,
463        room_instance_ids: &BTreeSet<RoomInstanceId>,
464    ) -> Vec<Arc<Room>> {
465        let directory = self.directory.read().await;
466        room_instance_ids
467            .iter()
468            .filter_map(|room_instance_id| directory.entry_by_instance_id(*room_instance_id))
469            .map(|entry| entry.room)
470            .collect()
471    }
472
473    async fn entry_stats_snapshot(
474        &self,
475        entry: RoomDirectoryEntry,
476        media_transport: &MediaTransport,
477    ) -> RuntimeRoomStatsSnapshot {
478        let room = entry.room;
479        let users_stats = room.session_stats_snapshot(media_transport).await;
480        RuntimeRoomStatsSnapshot {
481            create_date: entry.create_date,
482            uuid: room.uuid().to_owned(),
483            remote_address: entry.remote_address,
484            users_stats,
485            web_rtc_enabled: room.web_rtc_enabled(),
486        }
487    }
488
489    async fn is_current_entry(&self, room_id: &str, room: &Arc<Room>) -> bool {
490        let directory = self.directory.read().await;
491        directory.contains_current(room_id, room)
492    }
493}
494
495/// lease plus room pointer accepted from the current directory row
496///
497/// dropping the lease without [`RoomManager::finish_session_mutation`] releases
498/// admission but never removes the room
499#[derive(Debug)]
500struct CurrentRoomMutation {
501    room: Arc<Room>,
502    lease: RoomLifecycleLease,
503}