Skip to main content

reifydb_core/window/engine/
tumbling.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::{BTreeMap, BTreeSet, HashMap},
6	fmt::Debug,
7	hash::Hash,
8	marker::PhantomData,
9};
10
11use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13
14use crate::{
15	encoded::key::{EncodedKey, IntoEncodedKey},
16	util::encoding::keycode::encode_u64,
17	window::{
18		accumulator::WindowAccumulator,
19		engine::{
20			AccumulatorEvent, EmitKind, GroupMeta, LatePolicy, MetaKey, WindowResult, expiry_due_range,
21			expiry_key, meta_key_for,
22		},
23		span::{Slot, WindowSpan},
24		state::StateCache,
25		store::WindowStore,
26	},
27};
28
29pub type TumblingBuckets<G, C, Contribution> = BTreeMap<(G, WindowSpan<C>), Vec<AccumulatorEvent<Contribution>>>;
30
31type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
32type SlotResolved = Vec<Option<(RowNumber, bool)>>;
33
34pub struct ExpiredWindow<G, C, Output> {
35	pub row_number: RowNumber,
36	pub group: G,
37	pub window_start: C,
38	pub value: Option<Output>,
39}
40
41#[derive(Serialize, Deserialize)]
42#[serde(bound(serialize = "G: Serialize, C: Serialize", deserialize = "G: DeserializeOwned, C: DeserializeOwned"))]
43struct TumblingIndexEntry<G, C> {
44	group: G,
45	window_start: C,
46	row_number: u64,
47}
48
49pub fn reindex_window<S, G, C>(
50	store: &mut S,
51	group: &G,
52	window_start: C,
53	row_number: RowNumber,
54	prior: Option<u64>,
55	new: Option<u64>,
56) -> Result<()>
57where
58	S: WindowStore,
59	G: Clone + Serialize,
60	C: Slot + Serialize,
61	for<'a> &'a G: IntoEncodedKey,
62{
63	if prior == new {
64		return Ok(());
65	}
66	let suffix = encode_u64(window_start.order_key());
67	if let Some(old) = prior {
68		store.internal_drop(&expiry_key(old, group, &suffix))?;
69	}
70	if let Some(new) = new {
71		store.internal_set(
72			&expiry_key(new, group, &suffix),
73			&TumblingIndexEntry {
74				group: group.clone(),
75				window_start,
76				row_number: row_number.0,
77			},
78		)?;
79	}
80	Ok(())
81}
82
83pub struct TumblingEngine<G, C, Accumulator> {
84	accumulators: StateCache<RowNumber, Accumulator>,
85	meta: StateCache<MetaKey, GroupMeta<C>>,
86	late_policy: LatePolicy,
87	_pd: PhantomData<G>,
88}
89
90impl<G, C, Accumulator> Default for TumblingEngine<G, C, Accumulator>
91where
92	G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
93	C: Slot + Hash + Serialize + DeserializeOwned,
94	Accumulator: WindowAccumulator,
95	for<'a> &'a G: IntoEncodedKey,
96{
97	fn default() -> Self {
98		Self::new()
99	}
100}
101
102impl<G, C, Accumulator> TumblingEngine<G, C, Accumulator>
103where
104	G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
105	C: Slot + Hash + Serialize + DeserializeOwned,
106	Accumulator: WindowAccumulator,
107	for<'a> &'a G: IntoEncodedKey,
108{
109	pub fn new() -> Self {
110		Self::with_late_policy(LatePolicy::Drop)
111	}
112
113	pub fn with_late_policy(late_policy: LatePolicy) -> Self {
114		Self {
115			accumulators: StateCache::<RowNumber, Accumulator>::new(8),
116			meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(64),
117			late_policy,
118			_pd: PhantomData,
119		}
120	}
121
122	pub fn apply<S, K, NA>(
123		&mut self,
124		store: &mut S,
125		buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
126		row_key: K,
127		new_accumulator: NA,
128	) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
129	where
130		S: WindowStore,
131		K: Fn(&G, C) -> EncodedKey,
132		NA: Fn() -> Accumulator,
133	{
134		if buckets.is_empty() {
135			return Ok(Vec::new());
136		}
137		let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
138		let slot_resolved = self.resolve_survivor_rows(store, &buckets, &meta_loaded, &row_key)?;
139		let results =
140			self.apply_events(store, buckets, slot_resolved, &mut meta_loaded, &row_key, &new_accumulator)?;
141		self.persist_meta(store, meta_loaded)?;
142		Ok(results)
143	}
144
145	pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
146		self.accumulators.flush(store)?;
147		self.meta.flush(store)?;
148		Ok(())
149	}
150
151	fn warm_and_load_meta<S: WindowStore>(
152		&mut self,
153		store: &mut S,
154		buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
155	) -> Result<MetaLoaded<G, C>> {
156		let meta_keys: Vec<MetaKey> = buckets
157			.keys()
158			.map(|(group, _)| group)
159			.collect::<BTreeSet<_>>()
160			.into_iter()
161			.map(meta_key_for)
162			.collect();
163		self.meta.warm(store, &meta_keys)?;
164
165		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
166		for (group, _) in buckets.keys() {
167			if !meta_loaded.contains_key(group) {
168				let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
169				meta_loaded.insert(group.clone(), m);
170			}
171		}
172		Ok(meta_loaded)
173	}
174
175	fn resolve_survivor_rows<S, K>(
176		&mut self,
177		store: &mut S,
178		buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
179		meta_loaded: &MetaLoaded<G, C>,
180		row_key: &K,
181	) -> Result<SlotResolved>
182	where
183		S: WindowStore,
184		K: Fn(&G, C) -> EncodedKey,
185	{
186		let mut survivor_keys: Vec<EncodedKey> = Vec::new();
187		let mut slot_survives: Vec<bool> = Vec::with_capacity(buckets.len());
188		for (group, span) in buckets.keys() {
189			let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
190			let survives = initial_high_water.is_none_or(|hw| span.start >= hw);
191			slot_survives.push(survives);
192			if survives {
193				survivor_keys.push(row_key(group, span.start));
194			}
195		}
196		let resolved_rows = store.get_or_create_row_numbers(&survivor_keys)?;
197		reifydb_assertions! {
198			let survivors = survivor_keys.len();
199			let resolved = resolved_rows.len();
200			assert!(
201				resolved == survivors,
202				"get_or_create_row_numbers must return exactly one row per survivor key; a short batch would \
203				 leave a surviving slot with no resolved row, so the slot_resolved zip below pairs it with None \
204				 and apply_events silently re-creates a fresh row instead of reusing the existing window \
205				 state, double-counting it (survivor_keys={survivors}, resolved_rows={resolved})"
206			);
207		}
208		let accumulator_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
209		self.accumulators.warm(store, &accumulator_keys)?;
210		let mut resolved_rows = resolved_rows.into_iter();
211		let slot_resolved: SlotResolved = slot_survives
212			.into_iter()
213			.map(|survives| {
214				if survives {
215					resolved_rows.next()
216				} else {
217					None
218				}
219			})
220			.collect();
221		Ok(slot_resolved)
222	}
223
224	fn apply_events<S, K, NA>(
225		&mut self,
226		store: &mut S,
227		buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
228		slot_resolved: SlotResolved,
229		meta_loaded: &mut MetaLoaded<G, C>,
230		row_key: &K,
231		new_accumulator: &NA,
232	) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
233	where
234		S: WindowStore,
235		K: Fn(&G, C) -> EncodedKey,
236		NA: Fn() -> Accumulator,
237	{
238		let mut results: Vec<WindowResult<G, C, Accumulator::Output>> = Vec::new();
239
240		for (((group, span), events), slot_pre) in buckets.into_iter().zip(slot_resolved) {
241			let entry = meta_loaded.entry(group.clone()).or_default();
242			match entry.high_water {
243				Some(hw) if span.start < hw => {
244					if matches!(self.late_policy, LatePolicy::Drop) {
245						continue;
246					}
247				}
248				Some(hw) if span.start > hw => entry.high_water = Some(span.start),
249				Some(_) => {}
250				None => entry.high_water = Some(span.start),
251			}
252
253			let (row_number, is_new) = match slot_pre {
254				Some(resolved) => resolved,
255				None => {
256					let key = row_key(&group, span.start);
257					store.get_or_create_row_number(&key)?
258				}
259			};
260
261			let mut accumulator: Accumulator =
262				self.accumulators.get(store, &row_number)?.unwrap_or_else(new_accumulator);
263			let was_empty_before = accumulator.is_empty();
264			let prior = if was_empty_before {
265				None
266			} else {
267				accumulator.finalize()
268			};
269
270			for event in events {
271				match event {
272					AccumulatorEvent::Add(c) => accumulator.add(&c),
273					AccumulatorEvent::Remove(c) => accumulator.remove(&c),
274				}
275			}
276
277			let value = accumulator.finalize();
278			self.accumulators.put(store, &row_number, accumulator)?;
279
280			match value {
281				Some(value) => {
282					let kind = if is_new || was_empty_before {
283						EmitKind::Insert
284					} else {
285						EmitKind::Update
286					};
287					results.push(WindowResult {
288						row_number,
289						group,
290						span,
291						value,
292						prior,
293						kind,
294					});
295				}
296				None => {
297					if let Some(p) = prior.clone() {
298						results.push(WindowResult {
299							row_number,
300							group,
301							span,
302							value: p,
303							prior,
304							kind: EmitKind::Remove,
305						});
306					}
307				}
308			}
309		}
310		Ok(results)
311	}
312
313	pub fn expire<S: WindowStore>(
314		&mut self,
315		store: &mut S,
316		threshold: u64,
317	) -> Result<Vec<ExpiredWindow<G, C, Accumulator::Output>>> {
318		let mut due: Vec<(EncodedKey, TumblingIndexEntry<G, C>)> = Vec::new();
319		store.internal_range_visit::<TumblingIndexEntry<G, C>>(
320			expiry_due_range(threshold),
321			&mut |key, entry| {
322				due.push((key, entry));
323				Ok(())
324			},
325		)?;
326
327		let mut out: Vec<ExpiredWindow<G, C, Accumulator::Output>> = Vec::new();
328		for (index_key, entry) in due {
329			let row_number = RowNumber(entry.row_number);
330			store.internal_drop(&index_key)?;
331			let value = self
332				.accumulators
333				.get(store, &row_number)?
334				.and_then(|accumulator| accumulator.finalize());
335			self.accumulators.remove(store, &row_number)?;
336			out.push(ExpiredWindow {
337				row_number,
338				group: entry.group,
339				window_start: entry.window_start,
340				value,
341			});
342		}
343		Ok(out)
344	}
345
346	fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
347		for (group, meta) in meta_loaded {
348			self.meta.set(store, &meta_key_for(&group), &meta)?;
349		}
350		Ok(())
351	}
352}
353
354#[cfg(test)]
355mod tests {
356	use std::collections::BTreeMap;
357
358	use crate::{
359		encoded::key::EncodedKey,
360		window::{
361			engine::{
362				AccumulatorEvent, WindowResult,
363				test_support::{MockStore, SumAccumulator},
364				tumbling::{TumblingBuckets, TumblingEngine, reindex_window},
365			},
366			span::WindowSpan,
367		},
368	};
369
370	fn row_key(group: &u32, window_start: u64) -> EncodedKey {
371		EncodedKey::builder().u32(*group).u64(window_start).build()
372	}
373
374	fn seed_window(store: &mut MockStore, window_start: u64, contribution: i64) -> WindowResult<u32, u64, i64> {
375		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
376		let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
377		buckets.insert(
378			(1u32, WindowSpan::new(window_start, window_start + 1)),
379			vec![AccumulatorEvent::Add(contribution)],
380		);
381		let mut results = engine.apply(store, buckets, row_key, SumAccumulator::default).expect("apply");
382		engine.flush(store).expect("flush");
383		results.pop().expect("one window")
384	}
385
386	#[test]
387	fn expire_returns_only_due_windows_and_clears_their_state() {
388		let mut store = MockStore::default();
389		// Two live windows; the face indexes each by its last_event_time (10 and 90).
390		let w0 = seed_window(&mut store, 0, 5);
391		reindex_window(&mut store, &w0.group, w0.span.start, w0.row_number, None, Some(10)).unwrap();
392		let w100 = seed_window(&mut store, 100, 7);
393		reindex_window(&mut store, &w100.group, w100.span.start, w100.row_number, None, Some(90)).unwrap();
394		assert_eq!(store.index_entry_count(), 2, "both live windows are indexed");
395
396		// Threshold 10: only the window whose expiry (10) is at/under the threshold is due.
397		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
398		let expired = engine.expire(&mut store, 10).unwrap();
399		engine.flush(&mut store).unwrap();
400		assert_eq!(expired.len(), 1, "exactly one window is due, not the whole population");
401		assert_eq!(expired[0].window_start, 0);
402		assert_eq!(expired[0].value, Some(5));
403		assert_eq!(store.index_entry_count(), 1, "the due window's index entry is gone, the other remains");
404
405		// The surviving window finalizes correctly once the threshold reaches it.
406		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
407		let later = engine.expire(&mut store, 1000).unwrap();
408		assert_eq!(later.len(), 1);
409		assert_eq!(later[0].window_start, 100);
410		assert_eq!(later[0].value, Some(7));
411		assert_eq!(store.index_entry_count(), 0);
412	}
413
414	#[test]
415	fn expire_threshold_is_inclusive() {
416		let mut store = MockStore::default();
417		let w = seed_window(&mut store, 0, 4);
418		reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(50)).unwrap();
419
420		// One below the expiry: not due, and the scan leaves the index intact.
421		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
422		assert!(engine.expire(&mut store, 49).unwrap().is_empty());
423		engine.flush(&mut store).unwrap();
424		assert_eq!(store.index_entry_count(), 1);
425
426		// Exactly at the expiry: due (the face folds the strict close boundary into the threshold).
427		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
428		assert_eq!(engine.expire(&mut store, 50).unwrap().len(), 1);
429	}
430
431	#[test]
432	fn reindex_rekeys_without_leaving_a_stale_entry() {
433		let mut store = MockStore::default();
434		let w = seed_window(&mut store, 0, 9);
435		// Index at 10, then a later event advances the window's expiry to 80.
436		reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(10)).unwrap();
437		reindex_window(&mut store, &w.group, w.span.start, w.row_number, Some(10), Some(80)).unwrap();
438		assert_eq!(store.index_entry_count(), 1, "re-keying must not leave the old entry behind");
439
440		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
441		assert!(engine.expire(&mut store, 10).unwrap().is_empty(), "no longer due at the old expiry");
442		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
443		assert_eq!(engine.expire(&mut store, 80).unwrap().len(), 1, "due at the new expiry");
444	}
445}