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::{Deserialize, Serialize, de::DeserializeOwned};
14
15use crate::window::{
16 accumulator::WindowAccumulator,
17 engine::{
18 AccumulatorEvent, EmitKind, GroupMeta, LatePolicy, MetaKey, config::WindowEngineConfig,
19 expiry_due_range, expiry_key, meta_key_for,
20 },
21 span::Slot,
22 state::StateCache,
23 store::WindowStore,
24};
25
26pub type RollingBuffer<C, Accumulator> = BTreeMap<C, Accumulator>;
27
28pub type RollingBuckets<G, C, Contribution> = BTreeMap<(G, C), Vec<AccumulatorEvent<Contribution>>>;
29
30pub struct RollingResult<G, Output> {
31 pub row_number: RowNumber,
32 pub group: G,
33 pub value: Output,
34 pub prior: Option<Output>,
35 pub kind: EmitKind,
36}
37
38pub enum RollingEviction<C: Slot> {
39 Capacity(usize),
40 Before(C),
41 BeforeStamp(u64),
42}
43
44pub enum RollingExpiry<G, Output> {
45 Update {
46 row_number: RowNumber,
47 group: G,
48 value: Output,
49 },
50 Remove {
51 row_number: RowNumber,
52 group: G,
53 },
54}
55
56#[derive(Clone, Copy)]
57enum IndexMode {
58 Coord,
59 Stamp,
60}
61
62#[derive(Serialize, Deserialize)]
63#[serde(bound(serialize = "G: Serialize", deserialize = "G: DeserializeOwned"))]
64struct RollingIndexEntry<G> {
65 group: G,
66 row_number: u64,
67}
68
69fn coord_min_key<C: Slot, A>(buffer: &RollingBuffer<C, A>) -> Option<u64> {
70 buffer.keys().next().map(|c| c.order_key())
71}
72
73fn stamp_min_key<C, A: WindowAccumulator>(buffer: &RollingBuffer<C, A>) -> Option<u64> {
74 buffer.values().filter_map(|a| a.stamp()).min()
75}
76
77type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
78type BufferRows<G> = HashMap<G, (RowNumber, bool)>;
79
80struct GroupSlot<C, Accumulator, Output> {
81 row_number: RowNumber,
82 is_new: bool,
83 buffer: RollingBuffer<C, Accumulator>,
84 was_empty_before: bool,
85 buffer_changed: bool,
86 prior_index_key: Option<u64>,
87 prior_output: Option<Output>,
88}
89
90pub struct RollingEngine<G, C, Accumulator> {
91 buffers: StateCache<RowNumber, RollingBuffer<C, Accumulator>>,
92 meta: StateCache<MetaKey, GroupMeta<C>>,
93 late_policy: LatePolicy,
94 _pd: PhantomData<G>,
95}
96
97impl<G, C, Accumulator> RollingEngine<G, C, Accumulator>
98where
99 G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
100 C: Slot + Hash + Serialize + DeserializeOwned,
101 Accumulator: WindowAccumulator,
102 for<'a> &'a G: IntoEncodedKey,
103{
104 pub fn new(config: WindowEngineConfig) -> Self {
105 Self {
106 buffers: StateCache::<RowNumber, RollingBuffer<C, Accumulator>>::new(
107 config.state_cache_capacity(),
108 ),
109 meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
110 late_policy: config.late_policy(),
111 _pd: PhantomData,
112 }
113 }
114
115 pub fn apply<S, K, CB, Output>(
116 &mut self,
117 store: &mut S,
118 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
119 capacity: usize,
120 row_key: K,
121 combine: CB,
122 ) -> Result<Vec<RollingResult<G, Output>>>
123 where
124 S: WindowStore,
125 K: Fn(&G) -> EncodedKey,
126 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
127 {
128 self.apply_evicting(
129 store,
130 buckets,
131 RollingEviction::Capacity(capacity),
132 row_key,
133 Accumulator::default,
134 combine,
135 )
136 }
137
138 pub fn apply_evicting<S, K, NA, CB, Output>(
139 &mut self,
140 store: &mut S,
141 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
142 eviction: RollingEviction<C>,
143 row_key: K,
144 new_accumulator: NA,
145 combine: CB,
146 ) -> Result<Vec<RollingResult<G, Output>>>
147 where
148 S: WindowStore,
149 K: Fn(&G) -> EncodedKey,
150 NA: Fn() -> Accumulator,
151 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
152 {
153 if buckets.is_empty() {
154 return Ok(Vec::new());
155 }
156 let index_mode = match eviction {
157 RollingEviction::Capacity(_) => None,
158 RollingEviction::Before(_) => Some(IndexMode::Coord),
159 RollingEviction::BeforeStamp(_) => Some(IndexMode::Stamp),
160 };
161 let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
162 let buffer_rows = self.resolve_buffer_rows(store, &buckets, &meta_loaded, &row_key)?;
163 let group_slots = self.apply_events_into_buffers(
164 store,
165 buckets,
166 &mut meta_loaded,
167 &buffer_rows,
168 &row_key,
169 &eviction,
170 &new_accumulator,
171 &combine,
172 index_mode,
173 )?;
174 let results = self.combine_and_collect(store, group_slots, &combine, index_mode)?;
175 self.persist_meta(store, meta_loaded)?;
176 Ok(results)
177 }
178
179 pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
180 self.buffers.flush(store)?;
181 self.meta.flush(store)?;
182 Ok(())
183 }
184
185 fn warm_and_load_meta<S: WindowStore>(
186 &mut self,
187 store: &mut S,
188 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
189 ) -> Result<MetaLoaded<G, C>> {
190 let meta_keys: Vec<MetaKey> = buckets
191 .keys()
192 .map(|(group, _)| group)
193 .collect::<BTreeSet<_>>()
194 .into_iter()
195 .map(meta_key_for)
196 .collect();
197 self.meta.warm(store, &meta_keys)?;
198
199 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
200 for (group, _) in buckets.keys() {
201 if !meta_loaded.contains_key(group) {
202 let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
203 meta_loaded.insert(group.clone(), m);
204 }
205 }
206 Ok(meta_loaded)
207 }
208
209 fn resolve_buffer_rows<S, K>(
210 &mut self,
211 store: &mut S,
212 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
213 meta_loaded: &MetaLoaded<G, C>,
214 row_key: &K,
215 ) -> Result<BufferRows<G>>
216 where
217 S: WindowStore,
218 K: Fn(&G) -> EncodedKey,
219 {
220 let mut buffer_rows: BufferRows<G> = HashMap::new();
221 let mut resolve_order: Vec<G> = Vec::new();
222 let mut group_keys: Vec<EncodedKey> = Vec::new();
223 let mut seen: BTreeSet<G> = BTreeSet::new();
224 for (group, coord) in buckets.keys() {
225 let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
226 if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
227 resolve_order.push(group.clone());
228 group_keys.push(row_key(group));
229 }
230 }
231 let resolved_rows = store.get_or_create_row_numbers(&group_keys)?;
232 reifydb_assertions! {
233 let requested = group_keys.len();
234 let resolved = resolved_rows.len();
235 assert!(
236 requested == resolved,
237 "get_or_create_row_numbers returned a different count than requested, so the resolve_order \
238 zip would silently truncate buffer_rows and survivor groups would be re-resolved one at a \
239 time in apply_events_into_buffers, changing the per-batch row-number lookup cost \
240 (requested={requested}, resolved={resolved})"
241 );
242 }
243 let buffer_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
244 for (group, resolved) in resolve_order.into_iter().zip(resolved_rows) {
245 buffer_rows.insert(group, resolved);
246 }
247 self.buffers.warm(store, &buffer_keys)?;
248 Ok(buffer_rows)
249 }
250
251 #[allow(clippy::too_many_arguments)]
252 fn apply_events_into_buffers<S, K, NA, CB, Output>(
253 &mut self,
254 store: &mut S,
255 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
256 meta_loaded: &mut MetaLoaded<G, C>,
257 buffer_rows: &BufferRows<G>,
258 row_key: &K,
259 eviction: &RollingEviction<C>,
260 new_accumulator: &NA,
261 combine: &CB,
262 index_mode: Option<IndexMode>,
263 ) -> Result<BTreeMap<G, GroupSlot<C, Accumulator, Output>>>
264 where
265 S: WindowStore,
266 K: Fn(&G) -> EncodedKey,
267 NA: Fn() -> Accumulator,
268 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
269 {
270 let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Output>> = BTreeMap::new();
271
272 for ((group, coord), events) in buckets {
273 let meta = meta_loaded.entry(group.clone()).or_default();
274
275 let slot = match group_slots.get_mut(&group) {
276 Some(s) => s,
277 None => {
278 let (row_number, is_new) = match buffer_rows.get(&group) {
279 Some(&resolved) => resolved,
280 None => {
281 let key = row_key(&group);
282 store.get_or_create_row_number(&key)?
283 }
284 };
285 let buffer: RollingBuffer<C, Accumulator> =
286 self.buffers.get(store, &row_number)?.unwrap_or_default();
287 let was_empty_before = buffer.is_empty();
288 let prior_output = if was_empty_before {
289 None
290 } else {
291 combine(&group, &buffer)
292 };
293 let prior_index_key = match index_mode {
294 Some(IndexMode::Coord) => coord_min_key(&buffer),
295 Some(IndexMode::Stamp) => stamp_min_key(&buffer),
296 None => None,
297 };
298 group_slots.insert(
299 group.clone(),
300 GroupSlot {
301 row_number,
302 is_new,
303 buffer,
304 was_empty_before,
305 buffer_changed: false,
306 prior_index_key,
307 prior_output,
308 },
309 );
310 group_slots.get_mut(&group).expect("just inserted")
311 }
312 };
313
314 let late = matches!(meta.high_water, Some(hw) if coord < hw)
315 && matches!(self.late_policy, LatePolicy::Drop)
316 && !slot.buffer.contains_key(&coord);
317
318 let mut accumulator = slot.buffer.remove(&coord).unwrap_or_else(new_accumulator);
319 let mut touched = false;
320 for event in events {
321 match event {
322 AccumulatorEvent::Add(c) => {
323 if late {
324 continue;
325 }
326 accumulator.add(&c);
327 touched = true;
328 }
329 AccumulatorEvent::Remove(c) => {
330 if accumulator.is_empty() {
331 continue;
332 }
333 accumulator.remove(&c);
334 touched = true;
335 }
336 }
337 }
338 if !accumulator.is_empty() {
339 slot.buffer.insert(coord, accumulator);
340 }
341 if !touched {
342 continue;
343 }
344 match eviction {
345 RollingEviction::Capacity(cap) => {
346 while slot.buffer.len() > *cap {
347 slot.buffer.pop_first();
348 }
349 }
350 RollingEviction::Before(cutoff) => {
351 while let Some((&oldest, _)) = slot.buffer.iter().next() {
352 if oldest <= *cutoff {
353 slot.buffer.pop_first();
354 } else {
355 break;
356 }
357 }
358 }
359 RollingEviction::BeforeStamp(cutoff) => {
360 let stale: Vec<C> = slot
361 .buffer
362 .iter()
363 .filter(|(_, accumulator)| {
364 accumulator.stamp().is_some_and(|s| s <= *cutoff)
365 })
366 .map(|(coord, _)| *coord)
367 .collect();
368 for coord in stale {
369 slot.buffer.remove(&coord);
370 }
371 }
372 }
373 slot.buffer_changed = true;
374
375 meta.high_water = Some(match meta.high_water {
376 Some(hw) if hw > coord => hw,
377 _ => coord,
378 });
379 }
380 Ok(group_slots)
381 }
382
383 fn combine_and_collect<S, CB, Output>(
384 &mut self,
385 store: &mut S,
386 group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Output>>,
387 combine: &CB,
388 index_mode: Option<IndexMode>,
389 ) -> Result<Vec<RollingResult<G, Output>>>
390 where
391 S: WindowStore,
392 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
393 {
394 let mut results: Vec<RollingResult<G, Output>> = Vec::new();
395 for (group, slot) in group_slots {
396 if !slot.buffer_changed {
397 continue;
398 }
399 if let Some(mode) = index_mode {
400 let new_index_key = match mode {
401 IndexMode::Coord => coord_min_key(&slot.buffer),
402 IndexMode::Stamp => stamp_min_key(&slot.buffer),
403 };
404 if new_index_key != slot.prior_index_key {
405 if let Some(old) = slot.prior_index_key {
406 store.internal_drop(&expiry_key(old, &group, &[]))?;
407 }
408 if let Some(new) = new_index_key {
409 store.internal_set(
410 &expiry_key(new, &group, &[]),
411 &RollingIndexEntry {
412 group: group.clone(),
413 row_number: slot.row_number.0,
414 },
415 )?;
416 }
417 }
418 }
419 let output = combine(&group, &slot.buffer);
420 self.buffers.put(store, &slot.row_number, slot.buffer)?;
421
422 if let Some(out) = output {
423 let kind = if slot.is_new || slot.was_empty_before {
424 EmitKind::Insert
425 } else {
426 EmitKind::Update
427 };
428 results.push(RollingResult {
429 row_number: slot.row_number,
430 group,
431 value: out,
432 prior: None,
433 kind,
434 });
435 } else if let Some(prior) = slot.prior_output {
436 results.push(RollingResult {
437 row_number: slot.row_number,
438 group,
439 value: prior,
440 prior: None,
441 kind: EmitKind::Remove,
442 });
443 }
444 }
445 Ok(results)
446 }
447
448 pub fn expire_before<S, CB, Output>(
449 &mut self,
450 store: &mut S,
451 cutoff: C,
452 combine: CB,
453 ) -> Result<Vec<RollingExpiry<G, Output>>>
454 where
455 S: WindowStore,
456 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
457 {
458 let mut due: Vec<(EncodedKey, RollingIndexEntry<G>)> = Vec::new();
459 store.internal_range_visit::<RollingIndexEntry<G>>(
460 expiry_due_range(cutoff.order_key()),
461 &mut |key, entry| {
462 due.push((key, entry));
463 Ok(())
464 },
465 )?;
466
467 let mut out: Vec<RollingExpiry<G, Output>> = Vec::new();
468 for (index_key, entry) in due {
469 let row_number = RowNumber(entry.row_number);
470 store.internal_drop(&index_key)?;
471 let Some(mut buffer) = self.buffers.get(store, &row_number)? else {
472 continue;
473 };
474 let before = buffer.len();
475 buffer.retain(|&coord, _| coord > cutoff);
476 if buffer.len() == before {
477 if let Some(new) = coord_min_key(&buffer) {
478 store.internal_set(
479 &expiry_key(new, &entry.group, &[]),
480 &RollingIndexEntry {
481 group: entry.group.clone(),
482 row_number: entry.row_number,
483 },
484 )?;
485 }
486 continue;
487 }
488 match combine(&entry.group, &buffer) {
489 Some(value) if !buffer.is_empty() => {
490 if let Some(new) = coord_min_key(&buffer) {
491 store.internal_set(
492 &expiry_key(new, &entry.group, &[]),
493 &RollingIndexEntry {
494 group: entry.group.clone(),
495 row_number: entry.row_number,
496 },
497 )?;
498 }
499 self.buffers.put(store, &row_number, buffer)?;
500 out.push(RollingExpiry::Update {
501 row_number,
502 group: entry.group,
503 value,
504 });
505 }
506 _ => {
507 self.buffers.remove(store, &row_number)?;
508 out.push(RollingExpiry::Remove {
509 row_number,
510 group: entry.group,
511 });
512 }
513 }
514 }
515 Ok(out)
516 }
517
518 pub fn expire_before_stamp<S, CB, Output>(
519 &mut self,
520 store: &mut S,
521 cutoff: u64,
522 combine: CB,
523 ) -> Result<Vec<RollingExpiry<G, Output>>>
524 where
525 S: WindowStore,
526 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
527 {
528 let mut due: Vec<(EncodedKey, RollingIndexEntry<G>)> = Vec::new();
529 store.internal_range_visit::<RollingIndexEntry<G>>(expiry_due_range(cutoff), &mut |key, entry| {
530 due.push((key, entry));
531 Ok(())
532 })?;
533
534 let mut out: Vec<RollingExpiry<G, Output>> = Vec::new();
535 for (index_key, entry) in due {
536 let row_number = RowNumber(entry.row_number);
537 store.internal_drop(&index_key)?;
538 let Some(mut buffer) = self.buffers.get(store, &row_number)? else {
539 continue;
540 };
541 let before = buffer.len();
542 buffer.retain(|_, accumulator| accumulator.stamp().is_none_or(|s| s > cutoff));
543 if buffer.len() == before {
544 if let Some(new) = stamp_min_key(&buffer) {
545 store.internal_set(
546 &expiry_key(new, &entry.group, &[]),
547 &RollingIndexEntry {
548 group: entry.group.clone(),
549 row_number: entry.row_number,
550 },
551 )?;
552 }
553 continue;
554 }
555 match combine(&entry.group, &buffer) {
556 Some(value) if !buffer.is_empty() => {
557 if let Some(new) = stamp_min_key(&buffer) {
558 store.internal_set(
559 &expiry_key(new, &entry.group, &[]),
560 &RollingIndexEntry {
561 group: entry.group.clone(),
562 row_number: entry.row_number,
563 },
564 )?;
565 }
566 self.buffers.put(store, &row_number, buffer)?;
567 out.push(RollingExpiry::Update {
568 row_number,
569 group: entry.group,
570 value,
571 });
572 }
573 _ => {
574 self.buffers.remove(store, &row_number)?;
575 out.push(RollingExpiry::Remove {
576 row_number,
577 group: entry.group,
578 });
579 }
580 }
581 }
582 Ok(out)
583 }
584
585 fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
586 for (group, meta) in meta_loaded {
587 self.meta.set(store, &meta_key_for(&group), &meta)?;
588 }
589 Ok(())
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use std::collections::BTreeMap;
596
597 use reifydb_codec::key::encoded::EncodedKey;
598
599 use crate::window::engine::{
600 AccumulatorEvent, EmitKind,
601 config::WindowEngineConfig,
602 rolling::{
603 RollingBuckets, RollingBuffer, RollingEngine, RollingEviction, RollingExpiry, RollingResult,
604 },
605 test_support::{MockStore, StampedSum, SumAccumulator},
606 };
607
608 fn test_config() -> WindowEngineConfig {
609 WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
610 }
611
612 fn row_key(group: &u32) -> EncodedKey {
613 EncodedKey::builder().u32(*group).build()
614 }
615
616 fn sum_combine(_group: &u32, buffer: &RollingBuffer<u64, SumAccumulator>) -> Option<i64> {
617 if buffer.is_empty() {
618 None
619 } else {
620 Some(buffer.values().map(|a| a.sum).sum())
621 }
622 }
623
624 fn stamped_combine(_group: &u32, buffer: &RollingBuffer<u64, StampedSum>) -> Option<i64> {
625 if buffer.is_empty() {
626 None
627 } else {
628 Some(buffer.values().map(|a| a.sum).sum())
629 }
630 }
631
632 #[test]
633 fn expire_before_evicts_a_quiet_group_then_rekeys_then_removes() {
634 let mut store = MockStore::default();
635 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
636 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
637 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(1)]);
638 buckets.insert((1u32, 20u64), vec![AccumulatorEvent::Add(2)]);
639 buckets.insert((1u32, 30u64), vec![AccumulatorEvent::Add(3)]);
640 engine.apply_evicting(
642 &mut store,
643 buckets,
644 RollingEviction::Before(0),
645 row_key,
646 SumAccumulator::default,
647 sum_combine,
648 )
649 .unwrap();
650 engine.flush(&mut store).unwrap();
651 assert_eq!(store.index_entry_count(), 1, "the group is indexed by its oldest coord");
652
653 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
655 let out = engine.expire_before(&mut store, 20, sum_combine).unwrap();
656 engine.flush(&mut store).unwrap();
657 assert_eq!(out.len(), 1);
658 match &out[0] {
659 RollingExpiry::Update {
660 group,
661 value,
662 ..
663 } => {
664 assert_eq!(*group, 1);
665 assert_eq!(*value, 3, "only the surviving coord 30 contributes");
666 }
667 RollingExpiry::Remove {
668 ..
669 } => panic!("group still has a live coord"),
670 }
671 assert_eq!(store.index_entry_count(), 1, "still one entry, re-keyed to coord 30");
672
673 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
675 let out = engine.expire_before(&mut store, 30, sum_combine).unwrap();
676 engine.flush(&mut store).unwrap();
677 assert_eq!(out.len(), 1);
678 match &out[0] {
679 RollingExpiry::Remove {
680 group,
681 ..
682 } => assert_eq!(*group, 1),
683 RollingExpiry::Update {
684 ..
685 } => panic!("the group is empty and must be removed"),
686 }
687 assert_eq!(store.index_entry_count(), 0, "the emptied group leaves no index entry");
688
689 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
691 assert!(engine.expire_before(&mut store, 1000, sum_combine).unwrap().is_empty());
692 }
693
694 #[test]
695 fn expire_before_leaves_groups_whose_oldest_coord_is_not_due() {
696 let mut store = MockStore::default();
697 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
698 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
699 buckets.insert((1u32, 100u64), vec![AccumulatorEvent::Add(1)]);
700 buckets.insert((2u32, 5u64), vec![AccumulatorEvent::Add(9)]);
701 engine.apply_evicting(
702 &mut store,
703 buckets,
704 RollingEviction::Before(0),
705 row_key,
706 SumAccumulator::default,
707 sum_combine,
708 )
709 .unwrap();
710 engine.flush(&mut store).unwrap();
711 assert_eq!(store.index_entry_count(), 2);
712
713 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
715 let out = engine.expire_before(&mut store, 5, sum_combine).unwrap();
716 engine.flush(&mut store).unwrap();
717 assert_eq!(out.len(), 1, "only the group with a due coord is processed");
718 assert!(matches!(&out[0], RollingExpiry::Remove { group, .. } if *group == 2));
719 assert_eq!(store.index_entry_count(), 1, "group 1 keeps its index entry");
720 }
721
722 #[test]
723 fn expire_before_stamp_evicts_by_accumulator_stamp() {
724 let mut store = MockStore::default();
725 let mut engine = RollingEngine::<u32, u64, StampedSum>::new(test_config());
726 let mut buckets: RollingBuckets<u32, u64, (i64, u64)> = BTreeMap::new();
727 buckets.insert((1u32, 1u64), vec![AccumulatorEvent::Add((1, 10))]);
728 buckets.insert((1u32, 2u64), vec![AccumulatorEvent::Add((2, 20))]);
729 buckets.insert((1u32, 3u64), vec![AccumulatorEvent::Add((3, 30))]);
730 engine.apply_evicting(
731 &mut store,
732 buckets,
733 RollingEviction::BeforeStamp(0),
734 row_key,
735 StampedSum::default,
736 stamped_combine,
737 )
738 .unwrap();
739 engine.flush(&mut store).unwrap();
740 assert_eq!(store.index_entry_count(), 1, "indexed by the minimum stamp");
741
742 let mut engine = RollingEngine::<u32, u64, StampedSum>::new(test_config());
744 let out = engine.expire_before_stamp(&mut store, 20, stamped_combine).unwrap();
745 engine.flush(&mut store).unwrap();
746 assert_eq!(out.len(), 1);
747 match &out[0] {
748 RollingExpiry::Update {
749 value,
750 ..
751 } => assert_eq!(*value, 3),
752 RollingExpiry::Remove {
753 ..
754 } => panic!("a live entry remains"),
755 }
756 assert_eq!(store.index_entry_count(), 1, "re-keyed to the surviving stamp");
757 }
758
759 #[test]
760 fn withdrawn_value_is_reconstructed_after_restart() {
761 let mut store = MockStore::default();
771
772 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
773 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
774 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
775 let published: Vec<RollingResult<u32, i64>> =
776 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
777 engine.flush(&mut store).unwrap();
778 assert_eq!(published.len(), 1);
779 assert!(matches!(published[0].kind, EmitKind::Insert));
780 assert_eq!(published[0].value, 5);
781
782 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
785 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
786 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
787 let withdrawn: Vec<RollingResult<u32, i64>> =
788 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
789 engine.flush(&mut store).unwrap();
790
791 assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
792 assert!(
793 matches!(withdrawn[0].kind, EmitKind::Remove),
794 "the group emptied under retraction, so the last published row must be withdrawn"
795 );
796 assert_eq!(
797 withdrawn[0].value, 5,
798 "the withdrawn value is the reconstructed last-published output, not a stale or zeroed value"
799 );
800 assert_eq!(
801 withdrawn[0].row_number, published[0].row_number,
802 "the withdrawal targets the same row that was published"
803 );
804 }
805
806 #[test]
807 fn buffer_survives_lru_eviction() {
808 let mut store = MockStore::default();
816 let mut engine = RollingEngine::<u32, u64, SumAccumulator>::new(test_config());
817
818 let mut published_group_1: Vec<RollingResult<u32, i64>> = Vec::new();
819 for group in 1u32..=11u32 {
820 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
821 buckets.insert((group, 10u64), vec![AccumulatorEvent::Add(i64::from(group))]);
822 let out: Vec<RollingResult<u32, i64>> =
823 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
824 if group == 1 {
825 published_group_1 = out;
826 }
827 }
828 engine.flush(&mut store).unwrap();
829 assert_eq!(published_group_1.len(), 1);
830 assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
831 assert_eq!(published_group_1[0].value, 1);
832
833 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
836 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(1)]);
837 let withdrawn: Vec<RollingResult<u32, i64>> =
838 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
839 engine.flush(&mut store).unwrap();
840
841 assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
842 assert!(
843 matches!(withdrawn[0].kind, EmitKind::Remove),
844 "the evicted group emptied under retraction, so the last published row must be withdrawn"
845 );
846 assert_eq!(
847 withdrawn[0].value, 1,
848 "the withdrawn value is reconstructed from the evicted group's persisted buffer"
849 );
850 assert_eq!(
851 withdrawn[0].row_number, published_group_1[0].row_number,
852 "the withdrawal targets the same row that was published for group 1"
853 );
854 }
855}