Skip to main content

reifydb_flow/window/engine/
rolling_incremental.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	slice::from_ref,
10};
11
12use reifydb_codec::{
13	key::encoded::{EncodedKey, IntoEncodedKey},
14	row::operator::OperatorState,
15};
16use reifydb_core::{
17	key::operator_state::GroupId,
18	metrics::heap::HeapSize,
19	state::{cache::StateCache, store::StateStore},
20};
21use reifydb_value::{Result, reifydb_assertions};
22
23use crate::window::{
24	accumulator::WindowAccumulator,
25	engine::{
26		AccumulatorEvent, BatchMeta, EmitKind, GroupMeta, MetaKey, RunningKey, WindowStateKey,
27		config::WindowEngineConfig,
28		load_batch_meta, meta_key_for, persist_batch_meta,
29		rolling::{RollingBuckets, RollingBuffer, RollingResult},
30		sweep_stale_meta,
31	},
32	span::Slot,
33};
34
35type MetaLoaded<G, C> = HashMap<G, BatchMeta<C>>;
36type BufferRows<G> = HashMap<G, (GroupId, EncodedKey)>;
37
38struct GroupSlot<C, Accumulator, Running, Output> {
39	group_id: GroupId,
40	key: EncodedKey,
41	buffer: RollingBuffer<C, Accumulator>,
42	running: Running,
43	buffer_changed: bool,
44	prior_output: Option<Output>,
45}
46
47pub struct RollingIncrementalEngine<G, C, Accumulator, Running> {
48	buffers: StateCache<WindowStateKey, RollingBuffer<C, Accumulator>>,
49	running: StateCache<RunningKey, Running>,
50	meta: StateCache<MetaKey, GroupMeta<C>>,
51	meta_low_water: Option<u64>,
52	_pd: PhantomData<G>,
53}
54
55impl<G, C, Accumulator, Running> RollingIncrementalEngine<G, C, Accumulator, Running>
56where
57	G: Clone + Eq + Ord + Hash + Debug,
58	C: Slot + Hash,
59	Accumulator: WindowAccumulator,
60	Running: WindowAccumulator,
61	for<'a> &'a G: IntoEncodedKey,
62	C: HeapSize,
63	GroupMeta<C>: OperatorState,
64	RollingBuffer<C, Accumulator>: OperatorState + HeapSize,
65{
66	pub fn new(_config: WindowEngineConfig) -> Self {
67		Self {
68			buffers: StateCache::<WindowStateKey, RollingBuffer<C, Accumulator>>::new(),
69			running: StateCache::<RunningKey, Running>::new(),
70			meta: StateCache::<MetaKey, GroupMeta<C>>::new(),
71			meta_low_water: None,
72			_pd: PhantomData,
73		}
74	}
75
76	pub fn expire_meta(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<usize> {
77		sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
78	}
79
80	#[allow(clippy::too_many_arguments)]
81	pub fn apply<K, WC, CR, Output>(
82		&mut self,
83		store: &mut dyn StateStore,
84		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
85		capacity: usize,
86		row_key: K,
87		window_contribution: WC,
88		combine_running: CR,
89	) -> Result<Vec<RollingResult<G, Output>>>
90	where
91		K: Fn(&G) -> EncodedKey,
92		WC: Fn(&Accumulator::Output) -> Running::Contribution,
93		CR: Fn(&G, &Running, &Accumulator::Output, C) -> Option<Output>,
94	{
95		if buckets.is_empty() {
96			return Ok(Vec::new());
97		}
98		let mut meta_loaded = self.load_meta(store, &buckets)?;
99		let buffer_rows = self.resolve_buffer_rows(store, &buckets, &meta_loaded, &row_key)?;
100
101		let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Running, Output>> = BTreeMap::new();
102
103		for ((group, coord), events) in buckets {
104			let meta = meta_loaded.entry(group.clone()).or_default();
105
106			let slot = match group_slots.get_mut(&group) {
107				Some(s) => s,
108				None => {
109					let (group_id, key) = match buffer_rows.get(&group) {
110						Some(resolved) => resolved.clone(),
111						None => {
112							let key = row_key(&group);
113							let group_id = store
114								.intern_groups(from_ref(&key))?
115								.into_iter()
116								.next()
117								.expect("intern_groups answers every requested key")
118								.0;
119							(group_id, key)
120						}
121					};
122					let buffer: RollingBuffer<C, Accumulator> = self
123						.buffers
124						.get(store, &WindowStateKey::new(group_id, key.clone()))?
125						.unwrap_or_default();
126					let running: Running = self
127						.running
128						.get(store, &RunningKey::new(group_id, key.clone()))?
129						.unwrap_or_default();
130					let prior_output = match buffer.iter().next_back() {
131						Some((coord, accumulator)) => {
132							accumulator.finalize().and_then(|newest| {
133								combine_running(&group, &running, &newest, *coord)
134							})
135						}
136						None => None,
137					};
138					group_slots.insert(
139						group.clone(),
140						GroupSlot {
141							group_id,
142							key,
143							buffer,
144							running,
145							buffer_changed: false,
146							prior_output,
147						},
148					);
149					group_slots.get_mut(&group).expect("just inserted")
150				}
151			};
152
153			let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
154			let old_value = accumulator.finalize();
155			let mut touched = false;
156			for event in events {
157				match event {
158					AccumulatorEvent::Add(c) => {
159						accumulator.add(&c);
160						touched = true;
161					}
162					AccumulatorEvent::Remove(c) => {
163						if accumulator.is_empty() {
164							continue;
165						}
166						accumulator.remove(&c);
167						touched = true;
168					}
169				}
170			}
171			if !touched {
172				continue;
173			}
174			let new_value = accumulator.finalize();
175
176			if let Some(old) = &old_value {
177				slot.running.remove(&window_contribution(old));
178			}
179			if let Some(new) = &new_value {
180				slot.running.add(&window_contribution(new));
181			}
182
183			if !accumulator.is_empty() {
184				slot.buffer.insert(coord, accumulator);
185			}
186			while slot.buffer.len() > capacity {
187				if let Some((_, evicted)) = slot.buffer.pop_first()
188					&& let Some(value) = evicted.finalize()
189				{
190					slot.running.remove(&window_contribution(&value));
191				}
192			}
193			slot.buffer_changed = true;
194
195			meta.observe(coord);
196		}
197
198		let mut pairs: Vec<(GroupId, EncodedKey)> = Vec::new();
199		let mut pending: Vec<(G, Output, bool)> = Vec::new();
200		for (group, slot) in group_slots {
201			if !slot.buffer_changed {
202				continue;
203			}
204			let output = match slot.buffer.iter().next_back() {
205				Some((coord, accumulator)) => accumulator
206					.finalize()
207					.and_then(|newest| combine_running(&group, &slot.running, &newest, *coord)),
208				None => None,
209			};
210			self.buffers.put(store, &WindowStateKey::new(slot.group_id, slot.key.clone()), slot.buffer)?;
211			self.running.put(store, &RunningKey::new(slot.group_id, slot.key.clone()), slot.running)?;
212
213			if let Some(out) = output {
214				pairs.push((slot.group_id, slot.key));
215				pending.push((group, out, false));
216			} else if let Some(prior) = slot.prior_output {
217				pairs.push((slot.group_id, slot.key));
218				pending.push((group, prior, true));
219			}
220		}
221
222		let mut results: Vec<RollingResult<G, Output>> = Vec::with_capacity(pending.len());
223		if !pairs.is_empty() {
224			let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
225			for (((group, value, withdrawn), (group_id, key)), (row_number, is_new)) in
226				pending.into_iter().zip(pairs).zip(rows)
227			{
228				if withdrawn {
229					store.remove_row_number(group_id, &key)?;
230					results.push(RollingResult {
231						row_number,
232						group,
233						value,
234						prior: None,
235						kind: EmitKind::Remove,
236					});
237				} else {
238					let kind = if is_new {
239						EmitKind::Insert
240					} else {
241						EmitKind::Update
242					};
243					results.push(RollingResult {
244						row_number,
245						group,
246						value,
247						prior: None,
248						kind,
249					});
250				}
251			}
252		}
253		self.persist_meta(store, meta_loaded)?;
254		Ok(results)
255	}
256
257	fn load_meta(
258		&mut self,
259		store: &mut dyn StateStore,
260		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
261	) -> Result<MetaLoaded<G, C>> {
262		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
263		for (group, _) in buckets.keys() {
264			if !meta_loaded.contains_key(group) {
265				let batch = load_batch_meta(store, &mut self.meta, &meta_key_for(group))?;
266				meta_loaded.insert(group.clone(), batch);
267			}
268		}
269		Ok(meta_loaded)
270	}
271
272	fn resolve_buffer_rows<K>(
273		&mut self,
274		store: &mut dyn StateStore,
275		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
276		meta_loaded: &MetaLoaded<G, C>,
277		row_key: &K,
278	) -> Result<BufferRows<G>>
279	where
280		K: Fn(&G) -> EncodedKey,
281	{
282		let mut buffer_rows: BufferRows<G> = HashMap::new();
283		let mut resolve_order: Vec<G> = Vec::new();
284		let mut group_keys: Vec<EncodedKey> = Vec::new();
285		let mut seen: BTreeSet<G> = BTreeSet::new();
286		for (group, coord) in buckets.keys() {
287			let initial_high_water = meta_loaded.get(group).and_then(|m| m.initial);
288			if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
289				resolve_order.push(group.clone());
290				group_keys.push(row_key(group));
291			}
292		}
293		if group_keys.is_empty() {
294			return Ok(buffer_rows);
295		}
296		let interned = store.intern_groups(&group_keys)?;
297		reifydb_assertions! {
298			let resolved = interned.len();
299			let requested = group_keys.len();
300			assert!(
301				resolved == requested,
302				"intern_groups returned {resolved} groups for {requested} group keys; \
303				 the zip below pairs resolve_order with the interned ids by position, so a length \
304				 mismatch would silently leave some groups without a buffer_rows entry and route \
305				 them through the per-bucket intern fallback, diverging behaviour"
306			);
307		}
308		for ((group, key), (group_id, _)) in resolve_order.into_iter().zip(group_keys).zip(interned) {
309			buffer_rows.insert(group, (group_id, key));
310		}
311		Ok(buffer_rows)
312	}
313
314	fn persist_meta(&mut self, store: &mut dyn StateStore, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
315		persist_batch_meta(store, &mut self.meta, meta_loaded)
316	}
317}
318
319#[cfg(test)]
320mod tests {
321	use std::collections::BTreeMap;
322
323	use reifydb_codec::key::encoded::EncodedKey;
324	use reifydb_core::state::store::StateStore;
325	use reifydb_value::{factory::time::at_millis, value::datetime::DateTime};
326
327	use crate::{
328		operator::state::mock::MockStore,
329		window::{
330			accumulator::{WindowAccumulator, mock::SumAccumulator},
331			engine::{
332				AccumulatorEvent, EmitKind,
333				config::WindowEngineConfig,
334				rolling::{RollingBuckets, RollingResult},
335				rolling_incremental::RollingIncrementalEngine,
336			},
337		},
338	};
339
340	fn test_config() -> WindowEngineConfig {
341		WindowEngineConfig::builder().build()
342	}
343
344	fn row_key(group: &u32) -> EncodedKey {
345		EncodedKey::builder().u32(*group).build()
346	}
347
348	fn running_sum(_group: &u32, running: &SumAccumulator, _newest: &i64, _coord: DateTime) -> Option<i64> {
349		running.finalize()
350	}
351
352	#[test]
353	fn buffer_survives_restart_without_running_collision() {
354		// `buffers` and `running` are keyed by the same RowNumber, so sharing a keyspace lets
355		// `running` (flushed last) clobber the buffer's store slot. A live engine hides it by
356		// serving both from memory; a restart is one of the two ways a read reaches the store.
357		let mut store = MockStore::default();
358
359		let mut engine =
360			RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
361		let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
362		buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(5)]);
363		let published: Vec<RollingResult<u32, i64>> =
364			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
365		assert_eq!(published.len(), 1);
366		assert!(matches!(published[0].kind, EmitKind::Insert));
367		assert_eq!(published[0].value, 5);
368
369		// A brand new engine with empty caches, forced to read the persisted buffer and running
370		// accumulator back from the store.
371		let mut engine =
372			RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
373		let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
374		buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Remove(5)]);
375		let withdrawn: Vec<RollingResult<u32, i64>> =
376			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
377
378		assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
379		assert!(
380			matches!(withdrawn[0].kind, EmitKind::Remove),
381			"the group emptied under retraction, so the last published row must be withdrawn"
382		);
383		assert_eq!(
384			withdrawn[0].value, 5,
385			"the withdrawn value is reconstructed from the persisted buffer plus running accumulator"
386		);
387		assert_eq!(
388			withdrawn[0].row_number, published[0].row_number,
389			"the withdrawal targets the same row that was published"
390		);
391	}
392
393	#[test]
394	fn a_group_whose_state_was_reclaimed_updates_its_row_rather_than_inserting_a_second() {
395		// The row-number mapping sits in an identity keyspace, so erasing the buffer and the running
396		// total leaves the published row addressable and the next event must reach it rather than
397		// mint a second one. Insert is decided from the mapping, not from state a data sweep takes.
398		let mut store = MockStore::default();
399		let mut engine =
400			RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
401
402		let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
403		buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(5)]);
404		let published: Vec<RollingResult<u32, i64>> =
405			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
406		assert_eq!(published.len(), 1);
407		assert!(matches!(published[0].kind, EmitKind::Insert), "precondition: the group publishes once");
408
409		let group_id = store
410			.lookup_groups(&[row_key(&1)])
411			.unwrap()
412			.into_iter()
413			.next()
414			.unwrap()
415			.expect("precondition: the group is interned");
416		assert!(store.drop_group_data_entries() > 0, "precondition: the sweep must have erased something");
417		assert!(
418			store.contains_row_mapping(group_id, &row_key(&1)),
419			"precondition: the identity half must survive the data phase"
420		);
421
422		let mut engine =
423			RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
424		let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
425		buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(3)]);
426		let republished: Vec<RollingResult<u32, i64>> =
427			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
428
429		assert_eq!(republished.len(), 1);
430		assert_eq!(
431			republished[0].kind,
432			EmitKind::Update,
433			"the published row survived the sweep, so this is an update and not a second insert"
434		);
435		assert_eq!(
436			republished[0].row_number, published[0].row_number,
437			"the woken group keeps the row it published"
438		);
439	}
440
441	#[test]
442	fn buffer_survives_lru_eviction_without_running_collision() {
443		// The second way a read reaches the store is LRU eviction, with no restart: the cache holds
444		// 8 groups, so tracking more evicts the oldest and the next access re-reads it. Same
445		// buffers/running keyspace collision as the restart test, inside one long-lived engine.
446		let mut store = MockStore::default();
447		let mut engine =
448			RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
449
450		let mut published_group_1: Vec<RollingResult<u32, i64>> = Vec::new();
451		for group in 1u32..=11u32 {
452			let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
453			buckets.insert((group, at_millis(10)), vec![AccumulatorEvent::Add(i64::from(group))]);
454			let out: Vec<RollingResult<u32, i64>> =
455				engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
456			if group == 1 {
457				published_group_1 = out;
458			}
459		}
460		assert_eq!(published_group_1.len(), 1);
461		assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
462		assert_eq!(published_group_1[0].value, 1);
463
464		// Group 1 was pushed out of the 8-slot cache by the later groups, so the same engine must
465		// re-read its buffer from the store to apply this retraction.
466		let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
467		buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Remove(1)]);
468		let withdrawn: Vec<RollingResult<u32, i64>> =
469			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
470
471		assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
472		assert!(
473			matches!(withdrawn[0].kind, EmitKind::Remove),
474			"the evicted group emptied under retraction, so the last published row must be withdrawn"
475		);
476		assert_eq!(
477			withdrawn[0].value, 1,
478			"the withdrawn value is reconstructed from the evicted group's persisted buffer and running"
479		);
480		assert_eq!(
481			withdrawn[0].row_number, published_group_1[0].row_number,
482			"the withdrawal targets the same row that was published for group 1"
483		);
484	}
485}