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_codec::key::{
12	encode_u64,
13	encoded::{EncodedKey, IntoEncodedKey},
14};
15use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
16use serde::{Deserialize, Serialize, de::DeserializeOwned};
17
18use crate::window::{
19	accumulator::WindowAccumulator,
20	engine::{
21		AccumulatorEvent, EmitKind, GroupMeta, LatePolicy, MetaKey, WindowResult, config::WindowEngineConfig,
22		expiry_due_range, expiry_key, meta_key_for,
23	},
24	span::{Slot, WindowSpan},
25	state::StateCache,
26	store::WindowStore,
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> 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	pub fn new(config: WindowEngineConfig) -> Self {
98		Self {
99			accumulators: StateCache::<RowNumber, Accumulator>::new(config.state_cache_capacity()),
100			meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
101			late_policy: config.late_policy(),
102			_pd: PhantomData,
103		}
104	}
105
106	pub fn apply<S, K, NA>(
107		&mut self,
108		store: &mut S,
109		buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
110		row_key: K,
111		new_accumulator: NA,
112	) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
113	where
114		S: WindowStore,
115		K: Fn(&G, C) -> EncodedKey,
116		NA: Fn() -> Accumulator,
117	{
118		if buckets.is_empty() {
119			return Ok(Vec::new());
120		}
121		let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
122		let slot_resolved = self.resolve_survivor_rows(store, &buckets, &meta_loaded, &row_key)?;
123		let results =
124			self.apply_events(store, buckets, slot_resolved, &mut meta_loaded, &row_key, &new_accumulator)?;
125		self.persist_meta(store, meta_loaded)?;
126		Ok(results)
127	}
128
129	pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
130		self.accumulators.flush(store)?;
131		self.meta.flush(store)?;
132		Ok(())
133	}
134
135	fn warm_and_load_meta<S: WindowStore>(
136		&mut self,
137		store: &mut S,
138		buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
139	) -> Result<MetaLoaded<G, C>> {
140		let meta_keys: Vec<MetaKey> = buckets
141			.keys()
142			.map(|(group, _)| group)
143			.collect::<BTreeSet<_>>()
144			.into_iter()
145			.map(meta_key_for)
146			.collect();
147		self.meta.warm(store, &meta_keys)?;
148
149		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
150		for (group, _) in buckets.keys() {
151			if !meta_loaded.contains_key(group) {
152				let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
153				meta_loaded.insert(group.clone(), m);
154			}
155		}
156		Ok(meta_loaded)
157	}
158
159	fn resolve_survivor_rows<S, K>(
160		&mut self,
161		store: &mut S,
162		buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
163		meta_loaded: &MetaLoaded<G, C>,
164		row_key: &K,
165	) -> Result<SlotResolved>
166	where
167		S: WindowStore,
168		K: Fn(&G, C) -> EncodedKey,
169	{
170		let mut survivor_keys: Vec<EncodedKey> = Vec::new();
171		let mut slot_survives: Vec<bool> = Vec::with_capacity(buckets.len());
172		for (group, span) in buckets.keys() {
173			let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
174			let survives = initial_high_water.is_none_or(|hw| span.start >= hw);
175			slot_survives.push(survives);
176			if survives {
177				survivor_keys.push(row_key(group, span.start));
178			}
179		}
180		let resolved_rows = store.get_or_create_row_numbers(&survivor_keys)?;
181		reifydb_assertions! {
182			let survivors = survivor_keys.len();
183			let resolved = resolved_rows.len();
184			assert!(
185				resolved == survivors,
186				"get_or_create_row_numbers must return exactly one row per survivor key; a short batch would \
187				 leave a surviving slot with no resolved row, so the slot_resolved zip below pairs it with None \
188				 and apply_events silently re-creates a fresh row instead of reusing the existing window \
189				 state, double-counting it (survivor_keys={survivors}, resolved_rows={resolved})"
190			);
191		}
192		let accumulator_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
193		self.accumulators.warm(store, &accumulator_keys)?;
194		let mut resolved_rows = resolved_rows.into_iter();
195		let slot_resolved: SlotResolved = slot_survives
196			.into_iter()
197			.map(|survives| {
198				if survives {
199					resolved_rows.next()
200				} else {
201					None
202				}
203			})
204			.collect();
205		Ok(slot_resolved)
206	}
207
208	fn apply_events<S, K, NA>(
209		&mut self,
210		store: &mut S,
211		buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
212		slot_resolved: SlotResolved,
213		meta_loaded: &mut MetaLoaded<G, C>,
214		row_key: &K,
215		new_accumulator: &NA,
216	) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
217	where
218		S: WindowStore,
219		K: Fn(&G, C) -> EncodedKey,
220		NA: Fn() -> Accumulator,
221	{
222		let mut results: Vec<WindowResult<G, C, Accumulator::Output>> = Vec::new();
223
224		for (((group, span), events), slot_pre) in buckets.into_iter().zip(slot_resolved) {
225			let entry = meta_loaded.entry(group.clone()).or_default();
226			let late = matches!(entry.high_water, Some(hw) if span.start < hw);
227			let drop_late_adds = late && matches!(self.late_policy, LatePolicy::Drop);
228			if drop_late_adds && !events.iter().any(|e| matches!(e, AccumulatorEvent::Remove(_))) {
229				continue;
230			}
231			match entry.high_water {
232				Some(hw) if span.start > hw => entry.high_water = Some(span.start),
233				None => entry.high_water = Some(span.start),
234				_ => {}
235			}
236
237			let (row_number, is_new) = match slot_pre {
238				Some(resolved) => resolved,
239				None => {
240					let key = row_key(&group, span.start);
241					store.get_or_create_row_number(&key)?
242				}
243			};
244
245			let mut accumulator: Accumulator =
246				self.accumulators.get(store, &row_number)?.unwrap_or_else(new_accumulator);
247			let was_empty_before = accumulator.is_empty();
248			let prior = if was_empty_before {
249				None
250			} else {
251				accumulator.finalize()
252			};
253
254			for event in events {
255				match event {
256					AccumulatorEvent::Add(c) => {
257						if drop_late_adds {
258							continue;
259						}
260						accumulator.add(&c);
261					}
262					AccumulatorEvent::Remove(c) => {
263						if accumulator.is_empty() {
264							continue;
265						}
266						if drop_late_adds {
267							accumulator.remove_if_present(&c);
268						} else {
269							accumulator.remove(&c);
270						}
271					}
272				}
273			}
274
275			let value = accumulator.finalize();
276			self.accumulators.put(store, &row_number, accumulator)?;
277
278			match value {
279				Some(value) => {
280					let kind = if is_new || was_empty_before {
281						EmitKind::Insert
282					} else {
283						EmitKind::Update
284					};
285					results.push(WindowResult {
286						row_number,
287						group,
288						span,
289						value,
290						prior,
291						kind,
292					});
293				}
294				None => {
295					if let Some(p) = prior.clone() {
296						results.push(WindowResult {
297							row_number,
298							group,
299							span,
300							value: p,
301							prior,
302							kind: EmitKind::Remove,
303						});
304					}
305				}
306			}
307		}
308		Ok(results)
309	}
310
311	pub fn expire<S: WindowStore>(
312		&mut self,
313		store: &mut S,
314		threshold: u64,
315	) -> Result<Vec<ExpiredWindow<G, C, Accumulator::Output>>> {
316		let mut due: Vec<(EncodedKey, TumblingIndexEntry<G, C>)> = Vec::new();
317		store.internal_range_visit::<TumblingIndexEntry<G, C>>(
318			expiry_due_range(threshold),
319			&mut |key, entry| {
320				due.push((key, entry));
321				Ok(())
322			},
323		)?;
324
325		let mut out: Vec<ExpiredWindow<G, C, Accumulator::Output>> = Vec::new();
326		for (index_key, entry) in due {
327			let row_number = RowNumber(entry.row_number);
328			store.internal_drop(&index_key)?;
329			let value = self
330				.accumulators
331				.get(store, &row_number)?
332				.and_then(|accumulator| accumulator.finalize());
333			self.accumulators.remove(store, &row_number)?;
334			out.push(ExpiredWindow {
335				row_number,
336				group: entry.group,
337				window_start: entry.window_start,
338				value,
339			});
340		}
341		Ok(out)
342	}
343
344	fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
345		for (group, meta) in meta_loaded {
346			self.meta.set(store, &meta_key_for(&group), &meta)?;
347		}
348		Ok(())
349	}
350}
351
352#[cfg(test)]
353mod tests {
354	use std::collections::BTreeMap;
355
356	use reifydb_codec::key::encoded::EncodedKey;
357
358	use crate::window::{
359		engine::{
360			AccumulatorEvent, EmitKind, WindowResult,
361			config::WindowEngineConfig,
362			test_support::{MockStore, SumAccumulator},
363			tumbling::{TumblingBuckets, TumblingEngine, reindex_window},
364		},
365		span::WindowSpan,
366	};
367
368	fn test_config() -> WindowEngineConfig {
369		WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
370	}
371
372	fn row_key(group: &u32, window_start: u64) -> EncodedKey {
373		EncodedKey::builder().u32(*group).u64(window_start).build()
374	}
375
376	fn seed_window(store: &mut MockStore, window_start: u64, contribution: i64) -> WindowResult<u32, u64, i64> {
377		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
378		let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
379		buckets.insert(
380			(1u32, WindowSpan::new(window_start, window_start + 1)),
381			vec![AccumulatorEvent::Add(contribution)],
382		);
383		let mut results = engine.apply(store, buckets, row_key, SumAccumulator::default).expect("apply");
384		engine.flush(store).expect("flush");
385		results.pop().expect("one window")
386	}
387
388	#[test]
389	fn expire_returns_only_due_windows_and_clears_their_state() {
390		let mut store = MockStore::default();
391		// Two live windows; the face indexes each by its last_event_time (10 and 90).
392		let w0 = seed_window(&mut store, 0, 5);
393		reindex_window(&mut store, &w0.group, w0.span.start, w0.row_number, None, Some(10)).unwrap();
394		let w100 = seed_window(&mut store, 100, 7);
395		reindex_window(&mut store, &w100.group, w100.span.start, w100.row_number, None, Some(90)).unwrap();
396		assert_eq!(store.index_entry_count(), 2, "both live windows are indexed");
397
398		// Threshold 10: only the window whose expiry (10) is at/under the threshold is due.
399		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
400		let expired = engine.expire(&mut store, 10).unwrap();
401		engine.flush(&mut store).unwrap();
402		assert_eq!(expired.len(), 1, "exactly one window is due, not the whole population");
403		assert_eq!(expired[0].window_start, 0);
404		assert_eq!(expired[0].value, Some(5));
405		assert_eq!(store.index_entry_count(), 1, "the due window's index entry is gone, the other remains");
406
407		// The surviving window finalizes correctly once the threshold reaches it.
408		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
409		let later = engine.expire(&mut store, 1000).unwrap();
410		assert_eq!(later.len(), 1);
411		assert_eq!(later[0].window_start, 100);
412		assert_eq!(later[0].value, Some(7));
413		assert_eq!(store.index_entry_count(), 0);
414	}
415
416	#[test]
417	fn expire_threshold_is_inclusive() {
418		let mut store = MockStore::default();
419		let w = seed_window(&mut store, 0, 4);
420		reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(50)).unwrap();
421
422		// One below the expiry: not due, and the scan leaves the index intact.
423		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
424		assert!(engine.expire(&mut store, 49).unwrap().is_empty());
425		engine.flush(&mut store).unwrap();
426		assert_eq!(store.index_entry_count(), 1);
427
428		// Exactly at the expiry: due (the face folds the strict close boundary into the threshold).
429		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
430		assert_eq!(engine.expire(&mut store, 50).unwrap().len(), 1);
431	}
432
433	#[test]
434	fn reindex_rekeys_without_leaving_a_stale_entry() {
435		let mut store = MockStore::default();
436		let w = seed_window(&mut store, 0, 9);
437		// Index at 10, then a later event advances the window's expiry to 80.
438		reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(10)).unwrap();
439		reindex_window(&mut store, &w.group, w.span.start, w.row_number, Some(10), Some(80)).unwrap();
440		assert_eq!(store.index_entry_count(), 1, "re-keying must not leave the old entry behind");
441
442		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
443		assert!(engine.expire(&mut store, 10).unwrap().is_empty(), "no longer due at the old expiry");
444		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
445		assert_eq!(engine.expire(&mut store, 80).unwrap().len(), 1, "due at the new expiry");
446	}
447
448	#[test]
449	fn accumulator_survives_restart() {
450		// When a tumbling window empties under retraction it emits a terminal Remove carrying the value
451		// it last published; that value is the window accumulator's pre-batch finalize, read back from
452		// the store. Dropping the engine between the publish and the retraction (a restart) forces the
453		// accumulator to be reloaded from the store rather than served from the in-memory cache. It
454		// would fail if the accumulator failed to round-trip through the store (a serialization break,
455		// or a second Data cache colliding on the same RowNumber).
456		let mut store = MockStore::default();
457
458		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
459		let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
460		buckets.insert((1u32, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Add(5)]);
461		let published: Vec<WindowResult<u32, u64, i64>> =
462			engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
463		engine.flush(&mut store).unwrap();
464		assert_eq!(published.len(), 1);
465		assert!(matches!(published[0].kind, EmitKind::Insert));
466		assert_eq!(published[0].value, 5);
467
468		// Restart: a brand new engine with empty caches, forced to read the persisted accumulator back.
469		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
470		let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
471		buckets.insert((1u32, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Remove(5)]);
472		let withdrawn: Vec<WindowResult<u32, u64, i64>> =
473			engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
474		engine.flush(&mut store).unwrap();
475
476		assert_eq!(withdrawn.len(), 1, "emptying the window emits exactly one terminal diff");
477		assert!(
478			matches!(withdrawn[0].kind, EmitKind::Remove),
479			"the window emptied under retraction, so the last published row must be withdrawn"
480		);
481		assert_eq!(withdrawn[0].value, 5, "the withdrawn value is the reloaded pre-batch accumulator output");
482		assert_eq!(
483			withdrawn[0].row_number, published[0].row_number,
484			"the withdrawal targets the same row that was published"
485		);
486	}
487
488	#[test]
489	fn accumulator_survives_lru_eviction() {
490		// The other way a read reaches the store is LRU eviction, no restart needed: the accumulator
491		// cache holds only 8 windows, so more than that evicts the oldest and the next access re-reads
492		// it from the store. We publish 11 single-window groups so group 1 is evicted, flush, then
493		// retract group 1 and assert its accumulator is read back intact.
494		let mut store = MockStore::default();
495		let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
496
497		let mut published_group_1: Vec<WindowResult<u32, u64, i64>> = Vec::new();
498		for group in 1u32..=11u32 {
499			let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
500			buckets.insert((group, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Add(i64::from(group))]);
501			let out: Vec<WindowResult<u32, u64, i64>> =
502				engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
503			if group == 1 {
504				published_group_1 = out;
505			}
506		}
507		engine.flush(&mut store).unwrap();
508		assert_eq!(published_group_1.len(), 1);
509		assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
510		assert_eq!(published_group_1[0].value, 1);
511
512		// Group 1's window was published first and pushed out of the 8-slot cache by the later groups,
513		// so the same engine must re-read its accumulator from the store to apply this retraction.
514		let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
515		buckets.insert((1u32, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Remove(1)]);
516		let withdrawn: Vec<WindowResult<u32, u64, i64>> =
517			engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
518		engine.flush(&mut store).unwrap();
519
520		assert_eq!(withdrawn.len(), 1, "emptying the evicted window emits exactly one terminal diff");
521		assert!(
522			matches!(withdrawn[0].kind, EmitKind::Remove),
523			"the evicted window emptied under retraction, so the last published row must be withdrawn"
524		);
525		assert_eq!(withdrawn[0].value, 1, "the withdrawn value is the reloaded accumulator output for group 1");
526		assert_eq!(
527			withdrawn[0].row_number, published_group_1[0].row_number,
528			"the withdrawal targets the same row that was published for group 1"
529		);
530	}
531}