o_sfu_core/engine/room/factory.rs
1//! Room construction for rooms that are new to the runtime directory.
2//!
3//! `RoomManager` owns idempotent lookup, directory publication and creation
4//! diagnostics. This module contains the cold-path allocation step used
5//! after lookup misses, before the new room is visible to other runtime
6//! entrypoints.
7//!
8//! A factory-created room receives fresh process-local placement, the immutable
9//! runtime policy selected at boot and the shared runtime metrics catalog. It
10//! does not register the room or emit creation events.
11//!
12//! Same-room worker placement is not decided here. The factory
13//! gives a room its stable instance id and primary router id, while
14//! `RoomManager::join_user` assigns workers from packet-loop heartbeat delays
15//! when sessions arrive.
16
17use std::sync::{Arc, Mutex};
18
19use o_sfu_router::{RouterId, rtp::MediaCapabilities};
20
21use super::{Room, RoomRuntimeContext};
22use crate::{
23 RoomMediaLimits, RoomWorkerPolicy, RuntimeFeatureFlags, VideoAdaptationTuning,
24 engine::{RoomInstanceId, metrics::RuntimeMetrics, sync::lock_unpoisoned},
25};
26
27/// admission limits that stay fixed for one room lifetime
28///
29/// this is kept separate from the wider runtime policy because admission is a
30/// narrow concern with its own tests and state checks
31///
32/// the policy is passed into `RoomState` at construction time and then treated
33/// as immutable room configuration
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct RoomAdmissionPolicy {
36 /// maximum number of live users the room accepts at once
37 ///
38 /// replaced connections still consume this budget until the room transition
39 /// finishes and the old live user has been removed
40 pub max_sessions: usize,
41}
42
43impl RoomAdmissionPolicy {
44 #[must_use]
45 pub const fn new(max_sessions: usize) -> Self {
46 Self { max_sessions }
47 }
48}
49
50/// stable runtime policy bundle shared by the room and its state model
51///
52/// this groups the room rules that are fixed for the room lifetime and read by
53/// more than one boundary during join, negotiation and observability work
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct RoomRuntimePolicy {
56 /// room-level admission limits enforced by room state
57 pub admission_policy: RoomAdmissionPolicy,
58 /// feature surface the room advertises to clients
59 pub feature_flags: RuntimeFeatureFlags,
60 /// router-native capability baseline used for negotiation and bootstrap
61 pub router_rtp_capabilities: MediaCapabilities,
62 /// same-room local worker-placement policy selected at runtime boot
63 pub room_worker_policy: RoomWorkerPolicy,
64 /// room media activation caps applied by source policy
65 pub media_limits: RoomMediaLimits,
66 /// receiver video adaptation knobs applied by source policy
67 pub video_adaptation_tuning: VideoAdaptationTuning,
68}
69
70impl RoomRuntimePolicy {
71 #[must_use]
72 pub fn new(
73 admission_policy: RoomAdmissionPolicy,
74 feature_flags: RuntimeFeatureFlags,
75 router_rtp_capabilities: MediaCapabilities,
76 ) -> Self {
77 Self {
78 admission_policy,
79 feature_flags,
80 router_rtp_capabilities,
81 room_worker_policy: RoomWorkerPolicy::strict_single_router(),
82 media_limits: RoomMediaLimits::default(),
83 video_adaptation_tuning: VideoAdaptationTuning::default(),
84 }
85 }
86
87 /// return a room policy that uses the provided same-room worker policy
88 #[must_use]
89 pub fn with_room_worker_policy(mut self, room_worker_policy: RoomWorkerPolicy) -> Self {
90 self.room_worker_policy = room_worker_policy;
91 self
92 }
93
94 /// return a room policy that uses the provided media activation limits
95 #[must_use]
96 pub fn with_media_limits(mut self, media_limits: RoomMediaLimits) -> Self {
97 self.media_limits = media_limits;
98 self
99 }
100
101 #[must_use]
102 pub fn with_video_adaptation_tuning(
103 mut self,
104 video_adaptation_tuning: VideoAdaptationTuning,
105 ) -> Self {
106 self.video_adaptation_tuning = video_adaptation_tuning;
107 self
108 }
109}
110
111/// external room config passed in from the http or runtime edge
112///
113/// this type keeps room identity separate from operator-facing knobs and
114/// compatibility toggles that may be chosen per room at creation time
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RoomConfig {
117 /// whether this room should expose WebRTC to clients at all
118 pub web_rtc_enabled: bool,
119 /// compatibility recording address from `/v1/channel`
120 pub recording_address: Option<String>,
121}
122
123impl Default for RoomConfig {
124 fn default() -> Self {
125 Self {
126 web_rtc_enabled: true,
127 recording_address: None,
128 }
129 }
130}
131
132pub(super) struct RoomInit {
133 /// runtime-local instance and primary placement for the room
134 pub(super) runtime_context: RoomRuntimeContext,
135 /// validated room policy copied from runtime startup
136 pub(super) runtime_policy: RoomRuntimePolicy,
137 /// compatibility-facing issuer captured at room creation
138 pub(super) issuer: String,
139 /// room key captured from the first create request
140 pub(super) key: String,
141 /// room-level compatibility configuration
142 pub(super) config: RoomConfig,
143 /// process metric catalog used by room observers
144 pub(super) metrics: Arc<RuntimeMetrics>,
145}
146
147/// Monotonic placement counters assigned by the current process.
148///
149/// Room instance ids and router ids are allocated under one lock so every
150/// new room receives one coherent runtime placement. The counters are not a
151/// distributed identity source and must not leak into the Odoo-facing room
152/// contract.
153#[derive(Debug)]
154struct RoomRuntimeAllocator {
155 next_room_instance_id: u64,
156 /// Next router id to allocate for room-local topology.
157 ///
158 /// Room creation consumes one primary router id. Dynamic spillover
159 /// placement consumes additional router ids when sessions join.
160 next_router_id: u64,
161}
162
163/// Cold-path constructor for rooms that are new to the directory.
164///
165/// `RoomFactory` keeps runtime-wide creation dependencies behind the manager
166/// so `RoomManager::serve_room` can focus on idempotent lookup and
167/// publication. Each call to [`Self::create`] returns an unpublished
168/// [`Room`] with fresh process-local placement. The caller must insert it in
169/// the directory before exposing it to other runtime entrypoints.
170#[derive(Debug)]
171pub(crate) struct RoomFactory {
172 /// Runtime-wide room rules cloned into each room.
173 ///
174 /// Keeping the policy here makes every room start from the validated
175 /// boot-time policy while still letting the room own its copy.
176 runtime_policy: RoomRuntimePolicy,
177 /// Process metric catalog cloned into each room.
178 metrics: Arc<RuntimeMetrics>,
179 /// Serialized allocator for process-local placement ids.
180 ///
181 /// This keeps concurrent create requests from receiving the same runtime
182 /// placement.
183 allocator: Mutex<RoomRuntimeAllocator>,
184}
185
186impl RoomFactory {
187 /// Builds the factory for one [`RoomManager`](super::RoomManager) lifetime.
188 #[must_use]
189 pub(crate) fn new(runtime_policy: RoomRuntimePolicy, metrics: Arc<RuntimeMetrics>) -> Self {
190 Self {
191 runtime_policy,
192 metrics,
193 allocator: Mutex::new(RoomRuntimeAllocator {
194 next_room_instance_id: 0,
195 next_router_id: 0,
196 }),
197 }
198 }
199
200 /// Creates an unpublished room from one manager lookup miss
201 ///
202 /// The room emits no creation diagnostics. `RoomManager` publishes it
203 /// before emitting its creation event
204 #[must_use]
205 pub(crate) fn create(&self, issuer: &str, key: &str, config: &RoomConfig) -> Arc<Room> {
206 Arc::new(Room::new(RoomInit {
207 runtime_context: self.allocate_runtime_context(),
208 runtime_policy: self.runtime_policy.clone(),
209 issuer: issuer.to_owned(),
210 key: key.to_owned(),
211 config: config.clone(),
212 metrics: Arc::clone(&self.metrics),
213 }))
214 }
215
216 /// Reserves runtime-local placement for one new room.
217 ///
218 /// The primary router id is allocated here, but worker placement remains
219 /// unset until the first session join assigns the room from live load data.
220 ///
221 /// The mutex is poisoned-tolerant because placement allocation has no
222 /// partial side effect beyond the counters themselves. Recovering the inner
223 /// value keeps later room creation possible after an unrelated panic.
224 fn allocate_runtime_context(&self) -> RoomRuntimeContext {
225 let (room_instance_id, primary_router_id) = {
226 let mut allocator = lock_unpoisoned(&self.allocator);
227 let room_instance_id = RoomInstanceId::allocate(&mut allocator.next_room_instance_id);
228 let primary_router_id = RouterId(allocator.next_router_id);
229 allocator.next_router_id = allocator.next_router_id.saturating_add(1);
230 drop(allocator);
231 (room_instance_id, primary_router_id)
232 };
233 RoomRuntimeContext::new_unassigned(room_instance_id, primary_router_id)
234 }
235
236 /// reserve a new process-unique identifier for a spillover router
237 ///
238 /// this provides the room engine with a thread-safe way to allocate new router
239 /// identities on the fly when media load exceeds the primary worker capacity.
240 /// the allocator lock is held only long enough to increment the counter, keeping
241 /// the cold-path creation from blocking active request loops
242 pub(super) fn allocate_spillover_router(&self) -> RouterId {
243 let mut allocator = lock_unpoisoned(&self.allocator);
244 let router_id = RouterId(allocator.next_router_id);
245 allocator.next_router_id = allocator.next_router_id.saturating_add(1);
246 router_id
247 }
248}