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