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/// The default bound on tracked sessions (see [`SessionStore::with_max_sessions`]).
48pub const DEFAULT_MAX_SESSIONS: usize = 65_536;
49
50/// Per-session ad-break records.
51///
52/// A `BTreeMap` keyed by session id: the records here are small (one
53/// [`BreakState`] per session, per the module's design decision), so there
54/// is no per-peer *media* state to bound with a cursor count the way
55/// `media-plane`'s `Trunk` must. The store is nonetheless bounded: it holds at
56/// most [`DEFAULT_MAX_SESSIONS`] sessions (or the bound set by
57/// [`Self::with_max_sessions`]), and [`Self::begin_break`] for a new session id
58/// past that is [`Error::SessionStoreFull`], so a flood of distinct ids cannot
59/// grow it forever. This crate has no clock of its own: the caller feeds the
60/// time in with [`Self::advance`] and calls [`Self::expire_idle`] to drop
61/// sessions nobody has touched for a while; [`Self::end`] evicts one directly.
62#[derive(Debug, Clone)]
63pub struct SessionStore {
64    sessions: BTreeMap<String, Tracked>,
65    max_sessions: usize,
66    now_us: u64,
67}
68
69#[derive(Debug, Clone)]
70struct Tracked {
71    state: BreakState,
72    touched_us: u64,
73}
74
75impl Default for SessionStore {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl SessionStore {
82    /// An empty store bounded at [`DEFAULT_MAX_SESSIONS`].
83    pub fn new() -> Self {
84        SessionStore {
85            sessions: BTreeMap::new(),
86            max_sessions: DEFAULT_MAX_SESSIONS,
87            now_us: 0,
88        }
89    }
90
91    /// Bounds the store at `max_sessions` tracked sessions (at least 1).
92    pub fn with_max_sessions(mut self, max_sessions: usize) -> Self {
93        self.max_sessions = max_sessions.max(1);
94        self
95    }
96
97    /// Moves the store's clock to `now_us` (microseconds on any monotonic
98    /// timeline the caller keeps; a value earlier than the current one is
99    /// ignored). Records touched from now on carry this time.
100    pub fn advance(&mut self, now_us: u64) {
101        self.now_us = self.now_us.max(now_us);
102    }
103
104    /// Evicts every session last touched (by [`Self::begin_break`] or
105    /// [`Self::resume`]) more than `ttl_us` before the store's clock; returns
106    /// how many were evicted.
107    pub fn expire_idle(&mut self, ttl_us: u64) -> usize {
108        let before = self.sessions.len();
109        let now = self.now_us;
110        self.sessions
111            .retain(|_, t| now.saturating_sub(t.touched_us) <= ttl_us);
112        before - self.sessions.len()
113    }
114
115    /// Start a break for `session_id`. Errors with
116    /// [`Error::BreakAlreadyActive`] if the session already has an
117    /// unresumed break — call [`Self::resume`] or [`Self::end`] first — and
118    /// with [`Error::SessionStoreFull`] if `session_id` is new and the store
119    /// is at its bound.
120    pub fn begin_break(&mut self, session_id: impl Into<String>, state: BreakState) -> Result<()> {
121        let session_id = session_id.into();
122        match self.sessions.get(&session_id) {
123            Some(existing) if !existing.state.is_resumed() => {
124                return Err(Error::BreakAlreadyActive(session_id));
125            }
126            None if self.sessions.len() >= self.max_sessions => {
127                return Err(Error::SessionStoreFull {
128                    max: self.max_sessions,
129                });
130            }
131            _ => {}
132        }
133        self.sessions.insert(
134            session_id,
135            Tracked {
136                state,
137                touched_us: self.now_us,
138            },
139        );
140        Ok(())
141    }
142
143    /// Record the resumption point for `session_id`'s active break. Errors
144    /// with [`Error::NoActiveBreak`] if there is no record for the session and
145    /// with [`Error::BreakAlreadyResumed`] if its break was already resumed
146    /// (the first resumption point is kept).
147    pub fn resume(&mut self, session_id: &str, resumed_at: ConditionedSplicePoint) -> Result<()> {
148        let tracked = self
149            .sessions
150            .get_mut(session_id)
151            .ok_or_else(|| Error::NoActiveBreak(session_id.into()))?;
152        if tracked.state.is_resumed() {
153            return Err(Error::BreakAlreadyResumed(session_id.into()));
154        }
155        tracked.state.resumed_at = Some(resumed_at);
156        tracked.touched_us = self.now_us;
157        Ok(())
158    }
159
160    /// The current break record for `session_id`, if any. A resumed record
161    /// stays until [`Self::end`] evicts it, so a caller can still render the
162    /// just-ended break's `EXT-X-DATERANGE` for one more playlist reload.
163    pub fn get(&self, session_id: &str) -> Option<&BreakState> {
164        self.sessions.get(session_id).map(|t| &t.state)
165    }
166
167    /// Evict `session_id`'s record entirely (session ended, or its break has
168    /// aged out of the sliding window). Returns the evicted record, if any.
169    pub fn end(&mut self, session_id: &str) -> Option<BreakState> {
170        self.sessions.remove(session_id).map(|t| t.state)
171    }
172
173    /// Number of tracked sessions.
174    pub fn len(&self) -> usize {
175        self.sessions.len()
176    }
177
178    /// Whether the store is empty.
179    pub fn is_empty(&self) -> bool {
180        self.sessions.is_empty()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::splice::condition_splice_point;
188
189    fn splice_point() -> ConditionedSplicePoint {
190        condition_splice_point(1_000, &[1_000], 0).unwrap()
191    }
192
193    #[test]
194    fn begin_get_resume_end_lifecycle() {
195        let mut store = SessionStore::new();
196        assert!(store.is_empty());
197
198        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
199        store
200            .begin_break("s1", BreakState::new(decision.clone(), splice_point()))
201            .unwrap();
202        assert_eq!(store.len(), 1);
203        assert!(!store.get("s1").unwrap().is_resumed());
204
205        store.resume("s1", splice_point()).unwrap();
206        assert!(store.get("s1").unwrap().is_resumed());
207
208        let evicted = store.end("s1").unwrap();
209        assert_eq!(evicted.decision, decision);
210        assert!(store.is_empty());
211    }
212
213    #[test]
214    fn cannot_begin_a_break_while_one_is_already_active() {
215        let mut store = SessionStore::new();
216        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
217        store
218            .begin_break("s1", BreakState::new(decision.clone(), splice_point()))
219            .unwrap();
220
221        let err = store
222            .begin_break("s1", BreakState::new(decision, splice_point()))
223            .unwrap_err();
224        assert!(matches!(err, Error::BreakAlreadyActive(ref s) if s == "s1"));
225    }
226
227    #[test]
228    fn begin_break_is_allowed_again_after_resume() {
229        let mut store = SessionStore::new();
230        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
231        store
232            .begin_break("s1", BreakState::new(decision.clone(), splice_point()))
233            .unwrap();
234        store.resume("s1", splice_point()).unwrap();
235
236        // A second, later break for the same session is fine once resumed.
237        store
238            .begin_break("s1", BreakState::new(decision, splice_point()))
239            .unwrap();
240        assert_eq!(store.len(), 1);
241    }
242
243    #[test]
244    fn resume_without_an_active_break_errors() {
245        let mut store = SessionStore::new();
246        let err = store.resume("nope", splice_point()).unwrap_err();
247        assert!(matches!(err, Error::NoActiveBreak(ref s) if s == "nope"));
248    }
249
250    #[test]
251    fn resume_on_an_already_resumed_break_keeps_the_first_point() {
252        let mut store = SessionStore::new();
253        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
254        store
255            .begin_break("s1", BreakState::new(decision, splice_point()))
256            .unwrap();
257        let first = condition_splice_point(2_000, &[2_000], 0).unwrap();
258        store.resume("s1", first).unwrap();
259        let err = store.resume("s1", splice_point()).unwrap_err();
260        assert!(matches!(err, Error::BreakAlreadyResumed(ref s) if s == "s1"));
261        assert_eq!(store.get("s1").unwrap().resumed_at, Some(first));
262    }
263
264    #[test]
265    fn the_store_is_bounded_and_existing_sessions_still_begin_breaks() {
266        let mut store = SessionStore::new().with_max_sessions(2);
267        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
268        for id in ["a", "b"] {
269            store
270                .begin_break(id, BreakState::new(decision.clone(), splice_point()))
271                .unwrap();
272        }
273        let err = store
274            .begin_break("c", BreakState::new(decision.clone(), splice_point()))
275            .unwrap_err();
276        assert!(matches!(err, Error::SessionStoreFull { max: 2 }));
277        store.resume("a", splice_point()).unwrap();
278        store
279            .begin_break("a", BreakState::new(decision, splice_point()))
280            .unwrap();
281        assert_eq!(store.len(), 2);
282    }
283
284    #[test]
285    fn expire_idle_drops_only_sessions_untouched_for_longer_than_the_ttl() {
286        let mut store = SessionStore::new();
287        let decision = AdBreakDecision::single_asset("b1", "https://x/a.m3u8");
288        store.advance(1_000);
289        store
290            .begin_break("old", BreakState::new(decision.clone(), splice_point()))
291            .unwrap();
292        store.advance(5_000);
293        store
294            .begin_break("new", BreakState::new(decision, splice_point()))
295            .unwrap();
296        store.advance(6_000);
297        assert_eq!(store.expire_idle(2_000), 1);
298        assert!(store.get("old").is_none());
299        assert!(store.get("new").is_some());
300        store.advance(1);
301        assert_eq!(
302            store.expire_idle(1_000),
303            0,
304            "the clock never goes backwards"
305        );
306    }
307}