Skip to main content

oxigeo_streaming/v2/
session_window.rs

1//! Session window processing for geospatial event streams.
2//!
3//! A session window groups events that occur within `gap_duration` of each other.
4//! When a gap larger than `gap_duration` is detected between consecutive events,
5//! the current session closes and a new one starts automatically.
6//!
7//! Additional v2 features:
8//! - Configurable **minimum session size** (sessions with fewer than `min_events`
9//!   events are silently discarded).
10//! - **Maximum session duration** guard: a session that exceeds `max_session_duration`
11//!   is force-closed even if no time-gap has been detected.
12//! - **`flush()`** to close the currently open session at end of stream.
13
14use std::time::{Duration, SystemTime};
15
16use crate::error::StreamingError;
17
18// ─── StreamEvent ──────────────────────────────────────────────────────────────
19
20/// A single typed event in the stream, carrying a wall-clock timestamp and a
21/// monotonically increasing sequence number.
22#[derive(Debug, Clone)]
23pub struct StreamEvent<T> {
24    /// Wall-clock time at which the event occurred.
25    pub timestamp: SystemTime,
26    /// Application payload.
27    pub payload: T,
28    /// Sequence number, strictly increasing within a stream.
29    pub sequence: u64,
30}
31
32impl<T> StreamEvent<T> {
33    /// Construct a new `StreamEvent`.
34    pub fn new(timestamp: SystemTime, payload: T, sequence: u64) -> Self {
35        Self {
36            timestamp,
37            payload,
38            sequence,
39        }
40    }
41}
42
43// ─── SessionWindow ────────────────────────────────────────────────────────────
44
45/// A closed session window containing all events that fell within the session.
46#[derive(Debug, Clone)]
47pub struct SessionWindow<T> {
48    /// Wall-clock timestamp of the first event.
49    pub start: SystemTime,
50    /// Wall-clock timestamp of the last event.
51    pub end: SystemTime,
52    /// All events belonging to this session, in arrival order.
53    pub events: Vec<StreamEvent<T>>,
54    /// Monotonically increasing session identifier (0-based).
55    pub session_id: u64,
56}
57
58impl<T> SessionWindow<T> {
59    /// Duration from first to last event.
60    ///
61    /// Returns `Duration::ZERO` if `end` is before `start` (clock anomaly).
62    pub fn duration(&self) -> Duration {
63        self.end
64            .duration_since(self.start)
65            .unwrap_or(Duration::ZERO)
66    }
67
68    /// Number of events in this session.
69    pub fn event_count(&self) -> usize {
70        self.events.len()
71    }
72
73    /// `true` if the session contains no events.
74    pub fn is_empty(&self) -> bool {
75        self.events.is_empty()
76    }
77}
78
79// ─── SessionWindowConfig ──────────────────────────────────────────────────────
80
81/// Configuration for the session window processor.
82#[derive(Debug, Clone)]
83pub struct SessionWindowConfig {
84    /// Maximum gap between consecutive events before the session is closed.
85    pub gap_duration: Duration,
86    /// Minimum number of events required for a session to be emitted.
87    /// Sessions with fewer events are silently discarded.
88    pub min_events: usize,
89    /// If set, force-close a session whose span exceeds this duration.
90    pub max_session_duration: Option<Duration>,
91    /// Bounded out-of-order tolerance.
92    ///
93    /// The processor assumes events arrive in non-decreasing timestamp order.
94    /// An event whose timestamp is earlier than the previously processed event
95    /// by **more than** `allowed_lateness` is rejected by [`SessionWindowProcessor::process`]
96    /// with [`StreamingError::InvalidState`], rather than being silently
97    /// mis-assigned to the wrong session. Defaults to [`Duration::ZERO`], which
98    /// rejects any strictly out-of-order event.
99    pub allowed_lateness: Duration,
100}
101
102impl Default for SessionWindowConfig {
103    fn default() -> Self {
104        Self {
105            gap_duration: Duration::from_secs(30),
106            min_events: 1,
107            max_session_duration: None,
108            allowed_lateness: Duration::ZERO,
109        }
110    }
111}
112
113// ─── SessionWindowProcessor ───────────────────────────────────────────────────
114
115/// Stateful processor that groups a time-ordered event stream into session windows.
116///
117/// Call [`Self::process`] for each incoming event (events **must** arrive in
118/// non-decreasing timestamp order). Call [`Self::flush`] at end-of-stream to close
119/// any pending session. Completed windows are collected via [`Self::drain_sessions`].
120pub struct SessionWindowProcessor<T: Clone> {
121    config: SessionWindowConfig,
122    /// Events buffered in the currently open session.
123    current_session: Option<Vec<StreamEvent<T>>>,
124    /// Timestamp of the first event in the current session.
125    session_start: Option<SystemTime>,
126    /// Timestamp of the most recently processed event.
127    last_event_time: Option<SystemTime>,
128    /// Counter for generating session IDs.
129    next_session_id: u64,
130    /// Completed sessions waiting to be drained.
131    closed_sessions: Vec<SessionWindow<T>>,
132}
133
134impl<T: Clone> SessionWindowProcessor<T> {
135    /// Create a new processor with the given configuration.
136    pub fn new(config: SessionWindowConfig) -> Self {
137        Self {
138            config,
139            current_session: None,
140            session_start: None,
141            last_event_time: None,
142            next_session_id: 0,
143            closed_sessions: Vec::new(),
144        }
145    }
146
147    /// Process an incoming event.
148    ///
149    /// A new session is started if:
150    /// - There is no open session, **or**
151    /// - The gap since the previous event exceeds `gap_duration`, **or**
152    /// - The current session has exceeded `max_session_duration`.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`StreamingError::InvalidState`] when `event` arrives earlier than
157    /// the previously processed event by more than `config.allowed_lateness`
158    /// (the out-of-order precondition is enforced rather than silently assumed).
159    pub fn process(&mut self, event: StreamEvent<T>) -> Result<(), StreamingError> {
160        let event_time = event.timestamp;
161
162        // Enforce the non-decreasing-order precondition with bounded lateness.
163        // An event that arrives earlier than the last processed event by more
164        // than `allowed_lateness` cannot be assigned to a session correctly, so
165        // reject it explicitly instead of silently mis-processing it.
166        if let Some(last) = self.last_event_time
167            && let Ok(behind) = last.duration_since(event_time)
168            && behind > self.config.allowed_lateness
169        {
170            return Err(StreamingError::InvalidState(format!(
171                "out-of-order event (sequence {}): timestamp is {:?} behind the last processed \
172                 event, exceeding allowed_lateness {:?}",
173                event.sequence, behind, self.config.allowed_lateness
174            )));
175        }
176
177        // Check whether we should close the current session.
178        let gap_exceeded = self.last_event_time.map(|last| {
179            event_time.duration_since(last).unwrap_or(Duration::ZERO) > self.config.gap_duration
180        });
181
182        let max_exceeded = self
183            .session_start
184            .zip(self.config.max_session_duration)
185            .map(|(start, max)| event_time.duration_since(start).unwrap_or(Duration::ZERO) > max);
186
187        let should_close = gap_exceeded.unwrap_or(false) || max_exceeded.unwrap_or(false);
188
189        if should_close {
190            self.close_current_session();
191        }
192
193        // Open a new session if none is active.
194        if self.current_session.is_none() {
195            self.current_session = Some(Vec::new());
196            self.session_start = Some(event_time);
197        }
198
199        self.last_event_time = Some(event_time);
200        if let Some(ref mut session) = self.current_session {
201            session.push(event);
202        }
203
204        Ok(())
205    }
206
207    /// Force-close the currently open session (call at end of stream).
208    ///
209    /// After flushing, any sessions that meet the `min_events` threshold are
210    /// available via [`Self::drain_sessions`].
211    pub fn flush(&mut self) {
212        self.close_current_session();
213    }
214
215    /// Drain and return all completed session windows.
216    ///
217    /// The internal buffer is cleared; subsequent calls return an empty `Vec`
218    /// until more sessions are closed.
219    pub fn drain_sessions(&mut self) -> Vec<SessionWindow<T>> {
220        std::mem::take(&mut self.closed_sessions)
221    }
222
223    /// Number of events buffered in the currently open (not yet closed) session.
224    pub fn pending_event_count(&self) -> usize {
225        self.current_session.as_ref().map(|s| s.len()).unwrap_or(0)
226    }
227
228    /// Total number of sessions that have been closed (includes discarded ones).
229    pub fn total_sessions_closed(&self) -> u64 {
230        self.next_session_id
231    }
232
233    // ── internals ────────────────────────────────────────────────────────────
234
235    fn close_current_session(&mut self) {
236        if let (Some(events), Some(start)) =
237            (self.current_session.take(), self.session_start.take())
238        {
239            let session_id = self.next_session_id;
240            self.next_session_id += 1;
241
242            if events.len() >= self.config.min_events {
243                let end = self.last_event_time.unwrap_or(start);
244                self.closed_sessions.push(SessionWindow {
245                    start,
246                    end,
247                    events,
248                    session_id,
249                });
250            }
251            // If min_events not met the session is discarded; ID is still consumed.
252        }
253        self.last_event_time = None;
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use std::time::UNIX_EPOCH;
261
262    fn ts(secs: u64) -> SystemTime {
263        UNIX_EPOCH + Duration::from_secs(secs)
264    }
265
266    fn event(secs: u64, seq: u64) -> StreamEvent<u32> {
267        StreamEvent::new(ts(secs), seq as u32, seq)
268    }
269
270    #[test]
271    fn test_single_session_from_close_events() {
272        let cfg = SessionWindowConfig {
273            gap_duration: Duration::from_secs(60),
274            min_events: 1,
275            max_session_duration: None,
276            allowed_lateness: Duration::ZERO,
277        };
278        let mut proc = SessionWindowProcessor::new(cfg);
279        proc.process(event(0, 0)).expect("process ok");
280        proc.process(event(10, 1)).expect("process ok");
281        proc.process(event(20, 2)).expect("process ok");
282        proc.flush();
283        let sessions = proc.drain_sessions();
284        assert_eq!(sessions.len(), 1);
285        assert_eq!(sessions[0].event_count(), 3);
286    }
287
288    #[test]
289    fn test_gap_detection_closes_session() {
290        let cfg = SessionWindowConfig {
291            gap_duration: Duration::from_secs(30),
292            min_events: 1,
293            max_session_duration: None,
294            allowed_lateness: Duration::ZERO,
295        };
296        let mut proc = SessionWindowProcessor::new(cfg);
297        proc.process(event(0, 0)).expect("process ok");
298        // gap of 60 s > 30 s → should close first session
299        proc.process(event(60, 1)).expect("process ok");
300        proc.flush();
301        let sessions = proc.drain_sessions();
302        assert_eq!(sessions.len(), 2);
303    }
304
305    #[test]
306    fn test_min_events_filter_drops_small_sessions() {
307        let cfg = SessionWindowConfig {
308            gap_duration: Duration::from_secs(5),
309            min_events: 3,
310            max_session_duration: None,
311            allowed_lateness: Duration::ZERO,
312        };
313        let mut proc = SessionWindowProcessor::new(cfg);
314        proc.process(event(0, 0)).expect("process ok");
315        proc.process(event(1, 1)).expect("process ok");
316        // only 2 events < min_events=3
317        proc.flush();
318        let sessions = proc.drain_sessions();
319        assert_eq!(sessions.len(), 0);
320    }
321
322    #[test]
323    fn test_max_session_duration_force_closes() {
324        let cfg = SessionWindowConfig {
325            gap_duration: Duration::from_secs(100),
326            min_events: 1,
327            max_session_duration: Some(Duration::from_secs(50)),
328            allowed_lateness: Duration::ZERO,
329        };
330        let mut proc = SessionWindowProcessor::new(cfg);
331        proc.process(event(0, 0)).expect("process ok");
332        // 60 s > max_session_duration=50 s → force close
333        proc.process(event(60, 1)).expect("process ok");
334        proc.flush();
335        let sessions = proc.drain_sessions();
336        // two sessions: first force-closed, second from event at t=60
337        assert_eq!(sessions.len(), 2);
338    }
339
340    #[test]
341    fn test_flush_closes_open_session() {
342        let cfg = SessionWindowConfig::default();
343        let mut proc = SessionWindowProcessor::new(cfg);
344        proc.process(event(0, 0)).expect("process ok");
345        assert_eq!(proc.pending_event_count(), 1);
346        proc.flush();
347        assert_eq!(proc.pending_event_count(), 0);
348        let sessions = proc.drain_sessions();
349        assert_eq!(sessions.len(), 1);
350    }
351
352    #[test]
353    fn test_multiple_sessions_from_gapped_stream() {
354        let cfg = SessionWindowConfig {
355            gap_duration: Duration::from_secs(10),
356            min_events: 1,
357            max_session_duration: None,
358            allowed_lateness: Duration::ZERO,
359        };
360        let mut proc = SessionWindowProcessor::new(cfg);
361        // Session 1
362        proc.process(event(0, 0)).expect("ok");
363        proc.process(event(5, 1)).expect("ok");
364        // gap of 30 s
365        // Session 2
366        proc.process(event(35, 2)).expect("ok");
367        proc.process(event(40, 3)).expect("ok");
368        // gap of 60 s
369        // Session 3
370        proc.process(event(100, 4)).expect("ok");
371        proc.flush();
372        let sessions = proc.drain_sessions();
373        assert_eq!(sessions.len(), 3);
374    }
375
376    #[test]
377    fn test_session_id_increments() {
378        let cfg = SessionWindowConfig {
379            gap_duration: Duration::from_secs(5),
380            min_events: 1,
381            max_session_duration: None,
382            allowed_lateness: Duration::ZERO,
383        };
384        let mut proc = SessionWindowProcessor::new(cfg);
385        proc.process(event(0, 0)).expect("ok");
386        proc.process(event(20, 1)).expect("ok"); // gap → closes session 0
387        proc.flush(); // closes session 1
388        let sessions = proc.drain_sessions();
389        assert_eq!(sessions[0].session_id, 0);
390        assert_eq!(sessions[1].session_id, 1);
391    }
392
393    #[test]
394    fn test_session_duration_computation() {
395        let cfg = SessionWindowConfig::default();
396        let mut proc = SessionWindowProcessor::new(cfg);
397        proc.process(event(100, 0)).expect("ok");
398        proc.process(event(110, 1)).expect("ok");
399        proc.flush();
400        let sessions = proc.drain_sessions();
401        assert_eq!(sessions[0].duration(), Duration::from_secs(10));
402    }
403
404    #[test]
405    fn test_empty_processor_has_no_sessions() {
406        let mut proc: SessionWindowProcessor<u32> = SessionWindowProcessor::new(Default::default());
407        proc.flush();
408        assert_eq!(proc.drain_sessions().len(), 0);
409    }
410
411    #[test]
412    fn test_events_within_gap_stay_in_same_session() {
413        let cfg = SessionWindowConfig {
414            gap_duration: Duration::from_secs(60),
415            min_events: 1,
416            max_session_duration: None,
417            allowed_lateness: Duration::ZERO,
418        };
419        let mut proc = SessionWindowProcessor::new(cfg);
420        for i in 0..10u64 {
421            proc.process(event(i * 5, i)).expect("ok"); // every 5 s, gap=60 s
422        }
423        proc.flush();
424        let sessions = proc.drain_sessions();
425        assert_eq!(sessions.len(), 1);
426        assert_eq!(sessions[0].event_count(), 10);
427    }
428
429    #[test]
430    fn test_out_of_order_event_rejected() {
431        let cfg = SessionWindowConfig::default(); // allowed_lateness = 0
432        let mut proc = SessionWindowProcessor::new(cfg);
433        proc.process(event(100, 0)).expect("in-order ok");
434        // An event 5 s earlier than the last processed event must be rejected.
435        let err = proc
436            .process(event(95, 1))
437            .expect_err("out-of-order should error");
438        assert!(matches!(err, StreamingError::InvalidState(_)));
439    }
440
441    #[test]
442    fn test_out_of_order_within_allowed_lateness_accepted() {
443        let cfg = SessionWindowConfig {
444            gap_duration: Duration::from_secs(60),
445            allowed_lateness: Duration::from_secs(10),
446            ..Default::default()
447        };
448        let mut proc = SessionWindowProcessor::new(cfg);
449        proc.process(event(100, 0)).expect("ok");
450        // 5 s late ≤ 10 s allowed_lateness → accepted.
451        proc.process(event(95, 1)).expect("within lateness ok");
452        // 20 s late > 10 s → rejected.
453        let err = proc
454            .process(event(80, 2))
455            .expect_err("beyond lateness should error");
456        assert!(matches!(err, StreamingError::InvalidState(_)));
457    }
458
459    #[test]
460    fn test_equal_timestamps_accepted() {
461        // Non-decreasing includes equal timestamps.
462        let cfg = SessionWindowConfig::default();
463        let mut proc = SessionWindowProcessor::new(cfg);
464        proc.process(event(100, 0)).expect("ok");
465        proc.process(event(100, 1)).expect("equal ts ok");
466    }
467
468    #[test]
469    fn test_pending_event_count_resets_after_flush() {
470        let cfg = SessionWindowConfig::default();
471        let mut proc = SessionWindowProcessor::new(cfg);
472        proc.process(event(0, 0)).expect("ok");
473        proc.process(event(1, 1)).expect("ok");
474        assert_eq!(proc.pending_event_count(), 2);
475        proc.flush();
476        assert_eq!(proc.pending_event_count(), 0);
477    }
478}