Skip to main content

ssai_runtime/
session.rs

1//! Per-session ad-break state.
2//!
3//! Issue #929's design decision: the per-session record is session id ->
4//! ad-break decision -> timeline offsets, and it is deliberately **not** a
5//! media cursor. Every viewer in a break watches one of a small set of ad
6//! assets, and outside a break every viewer sees byte-identical primary
7//! content; what differs per session is which URIs appear in that session's
8//! rendered playlist. `media-plane`'s "writer cost is O(N) in cursor count"
9//! rule is about the shared media rings — a `SessionStore` holds no cursor
10//! into any of them, just this one small [`BreakState`] record per session.
11
12use crate::decision::AdBreakDecision;
13use crate::error::{Error, Result};
14use crate::splice::ConditionedSplicePoint;
15use alloc::collections::BTreeMap;
16use alloc::string::String;
17
18/// One session's ad break: the decision being rendered, plus the conditioned
19/// splice points it entered (and, once known, resumed) at.
20// `AdBreakDecision` is `PartialEq`-only (carries `f64` fields), so this is too.
21#[derive(Debug, Clone, PartialEq)]
22pub struct BreakState {
23    /// The decision being rendered for this break.
24    pub decision: AdBreakDecision,
25    /// The conditioned splice point the break was entered at.
26    pub entered_at: ConditionedSplicePoint,
27    /// The conditioned splice point playback resumed at, once known.
28    pub resumed_at: Option<ConditionedSplicePoint>,
29}
30
31impl BreakState {
32    /// Start a new break with no resumption point yet known.
33    pub fn new(decision: AdBreakDecision, entered_at: ConditionedSplicePoint) -> Self {
34        BreakState {
35            decision,
36            entered_at,
37            resumed_at: None,
38        }
39    }
40
41    /// Whether [`Self::resumed_at`] has been recorded (the break has ended).
42    pub fn is_resumed(&self) -> bool {
43        self.resumed_at.is_some()
44    }
45}
46
47/// Per-session ad-break records.
48///
49/// A `BTreeMap` keyed by session id: the records here are small (one
50/// [`BreakState`] per session, per the module's design decision), so there
51/// is no per-peer *media* state to bound with a cursor count the way
52/// `media-plane`'s `Trunk` must. Eviction policy (TTL, LRU, session-end) is
53/// the caller's concern — this store only exposes [`Self::end`] as the
54/// mechanism; deciding *when* to call it is out of scope for a sans-IO core
55/// with no clock of its own.
56#[derive(Debug, Clone, Default)]
57pub struct SessionStore {
58    sessions: BTreeMap<String, BreakState>,
59}
60
61impl SessionStore {
62    /// An empty store.
63    pub fn new() -> Self {
64        SessionStore {
65            sessions: BTreeMap::new(),
66        }
67    }
68
69    /// Start a break for `session_id`. Errors with
70    /// [`Error::BreakAlreadyActive`] if the session already has an
71    /// unresumed break — call [`Self::resume`] or [`Self::end`] first.
72    pub fn begin_break(&mut self, session_id: impl Into<String>, state: BreakState) -> Result<()> {
73        let session_id = session_id.into();
74        if let Some(existing) = self.sessions.get(&session_id)
75            && !existing.is_resumed()
76        {
77            return Err(Error::BreakAlreadyActive(session_id));
78        }
79        self.sessions.insert(session_id, state);
80        Ok(())
81    }
82
83    /// Record the resumption point for `session_id`'s active break. Errors
84    /// with [`Error::NoActiveBreak`] if there is no record for the session.
85    pub fn resume(&mut self, session_id: &str, resumed_at: ConditionedSplicePoint) -> Result<()> {
86        let state = self
87            .sessions
88            .get_mut(session_id)
89            .ok_or_else(|| Error::NoActiveBreak(session_id.into()))?;
90        state.resumed_at = Some(resumed_at);
91        Ok(())
92    }
93
94    /// The current break record for `session_id`, if any. A resumed record
95    /// stays until [`Self::end`] evicts it, so a caller can still render the
96    /// just-ended break's `EXT-X-DATERANGE` for one more playlist reload.
97    pub fn get(&self, session_id: &str) -> Option<&BreakState> {
98        self.sessions.get(session_id)
99    }
100
101    /// Evict `session_id`'s record entirely (session ended, or its break has
102    /// aged out of the sliding window). Returns the evicted record, if any.
103    pub fn end(&mut self, session_id: &str) -> Option<BreakState> {
104        self.sessions.remove(session_id)
105    }
106
107    /// Number of tracked sessions.
108    pub fn len(&self) -> usize {
109        self.sessions.len()
110    }
111
112    /// Whether the store is empty.
113    pub fn is_empty(&self) -> bool {
114        self.sessions.is_empty()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::splice::condition_splice_point;
122
123    fn splice_point() -> ConditionedSplicePoint {
124        condition_splice_point(1_000, &[1_000], 0).unwrap()
125    }
126
127    #[test]
128    fn begin_get_resume_end_lifecycle() {
129        let mut store = SessionStore::new();
130        assert!(store.is_empty());
131
132        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
133        store
134            .begin_break("s1", BreakState::new(decision.clone(), splice_point()))
135            .unwrap();
136        assert_eq!(store.len(), 1);
137        assert!(!store.get("s1").unwrap().is_resumed());
138
139        store.resume("s1", splice_point()).unwrap();
140        assert!(store.get("s1").unwrap().is_resumed());
141
142        let evicted = store.end("s1").unwrap();
143        assert_eq!(evicted.decision, decision);
144        assert!(store.is_empty());
145    }
146
147    #[test]
148    fn cannot_begin_a_break_while_one_is_already_active() {
149        let mut store = SessionStore::new();
150        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
151        store
152            .begin_break("s1", BreakState::new(decision.clone(), splice_point()))
153            .unwrap();
154
155        let err = store
156            .begin_break("s1", BreakState::new(decision, splice_point()))
157            .unwrap_err();
158        assert!(matches!(err, Error::BreakAlreadyActive(ref s) if s == "s1"));
159    }
160
161    #[test]
162    fn begin_break_is_allowed_again_after_resume() {
163        let mut store = SessionStore::new();
164        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
165        store
166            .begin_break("s1", BreakState::new(decision.clone(), splice_point()))
167            .unwrap();
168        store.resume("s1", splice_point()).unwrap();
169
170        // A second, later break for the same session is fine once resumed.
171        store
172            .begin_break("s1", BreakState::new(decision, splice_point()))
173            .unwrap();
174        assert_eq!(store.len(), 1);
175    }
176
177    #[test]
178    fn resume_without_an_active_break_errors() {
179        let mut store = SessionStore::new();
180        let err = store.resume("nope", splice_point()).unwrap_err();
181        assert!(matches!(err, Error::NoActiveBreak(ref s) if s == "nope"));
182    }
183}