Skip to main content

reifydb_flow/window/kind/
sliding.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use reifydb_value::value::{datetime::DateTime, duration::Duration};
5
6use crate::{
7	operator::state::seal::coord::Coord,
8	window::{
9		coord::{EventCoord, OrdinalCoord, RowSpan},
10		kind::ordinal_window_span,
11		span::WindowSpan,
12	},
13};
14
15fn fits(size: u64, slide: u64) -> Option<(u64, u64)> {
16	(size > 0 && slide > 0 && slide < size).then_some((size, slide))
17}
18
19fn fits_span(size: Duration, slide: Duration) -> Option<(Duration, Duration)> {
20	(size.is_positive() && slide.is_positive() && slide < size).then_some((size, slide))
21}
22
23pub struct SlidingOverTime {
24	size: Duration,
25	slide: Duration,
26}
27
28impl SlidingOverTime {
29	pub fn by_duration(size: Duration, slide: Duration) -> Option<Self> {
30		let (size, slide) = fits_span(size, slide)?;
31		Some(Self {
32			size,
33			slide,
34		})
35	}
36
37	pub fn span(&self, anchor: u64) -> WindowSpan<DateTime> {
38		let start = <DateTime as Coord>::from_order(anchor);
39		WindowSpan::new(start, start.saturating_add(self.size))
40	}
41
42	pub fn anchors(&self, coord: EventCoord) -> Vec<u64> {
43		let instant = coord.at();
44		let mut start = instant.saturating_sub(self.size).floor_to(self.slide);
45		let mut anchors = Vec::new();
46		while start <= instant {
47			if instant < start.saturating_add(self.size) {
48				anchors.push(start.to_order());
49			}
50			start = start.saturating_add(self.slide);
51		}
52		anchors
53	}
54}
55
56pub struct SlidingOverRows {
57	size: RowSpan,
58	slide: RowSpan,
59}
60
61impl SlidingOverRows {
62	pub fn by_count(size: RowSpan, slide: RowSpan) -> Option<Self> {
63		let (size, slide) = fits(size.rows(), slide.rows())?;
64		Some(Self {
65			size: RowSpan::of(size),
66			slide: RowSpan::of(slide),
67		})
68	}
69
70	pub fn span(&self, anchor: u64) -> WindowSpan<DateTime> {
71		ordinal_window_span(anchor)
72	}
73
74	pub fn anchors(&self, coord: OrdinalCoord) -> Vec<u64> {
75		let row = coord.value() + 1;
76		let size = self.size.rows();
77		let slide = self.slide.rows();
78		let lowest = if row > size {
79			(row - size) / slide
80		} else {
81			0
82		};
83		let highest = (row - 1) / slide;
84		(lowest..=highest)
85			.filter(|window| {
86				let first = window * slide + 1;
87				row >= first && row < first + size
88			})
89			.collect()
90	}
91}
92
93#[cfg(test)]
94mod tests {
95	use super::*;
96	use crate::factory::coord::event_coord_at_millis;
97
98	fn ms(millis: u64) -> Duration {
99		Duration::from_milliseconds_const(millis as i64)
100	}
101
102	fn order(millis: u64) -> u64 {
103		DateTime::from_millis(millis).to_order()
104	}
105
106	fn timed() -> SlidingOverTime {
107		SlidingOverTime::by_duration(ms(1_000), ms(250)).expect("a 250ms slide fits inside a 1000ms window")
108	}
109
110	fn counted() -> SlidingOverRows {
111		SlidingOverRows::by_count(RowSpan::of(4), RowSpan::of(2))
112			.expect("a slide of 2 fits inside a window of 4")
113	}
114
115	#[test]
116	fn a_zero_slide_cannot_be_constructed_at_all() {
117		// Every anchor path divides by the slide. RQL rejects `slide >= size`, which lets `slide: 0`
118		// through, so a zero slide reaches the arithmetic and panics the whole flow from a plain
119		// user query.
120		assert!(SlidingOverTime::by_duration(ms(1_000), ms(0)).is_none());
121		assert!(SlidingOverRows::by_count(RowSpan::of(4), RowSpan::of(0)).is_none());
122	}
123
124	#[test]
125	fn a_slide_that_does_not_fit_inside_the_window_is_refused() {
126		// A slide at or above the size makes the windows disjoint or gapped - tumbling at best,
127		// row-dropping at worst. RQL rejects matched pairs; refusing here closes every other route.
128		assert!(SlidingOverTime::by_duration(ms(1_000), ms(1_000)).is_none());
129		assert!(SlidingOverRows::by_count(RowSpan::of(4), RowSpan::of(9)).is_none());
130		assert!(SlidingOverRows::by_count(RowSpan::of(0), RowSpan::of(0)).is_none());
131	}
132
133	#[test]
134	fn a_time_coordinate_lands_in_every_window_whose_span_still_covers_it() {
135		// One row contributes to several overlapping windows, and missing one under-counts that
136		// window forever. Size 1000 with slide 250 covers an instant with exactly four windows.
137		assert_eq!(
138			timed().anchors(event_coord_at_millis(5_000)),
139			vec![order(4_250), order(4_500), order(4_750), order(5_000)]
140		);
141	}
142
143	#[test]
144	fn the_earliest_instants_do_not_produce_windows_that_start_before_zero() {
145		// The low bound saturates because an instant inside the first window would otherwise
146		// underflow to near u64::MAX and iterate a range the size of the address space. The epoch is
147		// a real coordinate here: unstamped rows sit at exactly DateTime::default().
148		assert_eq!(timed().anchors(event_coord_at_millis(0)), vec![order(0)]);
149		assert_eq!(timed().anchors(event_coord_at_millis(250)), vec![order(0), order(250)]);
150	}
151
152	#[test]
153	fn a_row_ordinal_lands_in_every_window_still_accepting_rows() {
154		// The count domain is 1-based where the time domain is 0-based: window 0 holds rows 1..=size,
155		// so ordinal 0 is row 1. That offset shifts every count window by one row for the operator's
156		// whole life, and nothing downstream can tell.
157		assert_eq!(counted().anchors(OrdinalCoord::from_arrival_counter(0)), vec![0]);
158		assert_eq!(counted().anchors(OrdinalCoord::from_arrival_counter(3)), vec![0, 1]);
159		assert_eq!(counted().anchors(OrdinalCoord::from_arrival_counter(4)), vec![1, 2]);
160	}
161
162	#[test]
163	fn no_coordinate_ever_lands_in_zero_windows() {
164		// A row that maps to no anchor is silently dropped - it reaches no accumulator and is absent
165		// from every aggregate with nothing logged.
166		for instant in (0..4_000).step_by(37) {
167			assert!(
168				!timed().anchors(event_coord_at_millis(instant)).is_empty(),
169				"instant {instant} joined no window"
170			);
171		}
172		for ordinal in 0..500 {
173			assert!(
174				!counted().anchors(OrdinalCoord::from_arrival_counter(ordinal)).is_empty(),
175				"ordinal {ordinal} joined no window"
176			);
177		}
178	}
179
180	#[test]
181	fn a_time_window_span_covers_exactly_the_size_it_was_built_with() {
182		// The span the engine keys by must agree with the anchors() filter that decided membership
183		// (`instant < start + size`). A span one unit off keys the window under a boundary no row
184		// was admitted against, and the seal timer armed from it closes a different window.
185		let span = timed().span(order(4_250));
186
187		assert_eq!(span.start, DateTime::from_millis(4_250));
188		assert_eq!(span.end, DateTime::from_millis(5_250));
189	}
190}