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