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}