1use crate::decision::AdBreakDecision;
13use crate::error::{Error, Result};
14use crate::splice::ConditionedSplicePoint;
15use alloc::collections::BTreeMap;
16use alloc::string::String;
17
18#[derive(Debug, Clone, PartialEq)]
22pub struct BreakState {
23 pub decision: AdBreakDecision,
25 pub entered_at: ConditionedSplicePoint,
27 pub resumed_at: Option<ConditionedSplicePoint>,
29}
30
31impl BreakState {
32 pub fn new(decision: AdBreakDecision, entered_at: ConditionedSplicePoint) -> Self {
34 BreakState {
35 decision,
36 entered_at,
37 resumed_at: None,
38 }
39 }
40
41 pub fn is_resumed(&self) -> bool {
43 self.resumed_at.is_some()
44 }
45}
46
47pub const DEFAULT_MAX_SESSIONS: usize = 65_536;
49
50#[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 pub fn new() -> Self {
84 SessionStore {
85 sessions: BTreeMap::new(),
86 max_sessions: DEFAULT_MAX_SESSIONS,
87 now_us: 0,
88 }
89 }
90
91 pub fn with_max_sessions(mut self, max_sessions: usize) -> Self {
93 self.max_sessions = max_sessions.max(1);
94 self
95 }
96
97 pub fn advance(&mut self, now_us: u64) {
101 self.now_us = self.now_us.max(now_us);
102 }
103
104 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 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 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 pub fn get(&self, session_id: &str) -> Option<&BreakState> {
164 self.sessions.get(session_id).map(|t| &t.state)
165 }
166
167 pub fn end(&mut self, session_id: &str) -> Option<BreakState> {
170 self.sessions.remove(session_id).map(|t| t.state)
171 }
172
173 pub fn len(&self) -> usize {
175 self.sessions.len()
176 }
177
178 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 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}