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_value::{Result, reifydb_assertions, value::row_number::RowNumber};
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13
14use crate::{
15	encoded::key::{EncodedKey, IntoEncodedKey},
16	window::{
17		accumulator::WindowAccumulator,
18		engine::{AccumulatorEvent, GroupMeta, LatePolicy, MetaKey, meta_key_for, rolling::RollingBuckets},
19		span::Slot,
20		state::StateCache,
21		store::WindowStore,
22	},
23};
24
25pub type MultiRollingBuffer<C, Accumulator> = BTreeMap<C, Accumulator>;
26
27pub type MultiRollingEmit<SK, Output> = BTreeMap<SK, Output>;
28
29pub enum MultiEmit<Output> {
30	Insert {
31		row_number: RowNumber,
32		value: Output,
33	},
34	Update {
35		row_number: RowNumber,
36		prior: Output,
37		value: Output,
38	},
39	Remove {
40		row_number: RowNumber,
41		value: Output,
42	},
43}
44
45#[derive(Serialize, Deserialize)]
46#[serde(bound(
47	serialize = "C: Serialize + Ord, Accumulator: Serialize, SK: Serialize + Ord, Output: Serialize",
48	deserialize = "C: serde::de::DeserializeOwned + Ord, Accumulator: serde::de::DeserializeOwned, \
49	               SK: serde::de::DeserializeOwned + Ord, Output: serde::de::DeserializeOwned"
50))]
51struct GroupState<C, Accumulator, SK, Output> {
52	buffer: MultiRollingBuffer<C, Accumulator>,
53	last_emit: MultiRollingEmit<SK, Output>,
54}
55
56impl<C: Ord, Accumulator, SK: Ord, Output> Default for GroupState<C, Accumulator, SK, Output> {
57	fn default() -> Self {
58		Self {
59			buffer: BTreeMap::new(),
60			last_emit: BTreeMap::new(),
61		}
62	}
63}
64
65impl<C: Ord + Clone, Accumulator: Clone, SK: Ord + Clone, Output: Clone> Clone
66	for GroupState<C, Accumulator, SK, Output>
67{
68	fn clone(&self) -> Self {
69		Self {
70			buffer: self.buffer.clone(),
71			last_emit: self.last_emit.clone(),
72		}
73	}
74}
75
76impl<C, Accumulator, SK, Output> Debug for GroupState<C, Accumulator, SK, Output> {
77	fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
78		f.debug_struct("GroupState")
79			.field("buffer_len", &self.buffer.len())
80			.field("last_emit_len", &self.last_emit.len())
81			.finish()
82	}
83}
84
85type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
86type StateRows<G> = HashMap<G, RowNumber>;
87
88struct GroupSlot<C, Accumulator, SK, Output> {
89	state_row_number: RowNumber,
90	buffer: MultiRollingBuffer<C, Accumulator>,
91	prior_emit: MultiRollingEmit<SK, Output>,
92	buffer_changed: bool,
93}
94
95pub struct MultiRollingEngine<G, C, Accumulator, SK, Output> {
96	groups: StateCache<RowNumber, GroupState<C, Accumulator, SK, Output>>,
97	meta: StateCache<MetaKey, GroupMeta<C>>,
98	late_policy: LatePolicy,
99	_pd: PhantomData<G>,
100}
101
102impl<G, C, Accumulator, SK, Output> Default for MultiRollingEngine<G, C, Accumulator, SK, Output>
103where
104	G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
105	C: Slot + Hash + Serialize + DeserializeOwned,
106	Accumulator: WindowAccumulator,
107	SK: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
108	Output: Clone + Debug + PartialEq + Serialize + DeserializeOwned,
109	for<'a> &'a G: IntoEncodedKey,
110{
111	fn default() -> Self {
112		Self::new()
113	}
114}
115
116impl<G, C, Accumulator, SK, Output> MultiRollingEngine<G, C, Accumulator, SK, Output>
117where
118	G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
119	C: Slot + Hash + Serialize + DeserializeOwned,
120	Accumulator: WindowAccumulator,
121	SK: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
122	Output: Clone + Debug + PartialEq + Serialize + DeserializeOwned,
123	for<'a> &'a G: IntoEncodedKey,
124{
125	pub fn new() -> Self {
126		Self::with_late_policy(LatePolicy::Drop)
127	}
128
129	pub fn with_late_policy(late_policy: LatePolicy) -> Self {
130		Self {
131			groups: StateCache::<RowNumber, GroupState<C, Accumulator, SK, Output>>::new(8),
132			meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(64),
133			late_policy,
134			_pd: PhantomData,
135		}
136	}
137
138	pub fn apply<S, SKF, RKF, CB>(
139		&mut self,
140		store: &mut S,
141		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
142		capacity: usize,
143		state_key: SKF,
144		row_key: RKF,
145		combine: CB,
146	) -> Result<Vec<MultiEmit<Output>>>
147	where
148		S: WindowStore,
149		SKF: Fn(&G) -> EncodedKey,
150		RKF: Fn(&G, &SK) -> EncodedKey,
151		CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
152	{
153		if buckets.is_empty() {
154			return Ok(Vec::new());
155		}
156		let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
157		let state_rows = self.resolve_state_rows(store, &buckets, &meta_loaded, &state_key)?;
158		let group_slots = self.apply_events_into_buffers(
159			store,
160			buckets,
161			&mut meta_loaded,
162			&state_rows,
163			&state_key,
164			capacity,
165		)?;
166		let emits = self.diff_emits(store, group_slots, &row_key, &combine)?;
167		self.persist_meta(store, meta_loaded)?;
168		Ok(emits)
169	}
170
171	pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
172		self.groups.flush(store)?;
173		self.meta.flush(store)?;
174		Ok(())
175	}
176
177	fn warm_and_load_meta<S: WindowStore>(
178		&mut self,
179		store: &mut S,
180		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
181	) -> Result<MetaLoaded<G, C>> {
182		let meta_keys: Vec<MetaKey> = buckets
183			.keys()
184			.map(|(group, _)| group)
185			.collect::<BTreeSet<_>>()
186			.into_iter()
187			.map(meta_key_for)
188			.collect();
189		self.meta.warm(store, &meta_keys)?;
190
191		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
192		for (group, _) in buckets.keys() {
193			if !meta_loaded.contains_key(group) {
194				let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
195				meta_loaded.insert(group.clone(), m);
196			}
197		}
198		Ok(meta_loaded)
199	}
200
201	fn resolve_state_rows<S, SKF>(
202		&mut self,
203		store: &mut S,
204		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
205		meta_loaded: &MetaLoaded<G, C>,
206		state_key: &SKF,
207	) -> Result<StateRows<G>>
208	where
209		S: WindowStore,
210		SKF: Fn(&G) -> EncodedKey,
211	{
212		let mut state_rows: StateRows<G> = HashMap::new();
213		let mut resolve_order: Vec<G> = Vec::new();
214		let mut state_lookup_keys: Vec<EncodedKey> = Vec::new();
215		let mut seen: BTreeSet<G> = BTreeSet::new();
216		for (group, coord) in buckets.keys() {
217			let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
218			if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
219				resolve_order.push(group.clone());
220				state_lookup_keys.push(state_key(group));
221			}
222		}
223		let resolved_rows = store.get_or_create_row_numbers(&state_lookup_keys)?;
224		reifydb_assertions! {
225			let resolved = resolved_rows.len();
226			let requested = state_lookup_keys.len();
227			assert!(
228				resolved == requested,
229				"get_or_create_row_numbers returned {resolved} rows for {requested} group keys; \
230				 the zip below pairs resolve_order with resolved_rows by position, so a length \
231				 mismatch would silently leave some groups without a state_rows entry and route \
232				 them through the per-bucket get_or_create_row_number fallback, diverging behaviour"
233			);
234		}
235		let state_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
236		for (group, (state_row_number, _)) in resolve_order.into_iter().zip(resolved_rows) {
237			state_rows.insert(group, state_row_number);
238		}
239		self.groups.warm(store, &state_keys)?;
240		Ok(state_rows)
241	}
242
243	fn apply_events_into_buffers<S, SKF>(
244		&mut self,
245		store: &mut S,
246		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
247		meta_loaded: &mut MetaLoaded<G, C>,
248		state_rows: &StateRows<G>,
249		state_key: &SKF,
250		capacity: usize,
251	) -> Result<BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>>
252	where
253		S: WindowStore,
254		SKF: Fn(&G) -> EncodedKey,
255	{
256		let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>> = BTreeMap::new();
257
258		for ((group, coord), events) in buckets {
259			let meta = meta_loaded.entry(group.clone()).or_default();
260
261			let slot = match group_slots.get_mut(&group) {
262				Some(s) => s,
263				None => {
264					let state_row_number = match state_rows.get(&group) {
265						Some(&rn) => rn,
266						None => {
267							let key = state_key(&group);
268							let (rn, _is_new) = store.get_or_create_row_number(&key)?;
269							rn
270						}
271					};
272					let GroupState {
273						buffer,
274						last_emit: prior_emit,
275					} = self.groups.get(store, &state_row_number)?.unwrap_or_default();
276					group_slots.insert(
277						group.clone(),
278						GroupSlot {
279							state_row_number,
280							buffer,
281							prior_emit,
282							buffer_changed: false,
283						},
284					);
285					group_slots.get_mut(&group).expect("just inserted")
286				}
287			};
288
289			let late = matches!(meta.high_water, Some(hw) if coord < hw)
290				&& matches!(self.late_policy, LatePolicy::Drop)
291				&& !slot.buffer.contains_key(&coord);
292
293			let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
294			let mut touched = false;
295			for event in events {
296				match event {
297					AccumulatorEvent::Add(c) => {
298						if late {
299							continue;
300						}
301						accumulator.add(&c);
302						touched = true;
303					}
304					AccumulatorEvent::Remove(c) => {
305						if accumulator.is_empty() {
306							continue;
307						}
308						accumulator.remove(&c);
309						touched = true;
310					}
311				}
312			}
313			if !accumulator.is_empty() {
314				slot.buffer.insert(coord, accumulator);
315			}
316			if !touched {
317				continue;
318			}
319			while slot.buffer.len() > capacity {
320				slot.buffer.pop_first();
321			}
322			slot.buffer_changed = true;
323
324			let next_high_water = match meta.high_water {
325				Some(hw) if hw > coord => hw,
326				_ => coord,
327			};
328			reifydb_assertions! {
329				assert!(
330					next_high_water >= coord,
331					"high_water regressed below the window coord it just admitted, so the next batch would \
332					 treat an already-processed window as late and silently drop its events (coord={coord:?}, \
333					 prev_high_water={prev:?}, next_high_water={next_high_water:?})",
334					prev = meta.high_water
335				);
336				if let Some(prev) = meta.high_water {
337					assert!(
338						next_high_water >= prev,
339						"high_water moved backwards across an admit, breaking the monotonic late-event \
340						 cutoff that buried-window dropping relies on (coord={coord:?}, prev_high_water={prev:?}, \
341						 next_high_water={next_high_water:?})"
342					);
343				}
344			}
345			meta.high_water = Some(next_high_water);
346		}
347
348		Ok(group_slots)
349	}
350
351	fn diff_emits<S, RKF, CB>(
352		&mut self,
353		store: &mut S,
354		group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>,
355		row_key: &RKF,
356		combine: &CB,
357	) -> Result<Vec<MultiEmit<Output>>>
358	where
359		S: WindowStore,
360		RKF: Fn(&G, &SK) -> EncodedKey,
361		CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
362	{
363		let mut emits: Vec<MultiEmit<Output>> = Vec::new();
364
365		for (group, slot) in group_slots {
366			if !slot.buffer_changed {
367				continue;
368			}
369			let new_emit = combine(&group, &slot.buffer);
370
371			for (sk, new_out) in &new_emit {
372				let key = row_key(&group, sk);
373				let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
374				match slot.prior_emit.get(sk) {
375					Some(prior_out) => {
376						if prior_out != new_out {
377							emits.push(MultiEmit::Update {
378								row_number: rn,
379								prior: prior_out.clone(),
380								value: new_out.clone(),
381							});
382						}
383					}
384					None => {
385						emits.push(MultiEmit::Insert {
386							row_number: rn,
387							value: new_out.clone(),
388						});
389					}
390				}
391			}
392			for (sk, prior_out) in &slot.prior_emit {
393				if !new_emit.contains_key(sk) {
394					let key = row_key(&group, sk);
395					let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
396					emits.push(MultiEmit::Remove {
397						row_number: rn,
398						value: prior_out.clone(),
399					});
400				}
401			}
402
403			let combined = GroupState {
404				buffer: slot.buffer,
405				last_emit: new_emit,
406			};
407			self.groups.put(store, &slot.state_row_number, combined)?;
408		}
409
410		Ok(emits)
411	}
412
413	fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
414		for (group, meta) in meta_loaded {
415			self.meta.set(store, &meta_key_for(&group), &meta)?;
416		}
417		Ok(())
418	}
419}