Skip to main content

reifydb_core/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::key::encoded::{EncodedKey, IntoEncodedKey};
12use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
13use serde::{Serialize, de::DeserializeOwned};
14
15use crate::{
16	key::flow_node_internal_state::FlowNodeInternalStateKey,
17	window::{
18		accumulator::WindowAccumulator,
19		engine::{
20			AccumulatorEvent, EmitKind, GroupMeta, LatePolicy, MetaKey,
21			config::WindowEngineConfig,
22			meta_key_for,
23			rolling::{RollingBuckets, RollingBuffer, RollingResult},
24		},
25		span::Slot,
26		state::StateCache,
27		store::WindowStore,
28	},
29};
30
31#[derive(Clone, Copy, Hash, PartialEq, Eq)]
32struct RunningKey(RowNumber);
33
34impl IntoEncodedKey for &RunningKey {
35	fn into_encoded_key(self) -> EncodedKey {
36		let inner = (&self.0).into_encoded_key();
37		let inner = inner.as_ref();
38		let mut bytes = Vec::with_capacity(1 + inner.len());
39		bytes.push(FlowNodeInternalStateKey::WINDOW_RUNNING_TAG);
40		bytes.extend_from_slice(inner);
41		EncodedKey::new(bytes)
42	}
43}
44
45type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
46type BufferRows<G> = HashMap<G, (RowNumber, bool)>;
47
48struct GroupSlot<C, Accumulator, Running, Output> {
49	row_number: RowNumber,
50	is_new: bool,
51	buffer: RollingBuffer<C, Accumulator>,
52	running: Running,
53	was_empty_before: bool,
54	buffer_changed: bool,
55	prior_output: Option<Output>,
56}
57
58pub struct RollingIncrementalEngine<G, C, Accumulator, Running> {
59	buffers: StateCache<RowNumber, RollingBuffer<C, Accumulator>>,
60	running: StateCache<RunningKey, Running>,
61	meta: StateCache<MetaKey, GroupMeta<C>>,
62	late_policy: LatePolicy,
63	_pd: PhantomData<G>,
64}
65
66impl<G, C, Accumulator, Running> RollingIncrementalEngine<G, C, Accumulator, Running>
67where
68	G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
69	C: Slot + Hash + Serialize + DeserializeOwned,
70	Accumulator: WindowAccumulator,
71	Running: WindowAccumulator,
72	for<'a> &'a G: IntoEncodedKey,
73{
74	pub fn new(config: WindowEngineConfig) -> Self {
75		Self {
76			buffers: StateCache::<RowNumber, RollingBuffer<C, Accumulator>>::new(
77				config.state_cache_capacity(),
78			),
79			running: StateCache::<RunningKey, Running>::new(config.state_cache_capacity()),
80			meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
81			late_policy: config.late_policy(),
82			_pd: PhantomData,
83		}
84	}
85
86	pub fn apply<S, K, WC, CR, Output>(
87		&mut self,
88		store: &mut S,
89		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
90		capacity: usize,
91		row_key: K,
92		window_contribution: WC,
93		combine_running: CR,
94	) -> Result<Vec<RollingResult<G, Output>>>
95	where
96		S: WindowStore,
97		K: Fn(&G) -> EncodedKey,
98		WC: Fn(&Accumulator::Output) -> Running::Contribution,
99		CR: Fn(&G, &Running, &Accumulator::Output, C) -> Option<Output>,
100	{
101		if buckets.is_empty() {
102			return Ok(Vec::new());
103		}
104		let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
105		let buffer_rows = self.resolve_buffer_rows(store, &buckets, &meta_loaded, &row_key)?;
106
107		let late_policy = self.late_policy;
108		let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Running, Output>> = BTreeMap::new();
109
110		for ((group, coord), events) in buckets {
111			let meta = meta_loaded.entry(group.clone()).or_default();
112
113			let slot = match group_slots.get_mut(&group) {
114				Some(s) => s,
115				None => {
116					let (row_number, is_new) = match buffer_rows.get(&group) {
117						Some(&resolved) => resolved,
118						None => {
119							let key = row_key(&group);
120							store.get_or_create_row_number(&key)?
121						}
122					};
123					let buffer: RollingBuffer<C, Accumulator> =
124						self.buffers.get(store, &row_number)?.unwrap_or_default();
125					let running: Running =
126						self.running.get(store, &RunningKey(row_number))?.unwrap_or_default();
127					let was_empty_before = buffer.is_empty();
128					let prior_output = match buffer.iter().next_back() {
129						Some((coord, accumulator)) => {
130							accumulator.finalize().and_then(|newest| {
131								combine_running(&group, &running, &newest, *coord)
132							})
133						}
134						None => None,
135					};
136					group_slots.insert(
137						group.clone(),
138						GroupSlot {
139							row_number,
140							is_new,
141							buffer,
142							running,
143							was_empty_before,
144							buffer_changed: false,
145							prior_output,
146						},
147					);
148					group_slots.get_mut(&group).expect("just inserted")
149				}
150			};
151
152			let late = matches!(meta.high_water, Some(hw) if coord < hw)
153				&& matches!(late_policy, LatePolicy::Drop)
154				&& !slot.buffer.contains_key(&coord);
155
156			let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
157			let old_value = accumulator.finalize();
158			let mut touched = false;
159			for event in events {
160				match event {
161					AccumulatorEvent::Add(c) => {
162						if late {
163							continue;
164						}
165						accumulator.add(&c);
166						touched = true;
167					}
168					AccumulatorEvent::Remove(c) => {
169						if accumulator.is_empty() {
170							continue;
171						}
172						accumulator.remove(&c);
173						touched = true;
174					}
175				}
176			}
177			if !touched {
178				continue;
179			}
180			let new_value = accumulator.finalize();
181
182			if let Some(old) = &old_value {
183				slot.running.remove(&window_contribution(old));
184			}
185			if let Some(new) = &new_value {
186				slot.running.add(&window_contribution(new));
187			}
188
189			if !accumulator.is_empty() {
190				slot.buffer.insert(coord, accumulator);
191			}
192			while slot.buffer.len() > capacity {
193				if let Some((_, evicted)) = slot.buffer.pop_first()
194					&& let Some(value) = evicted.finalize()
195				{
196					slot.running.remove(&window_contribution(&value));
197				}
198			}
199			slot.buffer_changed = true;
200
201			meta.high_water = Some(match meta.high_water {
202				Some(hw) if hw > coord => hw,
203				_ => coord,
204			});
205		}
206
207		let mut results: Vec<RollingResult<G, Output>> = Vec::new();
208		for (group, slot) in group_slots {
209			if !slot.buffer_changed {
210				continue;
211			}
212			let output = match slot.buffer.iter().next_back() {
213				Some((coord, accumulator)) => accumulator
214					.finalize()
215					.and_then(|newest| combine_running(&group, &slot.running, &newest, *coord)),
216				None => None,
217			};
218			self.buffers.put(store, &slot.row_number, slot.buffer)?;
219			self.running.put(store, &RunningKey(slot.row_number), slot.running)?;
220
221			if let Some(out) = output {
222				let kind = if slot.is_new || slot.was_empty_before {
223					EmitKind::Insert
224				} else {
225					EmitKind::Update
226				};
227				results.push(RollingResult {
228					row_number: slot.row_number,
229					group,
230					value: out,
231					prior: None,
232					kind,
233				});
234			} else if let Some(prior) = slot.prior_output {
235				results.push(RollingResult {
236					row_number: slot.row_number,
237					group,
238					value: prior,
239					prior: None,
240					kind: EmitKind::Remove,
241				});
242			}
243		}
244		self.persist_meta(store, meta_loaded)?;
245		Ok(results)
246	}
247
248	pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
249		self.buffers.flush(store)?;
250		self.running.flush(store)?;
251		self.meta.flush(store)?;
252		Ok(())
253	}
254
255	fn warm_and_load_meta<S: WindowStore>(
256		&mut self,
257		store: &mut S,
258		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
259	) -> Result<MetaLoaded<G, C>> {
260		let meta_keys: Vec<MetaKey> = buckets
261			.keys()
262			.map(|(group, _)| group)
263			.collect::<BTreeSet<_>>()
264			.into_iter()
265			.map(meta_key_for)
266			.collect();
267		self.meta.warm(store, &meta_keys)?;
268
269		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
270		for (group, _) in buckets.keys() {
271			if !meta_loaded.contains_key(group) {
272				let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
273				meta_loaded.insert(group.clone(), m);
274			}
275		}
276		Ok(meta_loaded)
277	}
278
279	fn resolve_buffer_rows<S, K>(
280		&mut self,
281		store: &mut S,
282		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
283		meta_loaded: &MetaLoaded<G, C>,
284		row_key: &K,
285	) -> Result<BufferRows<G>>
286	where
287		S: WindowStore,
288		K: Fn(&G) -> EncodedKey,
289	{
290		let mut buffer_rows: BufferRows<G> = HashMap::new();
291		let mut resolve_order: Vec<G> = Vec::new();
292		let mut group_keys: Vec<EncodedKey> = Vec::new();
293		let mut seen: BTreeSet<G> = BTreeSet::new();
294		for (group, coord) in buckets.keys() {
295			let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
296			if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
297				resolve_order.push(group.clone());
298				group_keys.push(row_key(group));
299			}
300		}
301		let resolved_rows = store.get_or_create_row_numbers(&group_keys)?;
302		reifydb_assertions! {
303			let resolved = resolved_rows.len();
304			let requested = group_keys.len();
305			assert!(
306				resolved == requested,
307				"get_or_create_row_numbers returned {resolved} rows for {requested} group keys; \
308				 the zip below pairs resolve_order with resolved_rows by position, so a length \
309				 mismatch would silently leave some groups without a buffer_rows entry and route \
310				 them through the per-bucket get_or_create_row_number fallback, diverging behaviour"
311			);
312		}
313		let state_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
314		let running_keys: Vec<RunningKey> = state_keys.iter().map(|rn| RunningKey(*rn)).collect();
315		for (group, resolved) in resolve_order.into_iter().zip(resolved_rows) {
316			buffer_rows.insert(group, resolved);
317		}
318		self.buffers.warm(store, &state_keys)?;
319		self.running.warm(store, &running_keys)?;
320		Ok(buffer_rows)
321	}
322
323	fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
324		for (group, meta) in meta_loaded {
325			self.meta.set(store, &meta_key_for(&group), &meta)?;
326		}
327		Ok(())
328	}
329}
330
331#[cfg(test)]
332mod tests {
333	use std::collections::BTreeMap;
334
335	use reifydb_codec::key::encoded::EncodedKey;
336
337	use crate::window::{
338		accumulator::WindowAccumulator,
339		engine::{
340			AccumulatorEvent, EmitKind,
341			config::WindowEngineConfig,
342			rolling::{RollingBuckets, RollingResult},
343			rolling_incremental::RollingIncrementalEngine,
344			test_support::{MockStore, SumAccumulator},
345		},
346	};
347
348	fn test_config() -> WindowEngineConfig {
349		WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
350	}
351
352	fn row_key(group: &u32) -> EncodedKey {
353		EncodedKey::builder().u32(*group).build()
354	}
355
356	fn running_sum(_group: &u32, running: &SumAccumulator, _newest: &i64, _coord: u64) -> Option<i64> {
357		running.finalize()
358	}
359
360	#[test]
361	fn buffer_survives_restart_without_running_collision() {
362		// rolling_incremental keeps two Data-backend caches - the rolling `buffers` and the `running`
363		// accumulator - and both must live in distinct store keyspaces. They are keyed by the same
364		// RowNumber, so if their keyspaces are not separated, `running` (flushed last) clobbers the
365		// buffer's store slot and a later buffer read decodes running's bytes. Within one live engine
366		// this is hidden because reads are served from each cache's in-memory map; a restart is one of
367		// the two ways a read actually reaches the store. This test publishes a window, drops the
368		// engine (a restart / panic-recovery), then retracts the only contribution with a fresh engine
369		// whose caches are empty, and asserts the buffer is read back intact - the terminal Remove
370		// still carries the originally published value. It fails if `buffers` and `running` share a
371		// store key.
372		let mut store = MockStore::default();
373
374		let mut engine =
375			RollingIncrementalEngine::<u32, u64, SumAccumulator, SumAccumulator>::new(test_config());
376		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
377		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
378		let published: Vec<RollingResult<u32, i64>> =
379			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
380		engine.flush(&mut store).unwrap();
381		assert_eq!(published.len(), 1);
382		assert!(matches!(published[0].kind, EmitKind::Insert));
383		assert_eq!(published[0].value, 5);
384
385		// Restart: a brand new engine with empty caches, forced to read the persisted buffer and
386		// running accumulator back from the store.
387		let mut engine =
388			RollingIncrementalEngine::<u32, u64, SumAccumulator, SumAccumulator>::new(test_config());
389		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
390		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
391		let withdrawn: Vec<RollingResult<u32, i64>> =
392			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
393		engine.flush(&mut store).unwrap();
394
395		assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
396		assert!(
397			matches!(withdrawn[0].kind, EmitKind::Remove),
398			"the group emptied under retraction, so the last published row must be withdrawn"
399		);
400		assert_eq!(
401			withdrawn[0].value, 5,
402			"the withdrawn value is reconstructed from the persisted buffer plus running accumulator"
403		);
404		assert_eq!(
405			withdrawn[0].row_number, published[0].row_number,
406			"the withdrawal targets the same row that was published"
407		);
408	}
409
410	#[test]
411	fn buffer_survives_lru_eviction_without_running_collision() {
412		// The second way a read reaches the store is LRU eviction - no restart needed. The state cache
413		// holds only 8 groups, so an engine tracking more than 8 groups evicts the oldest ones; the
414		// next access re-reads them from the store. This exercises the same buffers/running keyspace
415		// collision as the restart test, but within a single long-lived engine. We publish 11 groups so
416		// the earliest (group 1) is evicted, flush, then retract group 1 and assert its buffer is read
417		// back intact. It fails if `buffers` and `running` share a store key.
418		let mut store = MockStore::default();
419		let mut engine =
420			RollingIncrementalEngine::<u32, u64, SumAccumulator, SumAccumulator>::new(test_config());
421
422		let mut published_group_1: Vec<RollingResult<u32, i64>> = Vec::new();
423		for group in 1u32..=11u32 {
424			let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
425			buckets.insert((group, 10u64), vec![AccumulatorEvent::Add(i64::from(group))]);
426			let out: Vec<RollingResult<u32, i64>> =
427				engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
428			if group == 1 {
429				published_group_1 = out;
430			}
431		}
432		engine.flush(&mut store).unwrap();
433		assert_eq!(published_group_1.len(), 1);
434		assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
435		assert_eq!(published_group_1[0].value, 1);
436
437		// Group 1 was published first and pushed out of the 8-slot cache by the later groups, so the
438		// same engine must re-read its buffer from the store to apply this retraction.
439		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
440		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(1)]);
441		let withdrawn: Vec<RollingResult<u32, i64>> =
442			engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
443		engine.flush(&mut store).unwrap();
444
445		assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
446		assert!(
447			matches!(withdrawn[0].kind, EmitKind::Remove),
448			"the evicted group emptied under retraction, so the last published row must be withdrawn"
449		);
450		assert_eq!(
451			withdrawn[0].value, 1,
452			"the withdrawn value is reconstructed from the evicted group's persisted buffer and running"
453		);
454		assert_eq!(
455			withdrawn[0].row_number, published_group_1[0].row_number,
456			"the withdrawal targets the same row that was published for group 1"
457		);
458	}
459}