1use 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, EmitKey, GroupMeta, MetaKey, config::WindowEngineConfig, load_buffer, meta_key_for,
19 persist_buffer, rolling::RollingBuckets, sweep_stale_meta,
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
46type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
47type StateRows<G> = HashMap<G, RowNumber>;
48
49struct GroupSlot<C, Accumulator, SK, Output> {
50 state_row_number: RowNumber,
51 buffer: MultiRollingBuffer<C, Accumulator>,
52 loaded_coords: Vec<u64>,
53 dirty: BTreeSet<u64>,
54 prior_emit: MultiRollingEmit<SK, Output>,
55 buffer_changed: bool,
56}
57
58pub struct MultiRollingEngine<G, C, Accumulator, SK, Output> {
59 last_emit: StateCache<EmitKey, MultiRollingEmit<SK, Output>>,
60 meta: StateCache<MetaKey, GroupMeta<C>>,
61 meta_low_water: Option<u64>,
62 _pd: PhantomData<(G, C, Accumulator)>,
63}
64
65impl<G, C, Accumulator, SK, Output> MultiRollingEngine<G, C, Accumulator, SK, Output>
66where
67 G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
68 C: Slot + Hash + Serialize + DeserializeOwned,
69 Accumulator: WindowAccumulator,
70 SK: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
71 Output: Clone + Debug + PartialEq + Serialize + DeserializeOwned,
72 for<'a> &'a G: IntoEncodedKey,
73{
74 pub fn new(config: WindowEngineConfig) -> Self {
75 Self {
76 last_emit: StateCache::<EmitKey, MultiRollingEmit<SK, Output>>::new_internal(
77 config.state_cache_capacity(),
78 ),
79 meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
80 meta_low_water: None,
81 _pd: PhantomData,
82 }
83 }
84
85 pub fn expire_meta<S: WindowStore>(&mut self, store: &mut S, threshold: u64) -> Result<usize> {
86 sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
87 }
88
89 pub fn apply<S, SKF, RKF, CB>(
90 &mut self,
91 store: &mut S,
92 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
93 capacity: usize,
94 state_key: SKF,
95 row_key: RKF,
96 combine: CB,
97 ) -> Result<Vec<MultiEmit<Output>>>
98 where
99 S: WindowStore,
100 SKF: Fn(&G) -> EncodedKey,
101 RKF: Fn(&G, &SK) -> EncodedKey,
102 CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
103 {
104 if buckets.is_empty() {
105 return Ok(Vec::new());
106 }
107 let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
108 let state_rows = self.resolve_state_rows(store, &buckets, &meta_loaded, &state_key)?;
109 let group_slots = self.apply_events_into_buffers(
110 store,
111 buckets,
112 &mut meta_loaded,
113 &state_rows,
114 &state_key,
115 capacity,
116 )?;
117 let emits = self.diff_emits(store, group_slots, &row_key, &combine)?;
118 self.persist_meta(store, meta_loaded)?;
119 Ok(emits)
120 }
121
122 pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
123 self.last_emit.flush(store)?;
124 self.meta.flush(store)?;
125 Ok(())
126 }
127
128 fn warm_and_load_meta<S: WindowStore>(
129 &mut self,
130 store: &mut S,
131 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
132 ) -> Result<MetaLoaded<G, C>> {
133 let meta_keys: Vec<MetaKey> = buckets
134 .keys()
135 .map(|(group, _)| group)
136 .collect::<BTreeSet<_>>()
137 .into_iter()
138 .map(meta_key_for)
139 .collect();
140 self.meta.warm(store, &meta_keys)?;
141
142 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
143 for (group, _) in buckets.keys() {
144 if !meta_loaded.contains_key(group) {
145 let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
146 meta_loaded.insert(group.clone(), m);
147 }
148 }
149 Ok(meta_loaded)
150 }
151
152 fn resolve_state_rows<S, SKF>(
153 &mut self,
154 store: &mut S,
155 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
156 meta_loaded: &MetaLoaded<G, C>,
157 state_key: &SKF,
158 ) -> Result<StateRows<G>>
159 where
160 S: WindowStore,
161 SKF: Fn(&G) -> EncodedKey,
162 {
163 let mut state_rows: StateRows<G> = HashMap::new();
164 let mut resolve_order: Vec<G> = Vec::new();
165 let mut state_lookup_keys: Vec<EncodedKey> = Vec::new();
166 let mut seen: BTreeSet<G> = BTreeSet::new();
167 for (group, coord) in buckets.keys() {
168 let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
169 if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
170 resolve_order.push(group.clone());
171 state_lookup_keys.push(state_key(group));
172 }
173 }
174 let resolved_rows = store.get_or_create_row_numbers(&state_lookup_keys)?;
175 reifydb_assertions! {
176 let resolved = resolved_rows.len();
177 let requested = state_lookup_keys.len();
178 assert!(
179 resolved == requested,
180 "get_or_create_row_numbers returned {resolved} rows for {requested} group keys; \
181 the zip below pairs resolve_order with resolved_rows by position, so a length \
182 mismatch would silently leave some groups without a state_rows entry and route \
183 them through the per-bucket get_or_create_row_number fallback, diverging behaviour"
184 );
185 }
186 let emit_keys: Vec<EmitKey> = resolved_rows.iter().map(|(rn, _)| EmitKey(*rn)).collect();
187 for (group, (state_row_number, _)) in resolve_order.into_iter().zip(resolved_rows) {
188 state_rows.insert(group, state_row_number);
189 }
190 self.last_emit.warm(store, &emit_keys)?;
191 Ok(state_rows)
192 }
193
194 fn apply_events_into_buffers<S, SKF>(
195 &mut self,
196 store: &mut S,
197 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
198 meta_loaded: &mut MetaLoaded<G, C>,
199 state_rows: &StateRows<G>,
200 state_key: &SKF,
201 capacity: usize,
202 ) -> Result<BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>>
203 where
204 S: WindowStore,
205 SKF: Fn(&G) -> EncodedKey,
206 {
207 let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>> = BTreeMap::new();
208
209 for ((group, coord), events) in buckets {
210 let meta = meta_loaded.entry(group.clone()).or_default();
211
212 let slot = match group_slots.get_mut(&group) {
213 Some(s) => s,
214 None => {
215 let state_row_number = match state_rows.get(&group) {
216 Some(&rn) => rn,
217 None => {
218 let key = state_key(&group);
219 let (rn, _is_new) = store.get_or_create_row_number(&key)?;
220 rn
221 }
222 };
223 let (buffer, loaded_coords): (MultiRollingBuffer<C, Accumulator>, Vec<u64>) =
224 load_buffer(store, state_row_number)?;
225 let prior_emit = self
226 .last_emit
227 .get(store, &EmitKey(state_row_number))?
228 .unwrap_or_default();
229 group_slots.insert(
230 group.clone(),
231 GroupSlot {
232 state_row_number,
233 buffer,
234 loaded_coords,
235 dirty: BTreeSet::new(),
236 prior_emit,
237 buffer_changed: false,
238 },
239 );
240 group_slots.get_mut(&group).expect("just inserted")
241 }
242 };
243
244 let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
245 let mut touched = false;
246 for event in events {
247 match event {
248 AccumulatorEvent::Add(c) => {
249 accumulator.add(&c);
250 touched = true;
251 }
252 AccumulatorEvent::Remove(c) => {
253 if accumulator.is_empty() {
254 continue;
255 }
256 accumulator.remove(&c);
257 touched = true;
258 }
259 }
260 }
261 if !accumulator.is_empty() {
262 slot.buffer.insert(coord, accumulator);
263 }
264 if !touched {
265 continue;
266 }
267 while slot.buffer.len() > capacity {
268 slot.buffer.pop_first();
269 }
270 slot.buffer_changed = true;
271 slot.dirty.insert(coord.order_key());
272
273 let next_high_water = match meta.high_water {
274 Some(hw) if hw > coord => hw,
275 _ => coord,
276 };
277 reifydb_assertions! {
278 assert!(
279 next_high_water >= coord,
280 "high_water regressed below the window coord it just admitted, so the next batch would \
281 treat an already-processed window as late and silently drop its events (coord={coord:?}, \
282 prev_high_water={prev:?}, next_high_water={next_high_water:?})",
283 prev = meta.high_water
284 );
285 if let Some(prev) = meta.high_water {
286 assert!(
287 next_high_water >= prev,
288 "high_water moved backwards across an admit, breaking the monotonic late-event \
289 cutoff that buried-window dropping relies on (coord={coord:?}, prev_high_water={prev:?}, \
290 next_high_water={next_high_water:?})"
291 );
292 }
293 }
294 meta.high_water = Some(next_high_water);
295 }
296
297 Ok(group_slots)
298 }
299
300 fn diff_emits<S, RKF, CB>(
301 &mut self,
302 store: &mut S,
303 group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>,
304 row_key: &RKF,
305 combine: &CB,
306 ) -> Result<Vec<MultiEmit<Output>>>
307 where
308 S: WindowStore,
309 RKF: Fn(&G, &SK) -> EncodedKey,
310 CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
311 {
312 let mut emits: Vec<MultiEmit<Output>> = Vec::new();
313
314 for (group, slot) in group_slots {
315 if !slot.buffer_changed {
316 continue;
317 }
318 let new_emit = combine(&group, &slot.buffer);
319
320 for (sk, new_out) in &new_emit {
321 let key = row_key(&group, sk);
322 let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
323 match slot.prior_emit.get(sk) {
324 Some(prior_out) => {
325 if prior_out != new_out {
326 emits.push(MultiEmit::Update {
327 row_number: rn,
328 prior: prior_out.clone(),
329 value: new_out.clone(),
330 });
331 }
332 }
333 None => {
334 emits.push(MultiEmit::Insert {
335 row_number: rn,
336 value: new_out.clone(),
337 });
338 }
339 }
340 }
341 for (sk, prior_out) in &slot.prior_emit {
342 if !new_emit.contains_key(sk) {
343 let key = row_key(&group, sk);
344 let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
345 emits.push(MultiEmit::Remove {
346 row_number: rn,
347 value: prior_out.clone(),
348 });
349 store.drop_row_number(&key)?;
350 }
351 }
352
353 persist_buffer(store, slot.state_row_number, &slot.buffer, &slot.loaded_coords, &slot.dirty)?;
354 if new_emit.is_empty() {
355 self.last_emit.remove(store, &EmitKey(slot.state_row_number))?;
356 } else {
357 self.last_emit.put(store, &EmitKey(slot.state_row_number), new_emit)?;
358 }
359 }
360
361 Ok(emits)
362 }
363
364 fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
365 for (group, meta) in meta_loaded {
366 self.meta.set(store, &meta_key_for(&group), &meta)?;
367 }
368 Ok(())
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use std::collections::BTreeMap;
375
376 use reifydb_codec::key::encoded::EncodedKey;
377
378 use super::{MultiEmit, MultiRollingBuffer, MultiRollingEngine};
379 use crate::window::engine::{
380 AccumulatorEvent,
381 config::WindowEngineConfig,
382 rolling::RollingBuckets,
383 test_support::{MockStore, SumAccumulator},
384 };
385
386 fn test_config() -> WindowEngineConfig {
387 WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
388 }
389
390 fn state_key(group: &u32) -> EncodedKey {
391 EncodedKey::builder().u32(*group).build()
392 }
393
394 fn row_key(group: &u32, sk: &u32) -> EncodedKey {
395 EncodedKey::builder().u32(*group).u32(*sk).build()
396 }
397
398 fn combine(_group: &u32, buffer: &MultiRollingBuffer<u64, SumAccumulator>) -> BTreeMap<u32, i64> {
399 let mut out = BTreeMap::new();
400 if !buffer.is_empty() {
401 out.insert(0u32, buffer.values().map(|a| a.sum).sum());
402 }
403 out
404 }
405
406 #[test]
407 fn group_state_survives_restart() {
408 let mut store = MockStore::default();
415
416 let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
417 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
418 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
419 let published = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
420 engine.flush(&mut store).unwrap();
421 assert_eq!(published.len(), 1);
422 let published_row = match &published[0] {
423 MultiEmit::Insert {
424 row_number,
425 value,
426 } => {
427 assert_eq!(*value, 5);
428 *row_number
429 }
430 _ => panic!("expected an Insert for the newly published group"),
431 };
432
433 let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
435 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
436 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
437 let withdrawn = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
438 engine.flush(&mut store).unwrap();
439
440 assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
441 match &withdrawn[0] {
442 MultiEmit::Remove {
443 row_number,
444 value,
445 } => {
446 assert_eq!(
447 *value, 5,
448 "the withdrawn value is the reloaded last_emit, not a stale or zeroed value"
449 );
450 assert_eq!(
451 *row_number, published_row,
452 "the withdrawal targets the same row that was published"
453 );
454 }
455 _ => panic!("the group emptied under retraction, so it must emit a terminal Remove"),
456 }
457 }
458
459 #[test]
460 fn withdrawn_ranking_reclaims_its_row_number_mapping() {
461 let mut store = MockStore::default();
466 let ranked_key = row_key(&1, &0);
469
470 let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
471 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
472 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
473 engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
474 engine.flush(&mut store).unwrap();
475 assert!(store.contains_row_mapping(&ranked_key), "publishing the ranking mints its mapping");
476
477 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
478 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
479 engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
480 engine.flush(&mut store).unwrap();
481 assert!(
482 !store.contains_row_mapping(&ranked_key),
483 "withdrawing the ranking must reclaim its row-number mapping, not leak it"
484 );
485 }
486
487 #[test]
488 fn group_state_survives_lru_eviction() {
489 let mut store = MockStore::default();
494 let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
495
496 let mut published_row_1 = None;
497 for group in 1u32..=11u32 {
498 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
499 buckets.insert((group, 10u64), vec![AccumulatorEvent::Add(i64::from(group))]);
500 let out = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
501 if group == 1 {
502 assert_eq!(out.len(), 1);
503 published_row_1 = match &out[0] {
504 MultiEmit::Insert {
505 row_number,
506 value,
507 } => {
508 assert_eq!(*value, 1);
509 Some(*row_number)
510 }
511 _ => panic!("expected an Insert for group 1"),
512 };
513 }
514 }
515 engine.flush(&mut store).unwrap();
516 let published_row_1 = published_row_1.expect("group 1 published an Insert");
517
518 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
521 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(1)]);
522 let withdrawn = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
523 engine.flush(&mut store).unwrap();
524
525 assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
526 match &withdrawn[0] {
527 MultiEmit::Remove {
528 row_number,
529 value,
530 } => {
531 assert_eq!(*value, 1, "the withdrawn value is the reloaded last_emit for group 1");
532 assert_eq!(
533 *row_number, published_row_1,
534 "the withdrawal targets the same row that was published for group 1"
535 );
536 }
537 _ => panic!("the evicted group emptied under retraction, so it must emit a terminal Remove"),
538 }
539 }
540 #[test]
541 fn per_coord_churn_matches_a_recomputed_ranking_oracle() {
542 const CAP: usize = 4;
551 let mut store = MockStore::default();
552 let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
553
554 let mut state = 0x1234_5678_9abc_def0u64;
555 let mut roll = |bound: u64| {
556 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
557 (state >> 33) % bound
558 };
559
560 let mut live: BTreeMap<u64, (i64, u64)> = BTreeMap::new();
561 let mut added: Vec<(u64, i64)> = Vec::new();
562 let mut visible: Option<i64> = None;
563 let mut coord_base = 100u64;
564
565 for round in 0..200u64 {
566 let mut plan: Vec<(u64, i64, bool)> = Vec::new();
567 for _ in 0..=roll(3) {
568 let coord = coord_base + roll(20);
569 let value = roll(1_000) as i64 + 1;
570 plan.push((coord, value, true));
571 added.push((coord, value));
572 }
573 if round % 3 == 2 && !added.is_empty() {
574 let (coord, value) = added.remove((roll(added.len() as u64)) as usize);
575 plan.push((coord, value, false));
576 }
577
578 for &(coord, value, is_add) in &plan {
579 let e = live.entry(coord).or_insert((0, 0));
580 if is_add {
581 e.0 += value;
582 e.1 += 1;
583 } else if e.1 > 0 {
584 e.0 -= value;
585 e.1 -= 1;
586 if e.1 == 0 {
587 live.remove(&coord);
588 }
589 } else {
590 live.remove(&coord);
591 }
592 }
593 while live.len() > CAP {
594 let &lowest = live.keys().next().unwrap();
595 live.remove(&lowest);
596 }
597
598 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
599 for &(coord, value, is_add) in &plan {
600 let ev = if is_add {
601 AccumulatorEvent::Add(value)
602 } else {
603 AccumulatorEvent::Remove(value)
604 };
605 buckets.entry((1u32, coord)).or_default().push(ev);
606 }
607 let emits = engine.apply(&mut store, buckets, CAP, state_key, row_key, combine).unwrap();
608 engine.flush(&mut store).unwrap();
609 for e in &emits {
610 match e {
611 MultiEmit::Insert {
612 value,
613 ..
614 }
615 | MultiEmit::Update {
616 value,
617 ..
618 } => visible = Some(*value),
619 MultiEmit::Remove {
620 ..
621 } => visible = None,
622 }
623 }
624
625 let oracle = if live.is_empty() {
626 None
627 } else {
628 Some(live.values().map(|(s, _)| *s).sum::<i64>())
629 };
630 assert_eq!(visible, oracle, "visible ranking diverged from the oracle after round {round}");
631 coord_base += roll(10);
632 }
633 }
634}