1use 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 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 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 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 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}