Skip to main content

arcly_stream/engine/
mod.rs

1//! The composition root — the analogue of `arcly-http`'s `App`.
2//!
3//! [`Engine`] replaces `stream-center`'s `ApplicationRegistry::from_config`.
4//! It is built by a plain-Rust [`EngineBuilder`] (no TOML, no
5//! `StreamCenterConfig`) and implements all three bus contracts
6//! ([`PublishRegistry`], [`PlaybackRegistry`], [`EventBus`]), so it drops
7//! straight into protocol code written against those traits.
8
9mod builder;
10mod driver;
11
12pub use builder::EngineBuilder;
13
14use crate::auth::{Credentials, StreamAuthenticator};
15use crate::bus::{
16    Application, EventBus, PlaybackRegistry, PublishRegistry, StreamEvent, StreamHandle,
17};
18use crate::observe::Observer;
19use crate::{AppName, Result, StreamError, StreamId, StreamKey};
20use async_trait::async_trait;
21use dashmap::DashMap;
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::Arc;
24use tokio::sync::broadcast;
25use tracing::info;
26
27/// Plain-Rust application descriptor — a named namespace of streams with its own
28/// fan-out capacity and GOP-replay policy. Build one fluently and register it via
29/// [`EngineBuilder::application`](crate::EngineBuilder::application).
30///
31/// ```
32/// use arcly_stream::prelude::*;
33///
34/// let live = AppSpec::new("live")
35///     .gop_cache(120)          // ~4s at 30fps → instant-start replay
36///     .broadcast_capacity(8192); // deeper buffer for very slow joiners
37///
38/// let engine = Engine::builder().application(live).build();
39/// assert_eq!(engine.list_apps()[0].as_str(), "live");
40/// ```
41#[derive(Debug, Clone)]
42pub struct AppSpec {
43    /// Application name (e.g. `"live"`).
44    pub name: AppName,
45    /// Per-stream broadcast channel capacity (frames buffered for slow joiners).
46    pub broadcast_capacity: usize,
47    /// Keyframe-anchored GOP replay buffer size, in frames (0 disables it).
48    /// Enables sub-second playback start for late joiners.
49    pub gop_capacity: usize,
50    /// Hard cap on GOP replay-buffer payload **bytes** (0 = unbounded). Frame
51    /// count alone does not bound memory; this caps a high-bitrate stream.
52    pub gop_byte_capacity: usize,
53    /// Default cumulative-lag budget for resilient subscribers (`None` = none): a
54    /// consumer that drops more than this many frames is evicted automatically.
55    pub subscriber_max_lag: Option<u64>,
56}
57
58impl AppSpec {
59    /// A new app with default capacities and GOP caching disabled.
60    pub fn new(name: impl Into<AppName>) -> Self {
61        Self {
62            name: name.into(),
63            broadcast_capacity: 4096,
64            gop_capacity: 0,
65            gop_byte_capacity: 0,
66            subscriber_max_lag: None,
67        }
68    }
69
70    /// Override the per-stream broadcast channel capacity.
71    pub fn broadcast_capacity(mut self, n: usize) -> Self {
72        self.broadcast_capacity = n;
73        self
74    }
75
76    /// Enable the keyframe-anchored GOP replay buffer, bounded to `frames`
77    /// (e.g. `fps × gop_seconds`). Late joiners receive the cached configs plus
78    /// the current GOP and start decoding immediately.
79    pub fn gop_cache(mut self, frames: usize) -> Self {
80        self.gop_capacity = frames;
81        self
82    }
83
84    /// Additionally bound the GOP replay buffer to `bytes` of payload (0 =
85    /// unbounded). The buffer truncates a GOP that exceeds *either* the frame
86    /// count or this byte budget, so a high-bitrate stream cannot grow the
87    /// replay buffer without limit. Pairs with [`gop_cache`](Self::gop_cache).
88    pub fn gop_cache_bytes(mut self, bytes: usize) -> Self {
89        self.gop_byte_capacity = bytes;
90        self
91    }
92
93    /// Evict any resilient subscriber that falls more than `frames` behind
94    /// (cumulative dropped frames), applied by default to every
95    /// [`subscribe_resilient`](crate::StreamHandle::subscribe_resilient) on this
96    /// app's streams. A backstop so a chronically slow consumer is shed instead
97    /// of churning the ring forever; a caller may still set a tighter bound per
98    /// subscription.
99    pub fn subscriber_max_lag(mut self, frames: u64) -> Self {
100        self.subscriber_max_lag = Some(frames);
101        self
102    }
103}
104
105/// Engine-wide configuration — replaces `sc-config::ServerConfig` knobs the
106/// engine actually needs.
107#[derive(Debug, Clone)]
108pub struct EngineConfig {
109    /// Hard cap on concurrent publishing streams across all applications.
110    pub max_publishers: usize,
111    /// If set, a stream that has not received a frame within this window is
112    /// reaped by [`Engine::reap_idle`] / the background idle reaper.
113    pub idle_timeout: Option<std::time::Duration>,
114}
115
116impl Default for EngineConfig {
117    fn default() -> Self {
118        Self {
119            max_publishers: 10_000,
120            idle_timeout: None,
121        }
122    }
123}
124
125/// The engine. Many instances may coexist in one process (ideal for tests).
126/// Cheap to `Arc`-share.
127pub struct Engine {
128    apps: DashMap<AppName, Arc<Application>>,
129    config: EngineConfig,
130    /// Running count of active publishers across all apps.
131    active_publishers: AtomicUsize,
132    observer: Arc<dyn Observer>,
133    authenticator: Arc<dyn StreamAuthenticator>,
134    /// Protocol workers registered on the builder via
135    /// [`EngineBuilder::protocol`], consumed once by
136    /// [`serve_registered`](Engine::serve_registered).
137    pending_protocols: std::sync::Mutex<Vec<Box<dyn crate::inbound::InboundProtocol>>>,
138}
139
140impl Engine {
141    /// Start building an engine.
142    pub fn builder() -> EngineBuilder {
143        EngineBuilder::new()
144    }
145
146    /// Internal constructor used by [`EngineBuilder::build`].
147    pub(crate) fn from_parts(
148        config: EngineConfig,
149        specs: Vec<AppSpec>,
150        observer: Arc<dyn Observer>,
151        authenticator: Arc<dyn StreamAuthenticator>,
152        protocols: Vec<Box<dyn crate::inbound::InboundProtocol>>,
153    ) -> Arc<Self> {
154        let apps = DashMap::new();
155        for spec in specs {
156            let app = Application::new(
157                spec.name.clone(),
158                spec.broadcast_capacity,
159                spec.gop_capacity,
160                spec.gop_byte_capacity,
161                spec.subscriber_max_lag,
162                Arc::clone(&observer),
163            );
164            apps.insert(spec.name.clone(), app);
165            info!(app = %spec.name, "Application registered");
166        }
167        Arc::new(Self {
168            apps,
169            config,
170            active_publishers: AtomicUsize::new(0),
171            observer,
172            authenticator,
173            pending_protocols: std::sync::Mutex::new(protocols),
174        })
175    }
176
177    /// Register an application after construction (e.g. on config reload).
178    ///
179    /// Rejects a name that is already registered with
180    /// [`StreamError::AppAlreadyRegistered`] rather than silently replacing the
181    /// live [`Application`] — an overwrite would orphan that app's active
182    /// streams and leak the engine-wide publisher count that gates
183    /// [`StreamError::PublisherLimitReached`]. To change an app's settings,
184    /// drain and remove it first (a future `remove_app`), then re-register.
185    pub fn register_app(&self, spec: AppSpec) -> Result<()> {
186        use dashmap::mapref::entry::Entry;
187        match self.apps.entry(spec.name.clone()) {
188            Entry::Occupied(_) => Err(StreamError::AppAlreadyRegistered(spec.name.to_string())),
189            Entry::Vacant(slot) => {
190                let app = Application::new(
191                    spec.name.clone(),
192                    spec.broadcast_capacity,
193                    spec.gop_capacity,
194                    spec.gop_byte_capacity,
195                    spec.subscriber_max_lag,
196                    Arc::clone(&self.observer),
197                );
198                info!(app = %spec.name, "Application registered");
199                slot.insert(app);
200                Ok(())
201            }
202        }
203    }
204
205    /// List registered application names.
206    pub fn list_apps(&self) -> Vec<AppName> {
207        self.apps.iter().map(|r| r.key().clone()).collect()
208    }
209
210    /// Total active publishers across all applications (single atomic load).
211    pub fn total_stream_count(&self) -> usize {
212        self.active_publishers.load(Ordering::Acquire)
213    }
214
215    fn get_app(&self, app: &AppName) -> Result<Arc<Application>> {
216        self.apps
217            .get(app)
218            .map(|r| r.clone())
219            .ok_or_else(|| StreamError::AppNotFound(app.to_string()))
220    }
221
222    /// Claim a publish slot only if the injected [`StreamAuthenticator`] permits
223    /// `creds` to publish `key`. Protocol handlers should call this rather than
224    /// [`start_publish`](PublishRegistry::start_publish) directly so the auth
225    /// policy is enforced uniformly across every transport.
226    pub async fn start_publish_authorized(
227        &self,
228        key: &StreamKey,
229        creds: &Credentials,
230    ) -> Result<StreamHandle> {
231        self.authenticator.authorize_publish(key, creds).await?;
232        self.start_publish(key).await
233    }
234
235    /// Resolve a live stream for playback only if the authenticator permits
236    /// `creds` to play `key`.
237    pub async fn open_playback_authorized(
238        &self,
239        key: &StreamKey,
240        creds: &Credentials,
241    ) -> Result<StreamHandle> {
242        self.authenticator.authorize_play(key, creds).await?;
243        self.get_stream(key)
244    }
245}
246
247#[async_trait]
248impl PublishRegistry for Engine {
249    /// Enforce the injected authenticator before claiming a slot — this is how a
250    /// `dyn PublishRegistry` ingest handler (e.g. RTMP) reaches the same policy
251    /// as the inherent [`Engine::start_publish_authorized`].
252    async fn start_publish_checked(
253        &self,
254        key: &StreamKey,
255        creds: &Credentials,
256    ) -> Result<StreamHandle> {
257        self.start_publish_authorized(key, creds).await
258    }
259
260    #[tracing::instrument(skip(self), fields(app = %key.app, stream = %key.stream_id))]
261    async fn start_publish(&self, key: &StreamKey) -> Result<StreamHandle> {
262        // Optimistically claim a publisher slot before taking the per-app lock.
263        // Roll back the increment if the app-level check later rejects the stream.
264        let prev = self.active_publishers.fetch_add(1, Ordering::AcqRel);
265        if prev >= self.config.max_publishers {
266            self.active_publishers.fetch_sub(1, Ordering::AcqRel);
267            return Err(StreamError::PublisherLimitReached {
268                limit: self.config.max_publishers,
269            });
270        }
271
272        let application = match self.get_app(&key.app) {
273            Ok(app) => app,
274            Err(e) => {
275                self.active_publishers.fetch_sub(1, Ordering::AcqRel);
276                return Err(e);
277            }
278        };
279
280        match application.start_publish(key.stream_id.clone()).await {
281            Ok(handle) => Ok(handle),
282            Err(e) => {
283                self.active_publishers.fetch_sub(1, Ordering::AcqRel);
284                Err(e)
285            }
286        }
287    }
288
289    #[tracing::instrument(skip(self), fields(app = %key.app, stream = %key.stream_id))]
290    async fn end_publish(&self, key: &StreamKey) -> Result<()> {
291        let application = self.get_app(&key.app)?;
292        if application.end_publish(&key.stream_id).await? {
293            self.active_publishers.fetch_sub(1, Ordering::AcqRel);
294        }
295        Ok(())
296    }
297}
298
299impl PlaybackRegistry for Engine {
300    fn get_stream(&self, key: &StreamKey) -> Result<StreamHandle> {
301        let application = self.get_app(&key.app)?;
302        application
303            .get_stream(&key.stream_id)
304            .ok_or_else(|| StreamError::StreamNotFound {
305                app: key.app.to_string(),
306                stream_id: key.stream_id.to_string(),
307            })
308    }
309
310    fn list_streams(&self, app: &AppName) -> Result<Vec<StreamId>> {
311        Ok(self.get_app(app)?.active_streams())
312    }
313}
314
315impl EventBus for Engine {
316    fn subscribe_events(&self, app: &AppName) -> Result<broadcast::Receiver<StreamEvent>> {
317        Ok(self.get_app(app)?.subscribe_events())
318    }
319}
320
321impl std::fmt::Debug for Engine {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.debug_struct("Engine")
324            .field("app_count", &self.apps.len())
325            .field("active_publishers", &self.total_stream_count())
326            .finish()
327    }
328}