Skip to main content

reifydb_core/window/engine/
multi_rolling.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::{BTreeMap, BTreeSet, HashMap},
6	fmt::{self, Debug, Formatter},
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::{Deserialize, Serialize, de::DeserializeOwned};
14
15use crate::window::{
16	accumulator::WindowAccumulator,
17	engine::{
18		AccumulatorEvent, GroupMeta, LatePolicy, MetaKey, config::WindowEngineConfig, meta_key_for,
19		rolling::RollingBuckets,
20	},
21	span::Slot,
22	state::StateCache,
23	store::WindowStore,
24};
25
26pub type MultiRollingBuffer<C, Accumulator> = BTreeMap<C, Accumulator>;
27
28pub type MultiRollingEmit<SK, Output> = BTreeMap<SK, Output>;
29
30pub enum MultiEmit<Output> {
31	Insert {
32		row_number: RowNumber,
33		value: Output,
34	},
35	Update {
36		row_number: RowNumber,
37		prior: Output,
38		value: Output,
39	},
40	Remove {
41		row_number: RowNumber,
42		value: Output,
43	},
44}
45
46#[derive(Serialize, Deserialize)]
47#[serde(bound(
48	serialize = "C: Serialize + Ord, Accumulator: Serialize, SK: Serialize + Ord, Output: Serialize",
49	deserialize = "C: serde::de::DeserializeOwned + Ord, Accumulator: serde::de::DeserializeOwned, \
50	               SK: serde::de::DeserializeOwned + Ord, Output: serde::de::DeserializeOwned"
51))]
52struct GroupState<C, Accumulator, SK, Output> {
53	buffer: MultiRollingBuffer<C, Accumulator>,
54	last_emit: MultiRollingEmit<SK, Output>,
55}
56
57impl<C: Ord, Accumulator, SK: Ord, Output> Default for GroupState<C, Accumulator, SK, Output> {
58	fn default() -> Self {
59		Self {
60			buffer: BTreeMap::new(),
61			last_emit: BTreeMap::new(),
62		}
63	}
64}
65
66impl<C: Ord + Clone, Accumulator: Clone, SK: Ord + Clone, Output: Clone> Clone
67	for GroupState<C, Accumulator, SK, Output>
68{
69	fn clone(&self) -> Self {
70		Self {
71			buffer: self.buffer.clone(),
72			last_emit: self.last_emit.clone(),
73		}
74	}
75}
76
77impl<C, Accumulator, SK, Output> Debug for GroupState<C, Accumulator, SK, Output> {
78	fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
79		f.debug_struct("GroupState")
80			.field("buffer_len", &self.buffer.len())
81			.field("last_emit_len", &self.last_emit.len())
82			.finish()
83	}
84}
85
86type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
87type StateRows<G> = HashMap<G, RowNumber>;
88
89struct GroupSlot<C, Accumulator, SK, Output> {
90	state_row_number: RowNumber,
91	buffer: MultiRollingBuffer<C, Accumulator>,
92	prior_emit: MultiRollingEmit<SK, Output>,
93	buffer_changed: bool,
94}
95
96pub struct MultiRollingEngine<G, C, Accumulator, SK, Output> {
97	groups: StateCache<RowNumber, GroupState<C, Accumulator, SK, Output>>,
98	meta: StateCache<MetaKey, GroupMeta<C>>,
99	late_policy: LatePolicy,
100	_pd: PhantomData<G>,
101}
102
103impl<G, C, Accumulator, SK, Output> MultiRollingEngine<G, C, Accumulator, SK, Output>
104where
105	G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
106	C: Slot + Hash + Serialize + DeserializeOwned,
107	Accumulator: WindowAccumulator,
108	SK: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
109	Output: Clone + Debug + PartialEq + Serialize + DeserializeOwned,
110	for<'a> &'a G: IntoEncodedKey,
111{
112	pub fn new(config: WindowEngineConfig) -> Self {
113		Self {
114			groups: StateCache::<RowNumber, GroupState<C, Accumulator, SK, Output>>::new(
115				config.state_cache_capacity(),
116			),
117			meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
118			late_policy: config.late_policy(),
119			_pd: PhantomData,
120		}
121	}
122
123	pub fn apply<S, SKF, RKF, CB>(
124		&mut self,
125		store: &mut S,
126		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
127		capacity: usize,
128		state_key: SKF,
129		row_key: RKF,
130		combine: CB,
131	) -> Result<Vec<MultiEmit<Output>>>
132	where
133		S: WindowStore,
134		SKF: Fn(&G) -> EncodedKey,
135		RKF: Fn(&G, &SK) -> EncodedKey,
136		CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
137	{
138		if buckets.is_empty() {
139			return Ok(Vec::new());
140		}
141		let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
142		let state_rows = self.resolve_state_rows(store, &buckets, &meta_loaded, &state_key)?;
143		let group_slots = self.apply_events_into_buffers(
144			store,
145			buckets,
146			&mut meta_loaded,
147			&state_rows,
148			&state_key,
149			capacity,
150		)?;
151		let emits = self.diff_emits(store, group_slots, &row_key, &combine)?;
152		self.persist_meta(store, meta_loaded)?;
153		Ok(emits)
154	}
155
156	pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
157		self.groups.flush(store)?;
158		self.meta.flush(store)?;
159		Ok(())
160	}
161
162	fn warm_and_load_meta<S: WindowStore>(
163		&mut self,
164		store: &mut S,
165		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
166	) -> Result<MetaLoaded<G, C>> {
167		let meta_keys: Vec<MetaKey> = buckets
168			.keys()
169			.map(|(group, _)| group)
170			.collect::<BTreeSet<_>>()
171			.into_iter()
172			.map(meta_key_for)
173			.collect();
174		self.meta.warm(store, &meta_keys)?;
175
176		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
177		for (group, _) in buckets.keys() {
178			if !meta_loaded.contains_key(group) {
179				let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
180				meta_loaded.insert(group.clone(), m);
181			}
182		}
183		Ok(meta_loaded)
184	}
185
186	fn resolve_state_rows<S, SKF>(
187		&mut self,
188		store: &mut S,
189		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
190		meta_loaded: &MetaLoaded<G, C>,
191		state_key: &SKF,
192	) -> Result<StateRows<G>>
193	where
194		S: WindowStore,
195		SKF: Fn(&G) -> EncodedKey,
196	{
197		let mut state_rows: StateRows<G> = HashMap::new();
198		let mut resolve_order: Vec<G> = Vec::new();
199		let mut state_lookup_keys: Vec<EncodedKey> = Vec::new();
200		let mut seen: BTreeSet<G> = BTreeSet::new();
201		for (group, coord) in buckets.keys() {
202			let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
203			if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
204				resolve_order.push(group.clone());
205				state_lookup_keys.push(state_key(group));
206			}
207		}
208		let resolved_rows = store.get_or_create_row_numbers(&state_lookup_keys)?;
209		reifydb_assertions! {
210			let resolved = resolved_rows.len();
211			let requested = state_lookup_keys.len();
212			assert!(
213				resolved == requested,
214				"get_or_create_row_numbers returned {resolved} rows for {requested} group keys; \
215				 the zip below pairs resolve_order with resolved_rows by position, so a length \
216				 mismatch would silently leave some groups without a state_rows entry and route \
217				 them through the per-bucket get_or_create_row_number fallback, diverging behaviour"
218			);
219		}
220		let state_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
221		for (group, (state_row_number, _)) in resolve_order.into_iter().zip(resolved_rows) {
222			state_rows.insert(group, state_row_number);
223		}
224		self.groups.warm(store, &state_keys)?;
225		Ok(state_rows)
226	}
227
228	fn apply_events_into_buffers<S, SKF>(
229		&mut self,
230		store: &mut S,
231		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
232		meta_loaded: &mut MetaLoaded<G, C>,
233		state_rows: &StateRows<G>,
234		state_key: &SKF,
235		capacity: usize,
236	) -> Result<BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>>
237	where
238		S: WindowStore,
239		SKF: Fn(&G) -> EncodedKey,
240	{
241		let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>> = BTreeMap::new();
242
243		for ((group, coord), events) in buckets {
244			let meta = meta_loaded.entry(group.clone()).or_default();
245
246			let slot = match group_slots.get_mut(&group) {
247				Some(s) => s,
248				None => {
249					let state_row_number = match state_rows.get(&group) {
250						Some(&rn) => rn,
251						None => {
252							let key = state_key(&group);
253							let (rn, _is_new) = store.get_or_create_row_number(&key)?;
254							rn
255						}
256					};
257					let GroupState {
258						buffer,
259						last_emit: prior_emit,
260					} = self.groups.get(store, &state_row_number)?.unwrap_or_default();
261					group_slots.insert(
262						group.clone(),
263						GroupSlot {
264							state_row_number,
265							buffer,
266							prior_emit,
267							buffer_changed: false,
268						},
269					);
270					group_slots.get_mut(&group).expect("just inserted")
271				}
272			};
273
274			let late = matches!(meta.high_water, Some(hw) if coord < hw)
275				&& matches!(self.late_policy, LatePolicy::Drop)
276				&& !slot.buffer.contains_key(&coord);
277
278			let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
279			let mut touched = false;
280			for event in events {
281				match event {
282					AccumulatorEvent::Add(c) => {
283						if late {
284							continue;
285						}
286						accumulator.add(&c);
287						touched = true;
288					}
289					AccumulatorEvent::Remove(c) => {
290						if accumulator.is_empty() {
291							continue;
292						}
293						accumulator.remove(&c);
294						touched = true;
295					}
296				}
297			}
298			if !accumulator.is_empty() {
299				slot.buffer.insert(coord, accumulator);
300			}
301			if !touched {
302				continue;
303			}
304			while slot.buffer.len() > capacity {
305				slot.buffer.pop_first();
306			}
307			slot.buffer_changed = true;
308
309			let next_high_water = match meta.high_water {
310				Some(hw) if hw > coord => hw,
311				_ => coord,
312			};
313			reifydb_assertions! {
314				assert!(
315					next_high_water >= coord,
316					"high_water regressed below the window coord it just admitted, so the next batch would \
317					 treat an already-processed window as late and silently drop its events (coord={coord:?}, \
318					 prev_high_water={prev:?}, next_high_water={next_high_water:?})",
319					prev = meta.high_water
320				);
321				if let Some(prev) = meta.high_water {
322					assert!(
323						next_high_water >= prev,
324						"high_water moved backwards across an admit, breaking the monotonic late-event \
325						 cutoff that buried-window dropping relies on (coord={coord:?}, prev_high_water={prev:?}, \
326						 next_high_water={next_high_water:?})"
327					);
328				}
329			}
330			meta.high_water = Some(next_high_water);
331		}
332
333		Ok(group_slots)
334	}
335
336	fn diff_emits<S, RKF, CB>(
337		&mut self,
338		store: &mut S,
339		group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>,
340		row_key: &RKF,
341		combine: &CB,
342	) -> Result<Vec<MultiEmit<Output>>>
343	where
344		S: WindowStore,
345		RKF: Fn(&G, &SK) -> EncodedKey,
346		CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
347	{
348		let mut emits: Vec<MultiEmit<Output>> = Vec::new();
349
350		for (group, slot) in group_slots {
351			if !slot.buffer_changed {
352				continue;
353			}
354			let new_emit = combine(&group, &slot.buffer);
355
356			for (sk, new_out) in &new_emit {
357				let key = row_key(&group, sk);
358				let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
359				match slot.prior_emit.get(sk) {
360					Some(prior_out) => {
361						if prior_out != new_out {
362							emits.push(MultiEmit::Update {
363								row_number: rn,
364								prior: prior_out.clone(),
365								value: new_out.clone(),
366							});
367						}
368					}
369					None => {
370						emits.push(MultiEmit::Insert {
371							row_number: rn,
372							value: new_out.clone(),
373						});
374					}
375				}
376			}
377			for (sk, prior_out) in &slot.prior_emit {
378				if !new_emit.contains_key(sk) {
379					let key = row_key(&group, sk);
380					let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
381					emits.push(MultiEmit::Remove {
382						row_number: rn,
383						value: prior_out.clone(),
384					});
385				}
386			}
387
388			let combined = GroupState {
389				buffer: slot.buffer,
390				last_emit: new_emit,
391			};
392			self.groups.put(store, &slot.state_row_number, combined)?;
393		}
394
395		Ok(emits)
396	}
397
398	fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
399		for (group, meta) in meta_loaded {
400			self.meta.set(store, &meta_key_for(&group), &meta)?;
401		}
402		Ok(())
403	}
404}
405
406#[cfg(test)]
407mod tests {
408	use std::collections::BTreeMap;
409
410	use reifydb_codec::key::encoded::EncodedKey;
411
412	use super::{MultiEmit, MultiRollingBuffer, MultiRollingEngine};
413	use crate::window::engine::{
414		AccumulatorEvent,
415		config::WindowEngineConfig,
416		rolling::RollingBuckets,
417		test_support::{MockStore, SumAccumulator},
418	};
419
420	fn test_config() -> WindowEngineConfig {
421		WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
422	}
423
424	fn state_key(group: &u32) -> EncodedKey {
425		EncodedKey::builder().u32(*group).build()
426	}
427
428	fn row_key(group: &u32, sk: &u32) -> EncodedKey {
429		EncodedKey::builder().u32(*group).u32(*sk).build()
430	}
431
432	fn combine(_group: &u32, buffer: &MultiRollingBuffer<u64, SumAccumulator>) -> BTreeMap<u32, i64> {
433		let mut out = BTreeMap::new();
434		if !buffer.is_empty() {
435			out.insert(0u32, buffer.values().map(|a| a.sum).sum());
436		}
437		out
438	}
439
440	#[test]
441	fn group_state_survives_restart() {
442		// multi_rolling bundles a group's rolling buffer and its last emitted ranking into one
443		// persisted GroupState. When the group empties under retraction, the vanishing ranked key is
444		// withdrawn using the persisted `last_emit`. Dropping the engine between the publish and the
445		// retraction (a restart) forces the GroupState to be reloaded from the store. It would fail if
446		// the GroupState (buffer + last_emit) failed to round-trip - a serialization break, or
447		// last_emit not being persisted.
448		let mut store = MockStore::default();
449
450		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
451		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
452		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
453		let published = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
454		engine.flush(&mut store).unwrap();
455		assert_eq!(published.len(), 1);
456		let published_row = match &published[0] {
457			MultiEmit::Insert {
458				row_number,
459				value,
460			} => {
461				assert_eq!(*value, 5);
462				*row_number
463			}
464			_ => panic!("expected an Insert for the newly published group"),
465		};
466
467		// Restart: a brand new engine with empty caches, forced to reload the persisted GroupState.
468		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
469		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
470		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
471		let withdrawn = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
472		engine.flush(&mut store).unwrap();
473
474		assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
475		match &withdrawn[0] {
476			MultiEmit::Remove {
477				row_number,
478				value,
479			} => {
480				assert_eq!(
481					*value, 5,
482					"the withdrawn value is the reloaded last_emit, not a stale or zeroed value"
483				);
484				assert_eq!(
485					*row_number, published_row,
486					"the withdrawal targets the same row that was published"
487				);
488			}
489			_ => panic!("the group emptied under retraction, so it must emit a terminal Remove"),
490		}
491	}
492
493	#[test]
494	fn group_state_survives_lru_eviction() {
495		// The other way the GroupState is read back is LRU eviction, no restart needed: the group cache
496		// holds only 8 groups, so tracking more evicts the oldest and the next access re-reads it from
497		// the store. We publish 11 groups so group 1 is evicted, flush, then retract group 1 and assert
498		// its GroupState reloads and the vanishing ranked key is withdrawn with the persisted value.
499		let mut store = MockStore::default();
500		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
501
502		let mut published_row_1 = None;
503		for group in 1u32..=11u32 {
504			let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
505			buckets.insert((group, 10u64), vec![AccumulatorEvent::Add(i64::from(group))]);
506			let out = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
507			if group == 1 {
508				assert_eq!(out.len(), 1);
509				published_row_1 = match &out[0] {
510					MultiEmit::Insert {
511						row_number,
512						value,
513					} => {
514						assert_eq!(*value, 1);
515						Some(*row_number)
516					}
517					_ => panic!("expected an Insert for group 1"),
518				};
519			}
520		}
521		engine.flush(&mut store).unwrap();
522		let published_row_1 = published_row_1.expect("group 1 published an Insert");
523
524		// Group 1 was published first and pushed out of the 8-slot group cache by the later groups, so
525		// the same engine must re-read its GroupState from the store to apply this retraction.
526		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
527		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(1)]);
528		let withdrawn = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
529		engine.flush(&mut store).unwrap();
530
531		assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
532		match &withdrawn[0] {
533			MultiEmit::Remove {
534				row_number,
535				value,
536			} => {
537				assert_eq!(*value, 1, "the withdrawn value is the reloaded last_emit for group 1");
538				assert_eq!(
539					*row_number, published_row_1,
540					"the withdrawal targets the same row that was published for group 1"
541				);
542			}
543			_ => panic!("the evicted group emptied under retraction, so it must emit a terminal Remove"),
544		}
545	}
546}