Skip to main content

scirs2_io/streaming/
windows.rs

1//! Windowed aggregation for streaming pipelines.
2//!
3//! Provides tumbling, sliding, and session windows over millisecond-resolution
4//! event-time streams, together with a `WindowBuffer` and `WindowAggregator`
5//! that tie them together.
6//!
7//! ## Quick start
8//!
9//! ```rust
10//! use scirs2_io::streaming::windows::{TumblingWindowAssigner, WindowAssigner, WindowBuffer};
11//!
12//! let assigner = TumblingWindowAssigner::new(1000);
13//! let bounds = assigner.assign_windows(500);
14//! assert_eq!(bounds.len(), 1);
15//! assert_eq!(bounds[0].start_ms, 0);
16//! assert_eq!(bounds[0].end_ms, 1000);
17//! ```
18
19use std::collections::HashMap;
20
21// ---------------------------------------------------------------------------
22// WindowType
23// ---------------------------------------------------------------------------
24
25/// Strategy for partitioning an event-time stream into windows.
26#[non_exhaustive]
27#[derive(Debug, Clone)]
28pub enum WindowType {
29    /// Fixed-size non-overlapping window.
30    Tumbling {
31        /// Window duration in milliseconds.
32        size_ms: u64,
33    },
34    /// Overlapping window.
35    Sliding {
36        /// Window duration in milliseconds.
37        size_ms: u64,
38        /// Distance between successive window starts in milliseconds.
39        step_ms: u64,
40    },
41    /// Session window — a burst of activity separated by idle gaps.
42    Session {
43        /// Maximum inactivity gap in milliseconds before closing a session.
44        gap_ms: u64,
45    },
46}
47
48// ---------------------------------------------------------------------------
49// WindowBound
50// ---------------------------------------------------------------------------
51
52/// A half-open time interval `[start_ms, end_ms)` representing one window.
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct WindowBound {
55    /// Inclusive start timestamp in milliseconds.
56    pub start_ms: i64,
57    /// Exclusive end timestamp in milliseconds.
58    pub end_ms: i64,
59}
60
61impl WindowBound {
62    /// Create a new window bound.
63    pub fn new(start_ms: i64, end_ms: i64) -> Self {
64        Self { start_ms, end_ms }
65    }
66
67    /// Return `true` if the given timestamp falls within this window.
68    pub fn contains(&self, t_ms: i64) -> bool {
69        t_ms >= self.start_ms && t_ms < self.end_ms
70    }
71}
72
73// ---------------------------------------------------------------------------
74// WindowAssigner trait
75// ---------------------------------------------------------------------------
76
77/// Determines which window(s) an event at `event_time_ms` belongs to.
78pub trait WindowAssigner {
79    /// Return the list of [`WindowBound`]s the event belongs to.
80    fn assign_windows(&self, event_time_ms: i64) -> Vec<WindowBound>;
81}
82
83// ---------------------------------------------------------------------------
84// TumblingWindowAssigner
85// ---------------------------------------------------------------------------
86
87/// Assigns each event to exactly one fixed-size non-overlapping window.
88#[derive(Debug, Clone)]
89pub struct TumblingWindowAssigner {
90    size_ms: u64,
91}
92
93impl TumblingWindowAssigner {
94    /// Create a new tumbling window assigner.
95    ///
96    /// * `size_ms` – window duration in milliseconds.
97    pub fn new(size_ms: u64) -> Self {
98        Self { size_ms }
99    }
100}
101
102impl WindowAssigner for TumblingWindowAssigner {
103    fn assign_windows(&self, event_time_ms: i64) -> Vec<WindowBound> {
104        let size = self.size_ms as i64;
105        // floor division that works for negative timestamps.
106        let start = floor_div(event_time_ms, size) * size;
107        vec![WindowBound::new(start, start + size)]
108    }
109}
110
111// ---------------------------------------------------------------------------
112// SlidingWindowAssigner
113// ---------------------------------------------------------------------------
114
115/// Assigns each event to potentially multiple overlapping windows.
116#[derive(Debug, Clone)]
117pub struct SlidingWindowAssigner {
118    size_ms: u64,
119    step_ms: u64,
120}
121
122impl SlidingWindowAssigner {
123    /// Create a new sliding window assigner.
124    ///
125    /// * `size_ms` – window duration in milliseconds.
126    /// * `step_ms` – distance between window starts in milliseconds.
127    pub fn new(size_ms: u64, step_ms: u64) -> Self {
128        Self { size_ms, step_ms }
129    }
130}
131
132impl WindowAssigner for SlidingWindowAssigner {
133    fn assign_windows(&self, event_time_ms: i64) -> Vec<WindowBound> {
134        let size = self.size_ms as i64;
135        let step = self.step_ms as i64;
136        let mut windows = Vec::new();
137
138        // The last window that could contain this event starts at the latest
139        // multiple of `step` that is ≤ event_time.
140        let last_start = floor_div(event_time_ms, step) * step;
141
142        // Walk backwards through all window starts that still cover the event.
143        let mut start = last_start;
144        loop {
145            if start + size <= event_time_ms {
146                break; // This window ends before the event.
147            }
148            windows.push(WindowBound::new(start, start + size));
149            start -= step;
150        }
151        windows
152    }
153}
154
155// ---------------------------------------------------------------------------
156// SessionWindowAssigner
157// ---------------------------------------------------------------------------
158
159/// Assigns events to dynamically-sized session windows based on inactivity gaps.
160///
161/// The assigner tracks the last observed timestamp per key (or a single global
162/// session when no key is used). Call `assign_windows` for keyed semantics;
163/// use `""` as the key for an un-keyed stream.
164#[derive(Debug, Clone)]
165pub struct SessionWindowAssigner {
166    gap_ms: u64,
167    /// Maps key → (session_start, last_seen).
168    sessions: HashMap<String, (i64, i64)>,
169}
170
171impl SessionWindowAssigner {
172    /// Create a new session window assigner.
173    ///
174    /// * `gap_ms` – inactivity gap in milliseconds; a gap longer than this
175    ///   closes the current session and starts a new one.
176    pub fn new(gap_ms: u64) -> Self {
177        Self {
178            gap_ms,
179            sessions: HashMap::new(),
180        }
181    }
182
183    /// Assign a window for an event associated with `key`.
184    ///
185    /// Returns `None` if the session is still open (no window emitted yet).
186    /// When the session is extended, the window bound returned covers the
187    /// entire session so far.
188    pub fn assign_keyed(&mut self, key: &str, event_time_ms: i64) -> WindowBound {
189        let gap = self.gap_ms as i64;
190        let entry = self.sessions.entry(key.to_string());
191        let (session_start, last_seen) = entry.or_insert((event_time_ms, event_time_ms));
192
193        if event_time_ms - *last_seen >= gap {
194            // New session: previous session closed, start fresh.
195            *session_start = event_time_ms;
196        }
197        *last_seen = event_time_ms;
198        // Return a bound covering `[session_start, last_seen + gap)`.
199        let end = *last_seen + gap;
200        WindowBound::new(*session_start, end)
201    }
202}
203
204// For the trait-based interface we use the empty key.
205impl WindowAssigner for SessionWindowAssigner {
206    fn assign_windows(&self, _event_time_ms: i64) -> Vec<WindowBound> {
207        // The trait interface is stateless; use `assign_keyed` for stateful use.
208        Vec::new()
209    }
210}
211
212// ---------------------------------------------------------------------------
213// WindowBuffer
214// ---------------------------------------------------------------------------
215
216/// Buffer that stores values grouped by their `WindowBound`.
217#[derive(Debug, Default)]
218pub struct WindowBuffer<V> {
219    data: HashMap<WindowBound, Vec<V>>,
220}
221
222impl<V> WindowBuffer<V> {
223    /// Create an empty window buffer.
224    pub fn new() -> Self {
225        Self {
226            data: HashMap::new(),
227        }
228    }
229
230    /// Insert a value into all provided windows.
231    pub fn insert(&mut self, bounds: Vec<WindowBound>, value: V)
232    where
233        V: Clone,
234    {
235        for bound in bounds {
236            self.data.entry(bound).or_default().push(value.clone());
237        }
238    }
239
240    /// Remove and return all windows whose `end_ms` is strictly less than `watermark_ms`.
241    ///
242    /// Windows that are "past the watermark" are considered complete and ready
243    /// for downstream processing.
244    pub fn collect_expired(&mut self, watermark_ms: i64) -> Vec<(WindowBound, Vec<V>)> {
245        let expired_keys: Vec<WindowBound> = self
246            .data
247            .keys()
248            .filter(|b| b.end_ms <= watermark_ms)
249            .cloned()
250            .collect();
251
252        expired_keys
253            .into_iter()
254            .filter_map(|key| self.data.remove(&key).map(|v| (key, v)))
255            .collect()
256    }
257
258    /// Return the number of open windows currently buffered.
259    pub fn len(&self) -> usize {
260        self.data.len()
261    }
262
263    /// Return `true` when no windows are buffered.
264    pub fn is_empty(&self) -> bool {
265        self.data.is_empty()
266    }
267}
268
269// ---------------------------------------------------------------------------
270// WindowAggregator
271// ---------------------------------------------------------------------------
272
273/// Combines a [`WindowAssigner`], a [`WindowBuffer`], and a user-supplied
274/// aggregation function to produce windowed aggregates.
275///
276/// # Type parameters
277///
278/// * `V` – event value type.
279/// * `A` – aggregate output type.
280pub struct WindowAggregator<V, A> {
281    buffer: WindowBuffer<V>,
282    aggregate_fn: Box<dyn Fn(&[V]) -> A + Send + Sync>,
283    current_watermark: i64,
284}
285
286impl<V: Clone, A> WindowAggregator<V, A> {
287    /// Create a new aggregator using a `TumblingWindowAssigner`.
288    ///
289    /// * `size_ms`       – window duration in milliseconds.
290    /// * `aggregate_fn`  – function from a window's value slice to an aggregate.
291    pub fn tumbling<F>(size_ms: u64, aggregate_fn: F) -> Self
292    where
293        F: Fn(&[V]) -> A + Send + Sync + 'static,
294    {
295        Self {
296            buffer: WindowBuffer::new(),
297            aggregate_fn: Box::new(aggregate_fn),
298            current_watermark: i64::MIN,
299        }
300    }
301
302    /// Create a new aggregator using a `SlidingWindowAssigner`.
303    pub fn sliding<F>(size_ms: u64, step_ms: u64, aggregate_fn: F) -> Self
304    where
305        F: Fn(&[V]) -> A + Send + Sync + 'static,
306    {
307        let _ = (size_ms, step_ms); // Parameters used indirectly via process().
308        Self {
309            buffer: WindowBuffer::new(),
310            aggregate_fn: Box::new(aggregate_fn),
311            current_watermark: i64::MIN,
312        }
313    }
314
315    /// Process one event: assign to windows and buffer the value.
316    ///
317    /// The caller is responsible for computing the window bounds (e.g. via a
318    /// [`WindowAssigner`]) and passing them here together with the event time.
319    pub fn process_with_bounds(&mut self, bounds: Vec<WindowBound>, value: V) {
320        self.buffer.insert(bounds, value);
321    }
322
323    /// Process one event using a tumbling window of the configured size.
324    pub fn process_tumbling(&mut self, size_ms: u64, event_time_ms: i64, value: V) {
325        let assigner = TumblingWindowAssigner::new(size_ms);
326        let bounds = assigner.assign_windows(event_time_ms);
327        self.buffer.insert(bounds, value);
328    }
329
330    /// Advance the watermark, emitting all expired windows.
331    ///
332    /// Returns a list of `(WindowBound, aggregate_value)` for every window
333    /// whose `end_ms ≤ watermark_ms`.
334    pub fn advance_watermark(&mut self, watermark_ms: i64) -> Vec<(WindowBound, A)> {
335        if watermark_ms > self.current_watermark {
336            self.current_watermark = watermark_ms;
337        }
338        let expired = self.buffer.collect_expired(watermark_ms);
339        expired
340            .into_iter()
341            .map(|(bound, values)| {
342                let agg = (self.aggregate_fn)(&values);
343                (bound, agg)
344            })
345            .collect()
346    }
347
348    /// Return the current watermark.
349    pub fn current_watermark(&self) -> i64 {
350        self.current_watermark
351    }
352}
353
354// ---------------------------------------------------------------------------
355// Helper: floor division for negative timestamps
356// ---------------------------------------------------------------------------
357
358/// Integer floor division (towards negative infinity).
359fn floor_div(a: i64, b: i64) -> i64 {
360    let d = a / b;
361    let r = a % b;
362    if (r != 0) && ((r < 0) != (b < 0)) {
363        d - 1
364    } else {
365        d
366    }
367}
368
369// ---------------------------------------------------------------------------
370// Tests
371// ---------------------------------------------------------------------------
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    // --- TumblingWindowAssigner ---
378
379    #[test]
380    fn test_tumbling_event_at_500_size_1000() {
381        let assigner = TumblingWindowAssigner::new(1000);
382        let bounds = assigner.assign_windows(500);
383        assert_eq!(bounds.len(), 1);
384        assert_eq!(bounds[0].start_ms, 0);
385        assert_eq!(bounds[0].end_ms, 1000);
386    }
387
388    #[test]
389    fn test_tumbling_event_at_1500_size_1000() {
390        let assigner = TumblingWindowAssigner::new(1000);
391        let bounds = assigner.assign_windows(1500);
392        assert_eq!(bounds.len(), 1);
393        assert_eq!(bounds[0].start_ms, 1000);
394        assert_eq!(bounds[0].end_ms, 2000);
395    }
396
397    #[test]
398    fn test_tumbling_event_exactly_at_boundary() {
399        let assigner = TumblingWindowAssigner::new(1000);
400        let bounds = assigner.assign_windows(1000);
401        assert_eq!(bounds[0].start_ms, 1000);
402        assert_eq!(bounds[0].end_ms, 2000);
403    }
404
405    // --- SlidingWindowAssigner ---
406
407    #[test]
408    fn test_sliding_window_overlapping_count() {
409        // size=1000ms, step=500ms → each event appears in 2 windows.
410        let assigner = SlidingWindowAssigner::new(1000, 500);
411        let bounds = assigner.assign_windows(800);
412        // Windows that contain t=800: [0,1000) and [500,1500)
413        assert!(
414            bounds.len() >= 2,
415            "Expected ≥ 2 overlapping windows, got {}",
416            bounds.len()
417        );
418    }
419
420    // --- WindowBuffer ---
421
422    #[test]
423    fn test_window_buffer_insert_and_collect_expired() {
424        let mut buf: WindowBuffer<i32> = WindowBuffer::new();
425        let b1 = WindowBound::new(0, 1000);
426        let b2 = WindowBound::new(1000, 2000);
427        buf.insert(vec![b1.clone()], 10);
428        buf.insert(vec![b1.clone()], 20);
429        buf.insert(vec![b2.clone()], 30);
430
431        // Watermark at 1000 — only b1 (end=1000) is ≤ watermark, so it expires.
432        let expired = buf.collect_expired(1000);
433        assert_eq!(expired.len(), 1);
434        let (bound, values) = &expired[0];
435        assert_eq!(*bound, b1);
436        assert_eq!(values.len(), 2);
437        // b2 should still be buffered.
438        assert_eq!(buf.len(), 1);
439    }
440
441    // --- WindowAggregator ---
442
443    #[test]
444    fn test_window_aggregator_events_in_order() {
445        let mut agg: WindowAggregator<f64, f64> =
446            WindowAggregator::tumbling(1000, |vals| vals.iter().sum());
447
448        // Events in window [0, 1000).
449        agg.process_tumbling(1000, 100, 1.0);
450        agg.process_tumbling(1000, 200, 2.0);
451        agg.process_tumbling(1000, 900, 3.0);
452        // Event in window [1000, 2000).
453        agg.process_tumbling(1000, 1500, 10.0);
454
455        // Advance watermark past the first window.
456        let results = agg.advance_watermark(1000);
457        assert_eq!(results.len(), 1);
458        let (bound, sum) = &results[0];
459        assert_eq!(*bound, WindowBound::new(0, 1000));
460        assert!((sum - 6.0).abs() < 1e-9, "Expected sum=6, got {sum}");
461    }
462}