1use std::{
5 collections::{BTreeMap, BTreeSet, HashMap},
6 fmt::Debug,
7 hash::Hash,
8 marker::PhantomData,
9 ops::Bound,
10};
11
12use reifydb_codec::{
13 key::encoded::{EncodedKey, IntoEncodedKey},
14 row::operator::OperatorState,
15};
16use reifydb_core::{
17 key::operator_state::{GroupId, GroupStateKey},
18 metrics::heap::HeapSize,
19 state::{cache::StateCache, store::StateStore},
20};
21use reifydb_macro::operator_state;
22use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
23
24use crate::{
25 operator::state::{
26 expiry::{expiry_drop, expiry_due, expiry_earliest, expiry_key, expiry_set},
27 seal::coord::{Coord, IsZero},
28 },
29 window::{
30 accumulator::WindowAccumulator,
31 engine::{
32 AccumulatorEvent, BatchMeta, BufferKey, EmitKind, GroupMeta, MetaKey, RunningKey,
33 config::WindowEngineConfig, load_batch_meta, meta_key_for, note_when_expiry_capped,
34 persist_batch_meta, sweep_stale_meta,
35 },
36 span::Slot,
37 },
38};
39
40pub type RollingBuffer<C, Accumulator> = BTreeMap<C, Accumulator>;
41
42pub type RollingBuckets<G, C, Contribution> = BTreeMap<(G, C), Vec<AccumulatorEvent<Contribution>>>;
43
44pub struct RollingResult<G, Output> {
45 pub row_number: RowNumber,
46 pub group: G,
47 pub value: Output,
48 pub prior: Option<Output>,
49 pub kind: EmitKind,
50}
51
52pub enum RollingEviction<C: Slot> {
53 Capacity(usize),
54 Before(C),
55 Nothing,
56}
57
58pub enum RollingExpiry<G, Output> {
59 Update {
60 row_number: RowNumber,
61 group: G,
62 group_id: GroupId,
63 value: Output,
64 },
65 Remove {
66 row_number: RowNumber,
67 group: G,
68 group_id: GroupId,
69 },
70}
71
72#[operator_state]
73#[derive(Clone)]
74pub struct RollingIndexEntry<G> {
75 group: G,
76 slot_key: Vec<u8>,
77 group_id: u64,
78}
79
80fn coord_min_key<C: Slot, A>(buffer: &RollingBuffer<C, A>) -> Option<u64> {
81 buffer.keys().next().map(|c| c.order_key().to_order())
82}
83
84type MetaLoaded<G, C> = HashMap<G, BatchMeta<C>>;
85type BufferRows<G> = HashMap<G, (GroupId, EncodedKey)>;
86
87struct GroupSlot<C, Accumulator, Output> {
88 group_id: GroupId,
89 key: EncodedKey,
90 buffer: RollingBuffer<C, Accumulator>,
91 buffer_changed: bool,
92 prior_index_key: Option<u64>,
93 prior_output: Option<Output>,
94}
95
96pub struct RollingEngine<G, C: Slot, Accumulator> {
97 buffers: StateCache<BufferKey, RollingBuffer<C, Accumulator>>,
98 running: Option<StateCache<RunningKey, Accumulator>>,
99 meta: StateCache<MetaKey, GroupMeta<C>>,
100 meta_low_water: Option<u64>,
101 expire_batch: usize,
102 lag: <C::Coord as Coord>::Span,
103 _pd: PhantomData<G>,
104}
105
106struct RunnableGroupSlot<C: Slot, Accumulator>
107where
108 Accumulator: WindowAccumulator,
109{
110 group_id: GroupId,
111 key: EncodedKey,
112 buffer: RollingBuffer<C, Accumulator>,
113 running: Accumulator,
114 buffer_changed: bool,
115 prior_min: Option<u64>,
116 old_frontier: Option<C::Coord>,
117 prior_output: Option<Accumulator::Output>,
118}
119
120fn merge_into<A: WindowAccumulator>(running: &mut A, other: &A) {
121 if running.is_empty() {
122 *running = other.clone();
123 } else {
124 running.merge(other);
125 }
126}
127
128fn frontier_for<C: Slot>(lag: <C::Coord as Coord>::Span, high_water: &Option<C>) -> Option<C::Coord> {
129 if lag.is_zero() {
130 Some(<C::Coord as Coord>::MAX)
131 } else {
132 high_water.as_ref().map(|hw| hw.order_key().saturating_sub_span(lag))
133 }
134}
135
136fn is_merged_coord<C: Coord>(coord: C, frontier: Option<C>) -> bool {
137 frontier.is_some_and(|f| coord <= f)
138}
139
140fn running_below<C: Slot, A: WindowAccumulator>(buffer: &RollingBuffer<C, A>, frontier: Option<C::Coord>) -> A {
141 let mut running = A::default();
142 let Some(frontier) = frontier else {
143 return running;
144 };
145 for (coord, accumulator) in buffer.iter() {
146 if coord.order_key() > frontier {
147 break;
148 }
149 merge_into(&mut running, accumulator);
150 }
151 running
152}
153
154impl<G, C, Accumulator> RollingEngine<G, C, Accumulator>
155where
156 G: Clone + Eq + Ord + Hash + Debug,
157 C: Slot + Hash + HeapSize,
158 Accumulator: WindowAccumulator,
159 for<'a> &'a G: IntoEncodedKey,
160 GroupMeta<C>: OperatorState,
161 RollingIndexEntry<G>: OperatorState,
162 RollingBuffer<C, Accumulator>: OperatorState,
163{
164 pub fn new(config: WindowEngineConfig) -> Self {
165 Self {
166 buffers: StateCache::<BufferKey, RollingBuffer<C, Accumulator>>::new(),
167 running: None,
168 meta: StateCache::<MetaKey, GroupMeta<C>>::new(),
169 meta_low_water: None,
170 expire_batch: config.expire_batch(),
171 lag: Default::default(),
172 _pd: PhantomData,
173 }
174 }
175
176 pub fn new_runnable(config: WindowEngineConfig) -> Self {
177 let mut engine = Self::new(config);
178 engine.running = Some(StateCache::<RunningKey, Accumulator>::new());
179 engine
180 }
181
182 pub fn with_lag(mut self, lag: <C::Coord as Coord>::Span) -> Self {
183 self.lag = lag;
184 self
185 }
186
187 pub fn apply<K, CB, Output>(
188 &mut self,
189 store: &mut dyn StateStore,
190 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
191 capacity: usize,
192 row_key: K,
193 combine: CB,
194 ) -> Result<Vec<RollingResult<G, Output>>>
195 where
196 K: Fn(&G) -> (GroupId, EncodedKey),
197 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
198 {
199 self.apply_evicting(
200 store,
201 buckets,
202 RollingEviction::Capacity(capacity),
203 row_key,
204 Accumulator::default,
205 combine,
206 )
207 }
208
209 pub fn apply_evicting<K, NA, CB, Output>(
210 &mut self,
211 store: &mut dyn StateStore,
212 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
213 eviction: RollingEviction<C>,
214 row_key: K,
215 new_accumulator: NA,
216 combine: CB,
217 ) -> Result<Vec<RollingResult<G, Output>>>
218 where
219 K: Fn(&G) -> (GroupId, EncodedKey),
220 NA: Fn() -> Accumulator,
221 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
222 {
223 if buckets.is_empty() {
224 return Ok(Vec::new());
225 }
226 let indexed = matches!(eviction, RollingEviction::Before(_) | RollingEviction::Nothing);
227 let mut meta_loaded = self.load_meta(store, &buckets)?;
228 let buffer_rows = self.resolve_buffer_rows(&buckets, &meta_loaded, &row_key)?;
229 let group_slots = self.apply_events_into_buffers(
230 store,
231 buckets,
232 &mut meta_loaded,
233 &buffer_rows,
234 &row_key,
235 &eviction,
236 &new_accumulator,
237 &combine,
238 indexed,
239 )?;
240 let results = self.combine_and_collect(store, group_slots, &combine, indexed)?;
241 self.persist_meta(store, meta_loaded)?;
242 Ok(results)
243 }
244
245 fn load_meta(
246 &mut self,
247 store: &mut dyn StateStore,
248 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
249 ) -> Result<MetaLoaded<G, C>> {
250 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
251 for (group, _) in buckets.keys() {
252 if !meta_loaded.contains_key(group) {
253 let batch = load_batch_meta(store, &mut self.meta, &meta_key_for(group))?;
254 meta_loaded.insert(group.clone(), batch);
255 }
256 }
257 Ok(meta_loaded)
258 }
259
260 fn resolve_buffer_rows<K>(
261 &mut self,
262 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
263 meta_loaded: &MetaLoaded<G, C>,
264 row_key: &K,
265 ) -> Result<BufferRows<G>>
266 where
267 K: Fn(&G) -> (GroupId, EncodedKey),
268 {
269 let mut buffer_rows: BufferRows<G> = HashMap::new();
270 let mut seen: BTreeSet<G> = BTreeSet::new();
271 for (group, coord) in buckets.keys() {
272 let initial_high_water = meta_loaded.get(group).and_then(|m| m.initial);
273 if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
274 let (id, key) = row_key(group);
275 buffer_rows.insert(group.clone(), (id, key));
276 }
277 }
278 Ok(buffer_rows)
279 }
280
281 #[allow(clippy::too_many_arguments)]
282 fn apply_events_into_buffers<K, NA, CB, Output>(
283 &mut self,
284 store: &mut dyn StateStore,
285 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
286 meta_loaded: &mut MetaLoaded<G, C>,
287 buffer_rows: &BufferRows<G>,
288 row_key: &K,
289 eviction: &RollingEviction<C>,
290 new_accumulator: &NA,
291 combine: &CB,
292 indexed: bool,
293 ) -> Result<BTreeMap<G, GroupSlot<C, Accumulator, Output>>>
294 where
295 K: Fn(&G) -> (GroupId, EncodedKey),
296 NA: Fn() -> Accumulator,
297 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
298 {
299 let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Output>> = BTreeMap::new();
300
301 for ((group, coord), events) in buckets {
302 let meta = meta_loaded.entry(group.clone()).or_default();
303
304 let slot = match group_slots.get_mut(&group) {
305 Some(s) => s,
306 None => {
307 let (group_id, key) = match buffer_rows.get(&group) {
308 Some(resolved) => resolved.clone(),
309 None => row_key(&group),
310 };
311 let buffer: RollingBuffer<C, Accumulator> = self
312 .buffers
313 .get(store, &BufferKey::new(group_id, key.clone()))?
314 .unwrap_or_default();
315 let was_empty_before = buffer.is_empty();
316 let prior_output = if was_empty_before {
317 None
318 } else {
319 combine(&group, &buffer)
320 };
321 let prior_index_key = if indexed {
322 coord_min_key(&buffer)
323 } else {
324 None
325 };
326 group_slots.insert(
327 group.clone(),
328 GroupSlot {
329 group_id,
330 key,
331 buffer,
332 buffer_changed: false,
333 prior_index_key,
334 prior_output,
335 },
336 );
337 group_slots.get_mut(&group).expect("just inserted")
338 }
339 };
340
341 let mut accumulator = slot.buffer.remove(&coord).unwrap_or_else(new_accumulator);
342 let mut touched = false;
343 for event in events {
344 match event {
345 AccumulatorEvent::Add(c) => {
346 accumulator.add(&c);
347 touched = true;
348 }
349 AccumulatorEvent::Remove(c) => {
350 if accumulator.is_empty() {
351 continue;
352 }
353 accumulator.remove(&c);
354 touched = true;
355 }
356 }
357 }
358 if !accumulator.is_empty() {
359 slot.buffer.insert(coord, accumulator);
360 }
361 if !touched {
362 continue;
363 }
364 match eviction {
365 RollingEviction::Capacity(cap) => {
366 while slot.buffer.len() > *cap {
367 slot.buffer.pop_first();
368 }
369 }
370 RollingEviction::Before(cutoff) => {
371 while let Some((&oldest, _)) = slot.buffer.iter().next() {
372 if oldest <= *cutoff {
373 slot.buffer.pop_first();
374 } else {
375 break;
376 }
377 }
378 }
379 RollingEviction::Nothing => {}
380 }
381 slot.buffer_changed = true;
382
383 meta.observe(coord);
384 }
385 Ok(group_slots)
386 }
387
388 fn combine_and_collect<CB, Output>(
389 &mut self,
390 store: &mut dyn StateStore,
391 group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Output>>,
392 combine: &CB,
393 indexed: bool,
394 ) -> Result<Vec<RollingResult<G, Output>>>
395 where
396 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
397 {
398 let mut pairs: Vec<(GroupId, EncodedKey)> = Vec::new();
399 let mut pending: Vec<(G, Output, bool)> = Vec::new();
400 for (group, slot) in group_slots {
401 if !slot.buffer_changed {
402 continue;
403 }
404 if indexed {
405 let new_index_key = coord_min_key(&slot.buffer);
406 if new_index_key != slot.prior_index_key {
407 if let Some(old) = slot.prior_index_key {
408 expiry_drop(store, &expiry_key(old, &group, &[]))?;
409 }
410 if let Some(new) = new_index_key {
411 expiry_set(
412 store,
413 expiry_key(new, &group, &[]),
414 RollingIndexEntry {
415 group: group.clone(),
416 slot_key: slot.key.as_bytes().to_vec(),
417 group_id: slot.group_id.0,
418 },
419 )?;
420 }
421 }
422 }
423 let output = combine(&group, &slot.buffer);
424 if slot.buffer.is_empty() {
425 self.buffers.remove(store, &BufferKey::new(slot.group_id, slot.key.clone()))?;
426 } else {
427 self.buffers.put(
428 store,
429 &BufferKey::new(slot.group_id, slot.key.clone()),
430 slot.buffer,
431 )?;
432 }
433
434 if let Some(out) = output {
435 pairs.push((slot.group_id, slot.key));
436 pending.push((group, out, false));
437 } else if let Some(prior) = slot.prior_output {
438 pairs.push((slot.group_id, slot.key));
439 pending.push((group, prior, true));
440 }
441 }
442
443 if pairs.is_empty() {
444 return Ok(Vec::new());
445 }
446 let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
447 let mut results: Vec<RollingResult<G, Output>> = Vec::with_capacity(pending.len());
448 for (((group, value, withdrawn), (group_id, key)), (row_number, is_new)) in
449 pending.into_iter().zip(pairs).zip(rows)
450 {
451 if withdrawn {
452 store.remove_row_number(group_id, &key)?;
453 results.push(RollingResult {
454 row_number,
455 group,
456 value,
457 prior: None,
458 kind: EmitKind::Remove,
459 });
460 } else {
461 let kind = if is_new {
462 EmitKind::Insert
463 } else {
464 EmitKind::Update
465 };
466 results.push(RollingResult {
467 row_number,
468 group,
469 value,
470 prior: None,
471 kind,
472 });
473 }
474 }
475 Ok(results)
476 }
477
478 fn load_running(
479 &mut self,
480 store: &mut dyn StateStore,
481 buffer: &RollingBuffer<C, Accumulator>,
482 group_id: GroupId,
483 slot: &EncodedKey,
484 frontier: Option<C::Coord>,
485 ) -> Result<Accumulator> {
486 let running_cache = self.running.as_mut().expect("runnable engine has a running cache");
487 if let Some(running) = running_cache.get(store, &RunningKey::new(group_id, slot.clone()))? {
488 return Ok(running);
489 }
490 Ok(running_below(buffer, frontier))
491 }
492
493 pub fn apply_running<K, NA>(
494 &mut self,
495 store: &mut dyn StateStore,
496 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
497 eviction: RollingEviction<C>,
498 row_key: K,
499 new_accumulator: NA,
500 ) -> Result<Vec<RollingResult<G, Accumulator::Output>>>
501 where
502 K: Fn(&G) -> (GroupId, EncodedKey),
503 NA: Fn() -> Accumulator,
504 {
505 if buckets.is_empty() {
506 return Ok(Vec::new());
507 }
508 reifydb_assertions! {
509 assert!(
510 self.running.is_some(),
511 "apply_running requires an engine constructed with new_runnable"
512 );
513 }
514 let evict_cutoff = match eviction {
515 RollingEviction::Before(cutoff) => Some(cutoff),
516 RollingEviction::Nothing => None,
517 RollingEviction::Capacity(_) => {
518 unimplemented!("apply_running supports only Before eviction")
519 }
520 };
521 let mut meta_loaded = self.load_meta(store, &buckets)?;
522 let buffer_rows = self.resolve_buffer_rows(&buckets, &meta_loaded, &row_key)?;
523
524 let mut group_slots: BTreeMap<G, RunnableGroupSlot<C, Accumulator>> = BTreeMap::new();
525 for ((group, coord), events) in buckets {
526 let meta = meta_loaded.entry(group.clone()).or_default();
527
528 let slot = match group_slots.get_mut(&group) {
529 Some(s) => s,
530 None => {
531 let (group_id, key) = match buffer_rows.get(&group) {
532 Some(resolved) => resolved.clone(),
533 None => row_key(&group),
534 };
535 let buffer: RollingBuffer<C, Accumulator> = self
536 .buffers
537 .get(store, &BufferKey::new(group_id, key.clone()))?
538 .unwrap_or_default();
539 let old_frontier = frontier_for(self.lag, &meta.high_water());
540 let prior_min = coord_min_key(&buffer);
541 let merged_before = prior_min.is_some_and(|m| {
542 is_merged_coord(<C::Coord as Coord>::from_order(m), old_frontier)
543 });
544 let running = if merged_before {
545 self.load_running(store, &buffer, group_id, &key, old_frontier)?
546 } else {
547 Accumulator::default()
548 };
549 let prior_output = if merged_before {
550 running.finalize()
551 } else {
552 None
553 };
554 group_slots.insert(
555 group.clone(),
556 RunnableGroupSlot {
557 group_id,
558 key,
559 buffer,
560 running,
561 buffer_changed: false,
562 prior_min,
563 old_frontier,
564 prior_output,
565 },
566 );
567 group_slots.get_mut(&group).expect("just inserted")
568 }
569 };
570
571 let mut accumulator = slot.buffer.get(&coord).cloned().unwrap_or_else(&new_accumulator);
572 let before = accumulator.clone();
573 let mut touched = false;
574 for event in events {
575 match event {
576 AccumulatorEvent::Add(c) => {
577 accumulator.add(&c);
578 touched = true;
579 }
580 AccumulatorEvent::Remove(c) => {
581 if accumulator.is_empty() {
582 continue;
583 }
584 accumulator.remove(&c);
585 touched = true;
586 }
587 }
588 }
589 if !touched {
590 continue;
591 }
592 if is_merged_coord(coord.order_key(), slot.old_frontier) {
593 if !before.is_empty() {
594 slot.running.unmerge(&before);
595 }
596 if !accumulator.is_empty() {
597 merge_into(&mut slot.running, &accumulator);
598 }
599 }
600 if !accumulator.is_empty() {
601 slot.buffer.insert(coord, accumulator);
602 } else {
603 slot.buffer.remove(&coord);
604 }
605 slot.buffer_changed = true;
606
607 meta.observe(coord);
608 }
609
610 let mut pairs: Vec<(GroupId, EncodedKey)> = Vec::new();
611 let mut pending: Vec<(G, Accumulator::Output, bool)> = Vec::new();
612 for (group, mut slot) in group_slots {
613 if !slot.buffer_changed {
614 continue;
615 }
616 let high_water = meta_loaded.get(&group).expect("touched group has loaded meta").high_water();
617 let new_frontier = frontier_for(self.lag, &high_water);
618 if new_frontier > slot.old_frontier
619 && let Some(upto) = new_frontier
620 {
621 let lo = match slot.old_frontier {
622 Some(after) => Bound::Excluded(C::from_order_key(after)),
623 None => Bound::Unbounded,
624 };
625 let running = &mut slot.running;
626 for (_, accumulator) in
627 slot.buffer.range((lo, Bound::Included(C::from_order_key(upto))))
628 {
629 merge_into(running, accumulator);
630 }
631 }
632 if let Some(evict_cutoff) = evict_cutoff {
633 let due: Vec<C> = slot.buffer.range(..=evict_cutoff).map(|(coord, _)| *coord).collect();
634 for coord in due {
635 let Some(evicted) = slot.buffer.remove(&coord) else {
636 continue;
637 };
638 if is_merged_coord(coord.order_key(), new_frontier) {
639 slot.running.unmerge(&evicted);
640 }
641 }
642 }
643 let new_min = coord_min_key(&slot.buffer);
644 if new_min != slot.prior_min {
645 if let Some(old) = slot.prior_min {
646 expiry_drop(store, &expiry_key(old, &group, &[]))?;
647 }
648 if let Some(new) = new_min {
649 expiry_set(
650 store,
651 expiry_key(new, &group, &[]),
652 RollingIndexEntry {
653 group: group.clone(),
654 slot_key: slot.key.as_bytes().to_vec(),
655 group_id: slot.group_id.0,
656 },
657 )?;
658 }
659 }
660 let merged_any = new_min
661 .is_some_and(|m| is_merged_coord(<C::Coord as Coord>::from_order(m), new_frontier));
662 let output = if merged_any {
663 slot.running.finalize()
664 } else {
665 None
666 };
667 if slot.buffer.is_empty() {
668 self.buffers.remove(store, &BufferKey::new(slot.group_id, slot.key.clone()))?;
669 } else {
670 self.buffers.put(
671 store,
672 &BufferKey::new(slot.group_id, slot.key.clone()),
673 slot.buffer,
674 )?;
675 }
676 let running_cache = self.running.as_mut().expect("runnable engine has a running cache");
677 if merged_any {
678 running_cache.put(
679 store,
680 &RunningKey::new(slot.group_id, slot.key.clone()),
681 slot.running,
682 )?;
683 } else {
684 running_cache.remove(store, &RunningKey::new(slot.group_id, slot.key.clone()))?;
685 }
686
687 if let Some(out) = output {
688 pairs.push((slot.group_id, slot.key));
689 pending.push((group, out, false));
690 } else if let Some(prior) = slot.prior_output {
691 pairs.push((slot.group_id, slot.key));
692 pending.push((group, prior, true));
693 }
694 }
695
696 let mut results: Vec<RollingResult<G, Accumulator::Output>> = Vec::with_capacity(pending.len());
697 if !pairs.is_empty() {
698 let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
699 for (((group, value, withdrawn), (group_id, key)), (row_number, is_new)) in
700 pending.into_iter().zip(pairs).zip(rows)
701 {
702 if withdrawn {
703 store.remove_row_number(group_id, &key)?;
704 results.push(RollingResult {
705 row_number,
706 group,
707 value,
708 prior: None,
709 kind: EmitKind::Remove,
710 });
711 } else {
712 let kind = if is_new {
713 EmitKind::Insert
714 } else {
715 EmitKind::Update
716 };
717 results.push(RollingResult {
718 row_number,
719 group,
720 value,
721 prior: None,
722 kind,
723 });
724 }
725 }
726 }
727 self.persist_meta(store, meta_loaded)?;
728 Ok(results)
729 }
730
731 pub fn expire_before_running(
732 &mut self,
733 store: &mut dyn StateStore,
734 cutoff: C,
735 ) -> Result<Vec<RollingExpiry<G, Accumulator::Output>>> {
736 reifydb_assertions! {
737 assert!(
738 self.running.is_some(),
739 "expire_before_running requires an engine constructed with new_runnable"
740 );
741 }
742 let due: Vec<(GroupStateKey, RollingIndexEntry<G>)> =
743 expiry_due(store, cutoff.order_key().to_order(), self.expire_batch)?;
744
745 let mut pairs: Vec<(GroupId, EncodedKey)> = Vec::new();
746 let mut pending: Vec<(G, Option<Accumulator::Output>)> = Vec::new();
747 for (index_key, entry) in due {
748 let slot = EncodedKey::new(&entry.slot_key);
749 let group_id = GroupId(entry.group_id);
750 expiry_drop(store, &index_key)?;
751 let frontier = if self.lag.is_zero() {
752 Some(<C::Coord as Coord>::MAX)
753 } else {
754 let lag = self.lag;
755 self.meta
756 .get(store, &meta_key_for(&entry.group))?
757 .and_then(|meta| frontier_for::<C>(lag, &meta.high_water))
758 };
759 let mut buffer: RollingBuffer<C, Accumulator> =
760 self.buffers.get(store, &BufferKey::new(group_id, slot.clone()))?.unwrap_or_default();
761 let expired: Vec<C> = buffer.range(..=cutoff).map(|(coord, _)| *coord).collect();
762 if expired.is_empty() {
763 if let Some(new) = coord_min_key(&buffer) {
764 expiry_set(
765 store,
766 expiry_key(new, &entry.group, &[]),
767 RollingIndexEntry {
768 group: entry.group.clone(),
769 slot_key: entry.slot_key.clone(),
770 group_id: entry.group_id,
771 },
772 )?;
773 }
774 continue;
775 }
776 let mut running = self.load_running(store, &buffer, group_id, &slot, frontier)?;
777 let mut unmerged_any = false;
778 for coord in expired {
779 let Some(accumulator) = buffer.remove(&coord) else {
780 continue;
781 };
782 if is_merged_coord(coord.order_key(), frontier) {
783 running.unmerge(&accumulator);
784 unmerged_any = true;
785 }
786 }
787 let new_min = coord_min_key(&buffer);
788 let merged_any =
789 new_min.is_some_and(|m| is_merged_coord(<C::Coord as Coord>::from_order(m), frontier));
790 let finalized = if merged_any {
791 running.finalize()
792 } else {
793 None
794 };
795 match (new_min, merged_any, finalized) {
796 (Some(new), true, Some(value)) => {
797 expiry_set(
798 store,
799 expiry_key(new, &entry.group, &[]),
800 RollingIndexEntry {
801 group: entry.group.clone(),
802 slot_key: entry.slot_key.clone(),
803 group_id: entry.group_id,
804 },
805 )?;
806 self.buffers.put(store, &BufferKey::new(group_id, slot.clone()), buffer)?;
807 let running_cache =
808 self.running.as_mut().expect("runnable engine has a running cache");
809 running_cache.put(store, &RunningKey::new(group_id, slot.clone()), running)?;
810 pairs.push((group_id, slot));
811 pending.push((entry.group, Some(value)));
812 }
813 (Some(new), false, _) => {
814 expiry_set(
815 store,
816 expiry_key(new, &entry.group, &[]),
817 RollingIndexEntry {
818 group: entry.group.clone(),
819 slot_key: entry.slot_key.clone(),
820 group_id: entry.group_id,
821 },
822 )?;
823 self.buffers.put(store, &BufferKey::new(group_id, slot.clone()), buffer)?;
824 let running_cache =
825 self.running.as_mut().expect("runnable engine has a running cache");
826 running_cache.remove(store, &RunningKey::new(group_id, slot.clone()))?;
827 if unmerged_any {
828 pairs.push((group_id, slot));
829 pending.push((entry.group, None));
830 }
831 }
832 _ => {
833 self.buffers.remove(store, &BufferKey::new(group_id, slot.clone()))?;
834 let running_cache =
835 self.running.as_mut().expect("runnable engine has a running cache");
836 running_cache.remove(store, &RunningKey::new(group_id, slot.clone()))?;
837 pairs.push((group_id, slot));
838 pending.push((entry.group, None));
839 }
840 }
841 }
842
843 let mut out: Vec<RollingExpiry<G, Accumulator::Output>> = Vec::with_capacity(pending.len());
844 if !pairs.is_empty() {
845 let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
846 for (((group, value), (group_id, key)), (row_number, _)) in
847 pending.into_iter().zip(pairs).zip(rows)
848 {
849 match value {
850 Some(value) => out.push(RollingExpiry::Update {
851 row_number,
852 group,
853 group_id,
854 value,
855 }),
856 None => {
857 store.remove_row_number(group_id, &key)?;
858 out.push(RollingExpiry::Remove {
859 row_number,
860 group,
861 group_id,
862 });
863 }
864 }
865 }
866 }
867 note_when_expiry_capped(out.len(), self.expire_batch);
868 Ok(out)
869 }
870
871 pub fn expire_meta(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<usize> {
872 sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
873 }
874
875 pub fn earliest_expiry(&mut self, store: &mut dyn StateStore) -> Result<Option<u64>> {
876 expiry_earliest(store)
877 }
878
879 pub fn expire_before<CB, Output>(
880 &mut self,
881 store: &mut dyn StateStore,
882 cutoff: C,
883 combine: CB,
884 ) -> Result<Vec<RollingExpiry<G, Output>>>
885 where
886 CB: Fn(&G, &RollingBuffer<C, Accumulator>) -> Option<Output>,
887 {
888 let due: Vec<(GroupStateKey, RollingIndexEntry<G>)> =
889 expiry_due(store, cutoff.order_key().to_order(), self.expire_batch)?;
890
891 let mut pairs: Vec<(GroupId, EncodedKey)> = Vec::new();
892 let mut pending: Vec<(G, Option<Output>)> = Vec::new();
893 for (index_key, entry) in due {
894 let slot = EncodedKey::new(&entry.slot_key);
895 let group_id = GroupId(entry.group_id);
896 expiry_drop(store, &index_key)?;
897 let mut buffer: RollingBuffer<C, Accumulator> =
898 self.buffers.get(store, &BufferKey::new(group_id, slot.clone()))?.unwrap_or_default();
899 if buffer.is_empty() {
900 continue;
901 }
902 let before = buffer.len();
903 buffer.retain(|&coord, _| coord > cutoff);
904 if buffer.len() == before {
905 if let Some(new) = coord_min_key(&buffer) {
906 expiry_set(
907 store,
908 expiry_key(new, &entry.group, &[]),
909 RollingIndexEntry {
910 group: entry.group.clone(),
911 slot_key: entry.slot_key.clone(),
912 group_id: entry.group_id,
913 },
914 )?;
915 }
916 continue;
917 }
918 match combine(&entry.group, &buffer) {
919 Some(value) if !buffer.is_empty() => {
920 if let Some(new) = coord_min_key(&buffer) {
921 expiry_set(
922 store,
923 expiry_key(new, &entry.group, &[]),
924 RollingIndexEntry {
925 group: entry.group.clone(),
926 slot_key: entry.slot_key.clone(),
927 group_id: entry.group_id,
928 },
929 )?;
930 }
931 self.buffers.put(store, &BufferKey::new(group_id, slot.clone()), buffer)?;
932 pairs.push((group_id, slot));
933 pending.push((entry.group, Some(value)));
934 }
935 _ => {
936 self.buffers.remove(store, &BufferKey::new(group_id, slot.clone()))?;
937 pairs.push((group_id, slot));
938 pending.push((entry.group, None));
939 }
940 }
941 }
942
943 let mut out: Vec<RollingExpiry<G, Output>> = Vec::with_capacity(pending.len());
944 if !pairs.is_empty() {
945 let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
946 for (((group, value), (group_id, key)), (row_number, _)) in
947 pending.into_iter().zip(pairs).zip(rows)
948 {
949 match value {
950 Some(value) => out.push(RollingExpiry::Update {
951 row_number,
952 group,
953 group_id,
954 value,
955 }),
956 None => {
957 store.remove_row_number(group_id, &key)?;
958 out.push(RollingExpiry::Remove {
959 row_number,
960 group,
961 group_id,
962 });
963 }
964 }
965 }
966 }
967 note_when_expiry_capped(out.len(), self.expire_batch);
968 Ok(out)
969 }
970
971 fn persist_meta(&mut self, store: &mut dyn StateStore, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
972 persist_batch_meta(store, &mut self.meta, meta_loaded)
973 }
974}
975
976#[cfg(test)]
977mod tests {
978 use std::collections::{BTreeMap, BTreeSet};
979
980 use reifydb_codec::key::encoded::EncodedKey;
981 use reifydb_core::key::operator_state::GroupId;
982 use reifydb_value::{
983 factory::time::{at_millis, millis},
984 value::datetime::DateTime,
985 };
986
987 use crate::{
988 operator::state::{mock::MockStore, seal::coord::Coord},
989 window::{
990 accumulator::mock::SumAccumulator,
991 engine::{
992 AccumulatorEvent, EmitKind,
993 config::WindowEngineConfig,
994 rolling::{
995 RollingBuckets, RollingBuffer, RollingEngine, RollingEviction, RollingExpiry,
996 RollingResult,
997 },
998 },
999 },
1000 };
1001
1002 fn test_config() -> WindowEngineConfig {
1003 WindowEngineConfig::builder().build()
1004 }
1005
1006 fn order(millis: u64) -> u64 {
1007 <DateTime as Coord>::to_order(at_millis(millis))
1008 }
1009
1010 fn row_key(group: &u32) -> (GroupId, EncodedKey) {
1011 (GroupId::ROOT, node_row_key(group))
1012 }
1013
1014 fn node_row_key(group: &u32) -> EncodedKey {
1015 EncodedKey::builder().u32(*group).build()
1016 }
1017
1018 fn past_every_coord() -> DateTime {
1019 DateTime::MAX.saturating_sub(millis(1))
1023 }
1024
1025 fn sum_combine(_group: &u32, buffer: &RollingBuffer<DateTime, SumAccumulator>) -> Option<i64> {
1026 if buffer.is_empty() {
1027 None
1028 } else {
1029 Some(buffer.values().map(|a| a.sum).sum())
1030 }
1031 }
1032
1033 #[test]
1034 fn meta_reclaimed_when_group_stale_past_threshold() {
1035 let mut store = MockStore::default();
1039 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1040 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1041 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(1)]);
1042 buckets.insert((1u32, at_millis(20)), vec![AccumulatorEvent::Add(2)]);
1043 engine.apply_evicting(
1044 &mut store,
1045 buckets,
1046 RollingEviction::Before(at_millis(0)),
1047 row_key,
1048 SumAccumulator::default,
1049 sum_combine,
1050 )
1051 .unwrap();
1052 assert_eq!(store.meta_entry_count(), 1, "the group's meta is persisted on apply");
1053
1054 let dropped = engine.expire_meta(&mut store, order(100)).unwrap();
1055 assert_eq!(dropped, 1, "the group's high water (20) is below the threshold (100)");
1056 assert_eq!(store.meta_entry_count(), 0, "a stale group must not leak its GroupMeta");
1057 }
1058
1059 #[test]
1060 fn meta_survives_while_group_high_water_at_or_after_threshold() {
1061 let mut store = MockStore::default();
1063 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1064 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1065 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(1)]);
1066 buckets.insert((1u32, at_millis(20)), vec![AccumulatorEvent::Add(2)]);
1067 engine.apply_evicting(
1068 &mut store,
1069 buckets,
1070 RollingEviction::Before(at_millis(0)),
1071 row_key,
1072 SumAccumulator::default,
1073 sum_combine,
1074 )
1075 .unwrap();
1076
1077 let dropped = engine.expire_meta(&mut store, 5).unwrap();
1078 assert_eq!(dropped, 0, "high water (20) is not below the threshold (5)");
1079 assert_eq!(store.meta_entry_count(), 1, "a group within the staleness horizon keeps its meta");
1080 }
1081
1082 #[test]
1083 fn nothing_to_evict_retains_the_coordinate_at_zero_and_still_indexes_the_group() {
1084 let mut store = MockStore::default();
1088 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1089 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1090 buckets.insert((1u32, at_millis(0)), vec![AccumulatorEvent::Add(7)]);
1091
1092 let results = engine
1093 .apply_evicting(
1094 &mut store,
1095 buckets,
1096 RollingEviction::Nothing,
1097 row_key,
1098 SumAccumulator::default,
1099 sum_combine,
1100 )
1101 .unwrap();
1102
1103 assert_eq!(results.len(), 1, "the group must publish rather than come back empty");
1104 assert_eq!(results[0].value, 7, "the contribution at the epoch must survive the tick");
1105 assert_eq!(store.index_entry_count(), 1, "Nothing must index the group exactly as Before does");
1106 }
1107
1108 #[test]
1109 fn evicting_before_zero_still_drops_the_coordinate_at_zero() {
1110 let mut store = MockStore::default();
1113 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1114 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1115 buckets.insert((1u32, at_millis(0)), vec![AccumulatorEvent::Add(7)]);
1116
1117 let results = engine
1118 .apply_evicting(
1119 &mut store,
1120 buckets,
1121 RollingEviction::Before(at_millis(0)),
1122 row_key,
1123 SumAccumulator::default,
1124 sum_combine,
1125 )
1126 .unwrap();
1127
1128 assert!(
1129 results.iter().all(|r| r.value == 0),
1130 "a coordinate at or below the cutoff must not contribute, got {:?}",
1131 results.iter().map(|r| r.value).collect::<Vec<_>>()
1132 );
1133 }
1134
1135 #[test]
1136 fn expire_before_evicts_a_quiet_group_then_rekeys_then_removes() {
1137 let mut store = MockStore::default();
1138 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1139 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1140 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(1)]);
1141 buckets.insert((1u32, at_millis(20)), vec![AccumulatorEvent::Add(2)]);
1142 buckets.insert((1u32, at_millis(30)), vec![AccumulatorEvent::Add(3)]);
1143 engine.apply_evicting(
1145 &mut store,
1146 buckets,
1147 RollingEviction::Before(at_millis(0)),
1148 row_key,
1149 SumAccumulator::default,
1150 sum_combine,
1151 )
1152 .unwrap();
1153 assert_eq!(store.index_entry_count(), 1, "the group is indexed by its oldest coord");
1154
1155 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1157 let out = engine.expire_before(&mut store, at_millis(20), sum_combine).unwrap();
1158 assert_eq!(out.len(), 1);
1159 match &out[0] {
1160 RollingExpiry::Update {
1161 group,
1162 value,
1163 ..
1164 } => {
1165 assert_eq!(*group, 1);
1166 assert_eq!(*value, 3, "only the surviving coord 30 contributes");
1167 }
1168 RollingExpiry::Remove {
1169 ..
1170 } => panic!("group still has a live coord"),
1171 }
1172 assert_eq!(store.index_entry_count(), 1, "still one entry, re-keyed to coord 30");
1173
1174 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1176 let out = engine.expire_before(&mut store, at_millis(30), sum_combine).unwrap();
1177 assert_eq!(out.len(), 1);
1178 match &out[0] {
1179 RollingExpiry::Remove {
1180 group,
1181 ..
1182 } => assert_eq!(*group, 1),
1183 RollingExpiry::Update {
1184 ..
1185 } => panic!("the group is empty and must be removed"),
1186 }
1187 assert_eq!(store.index_entry_count(), 0, "the emptied group leaves no index entry");
1188
1189 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1191 assert!(engine.expire_before(&mut store, at_millis(1000), sum_combine).unwrap().is_empty());
1192 }
1193
1194 #[test]
1195 fn expire_before_leaves_groups_whose_oldest_coord_is_not_due() {
1196 let mut store = MockStore::default();
1197 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1198 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1199 buckets.insert((1u32, at_millis(100)), vec![AccumulatorEvent::Add(1)]);
1200 buckets.insert((2u32, at_millis(5)), vec![AccumulatorEvent::Add(9)]);
1201 engine.apply_evicting(
1202 &mut store,
1203 buckets,
1204 RollingEviction::Before(at_millis(0)),
1205 row_key,
1206 SumAccumulator::default,
1207 sum_combine,
1208 )
1209 .unwrap();
1210 assert_eq!(store.index_entry_count(), 2);
1211
1212 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1214 let out = engine.expire_before(&mut store, at_millis(5), sum_combine).unwrap();
1215 assert_eq!(out.len(), 1, "only the group with a due coord is processed");
1216 assert!(matches!(&out[0], RollingExpiry::Remove { group, .. } if *group == 2));
1217 assert_eq!(store.index_entry_count(), 1, "group 1 keeps its index entry");
1218 }
1219
1220 #[test]
1221 fn expire_before_processes_at_most_expire_batch_then_resumes_next_tick() {
1222 let mut store = MockStore::default();
1226 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1227 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1228 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(1)]);
1229 buckets.insert((2u32, at_millis(20)), vec![AccumulatorEvent::Add(2)]);
1230 buckets.insert((3u32, at_millis(30)), vec![AccumulatorEvent::Add(3)]);
1231 engine.apply_evicting(
1232 &mut store,
1233 buckets,
1234 RollingEviction::Before(at_millis(0)),
1235 row_key,
1236 SumAccumulator::default,
1237 sum_combine,
1238 )
1239 .unwrap();
1240 assert_eq!(store.index_entry_count(), 3);
1241
1242 let capped = WindowEngineConfig::builder().expire_batch(2).build();
1243
1244 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(capped.clone());
1245 let first = engine.expire_before(&mut store, at_millis(1000), sum_combine).unwrap();
1246 assert_eq!(first.len(), 2, "one tick drains at most expire_batch groups");
1247 assert!(matches!(&first[0], RollingExpiry::Remove { group, .. } if *group == 3));
1248 assert!(matches!(&first[1], RollingExpiry::Remove { group, .. } if *group == 2));
1249 assert_eq!(store.index_entry_count(), 1, "the deferred group keeps its index entry");
1250
1251 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(capped);
1252 let second = engine.expire_before(&mut store, at_millis(1000), sum_combine).unwrap();
1253 assert_eq!(second.len(), 1, "the next tick picks up the deferred group");
1254 assert!(matches!(&second[0], RollingExpiry::Remove { group, .. } if *group == 1));
1255 assert_eq!(store.index_entry_count(), 0);
1256 }
1257
1258 #[test]
1259 fn withdrawn_value_is_reconstructed_after_restart() {
1260 let mut store = MockStore::default();
1264
1265 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1266 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1267 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(5)]);
1268 let published: Vec<RollingResult<u32, i64>> =
1269 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
1270 assert_eq!(published.len(), 1);
1271 assert!(matches!(published[0].kind, EmitKind::Insert));
1272 assert_eq!(published[0].value, 5);
1273
1274 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1277 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1278 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Remove(5)]);
1279 let withdrawn: Vec<RollingResult<u32, i64>> =
1280 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
1281
1282 assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
1283 assert!(
1284 matches!(withdrawn[0].kind, EmitKind::Remove),
1285 "the group emptied under retraction, so the last published row must be withdrawn"
1286 );
1287 assert_eq!(
1288 withdrawn[0].value, 5,
1289 "the withdrawn value is the reconstructed last-published output, not a stale or zeroed value"
1290 );
1291 assert_eq!(
1292 withdrawn[0].row_number, published[0].row_number,
1293 "the withdrawal targets the same row that was published"
1294 );
1295 }
1296
1297 #[test]
1298 fn buffer_survives_lru_eviction() {
1299 let mut store = MockStore::default();
1303 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1304
1305 let mut published_group_1: Vec<RollingResult<u32, i64>> = Vec::new();
1306 for group in 1u32..=11u32 {
1307 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1308 buckets.insert((group, at_millis(10)), vec![AccumulatorEvent::Add(i64::from(group))]);
1309 let out: Vec<RollingResult<u32, i64>> =
1310 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
1311 if group == 1 {
1312 published_group_1 = out;
1313 }
1314 }
1315 assert_eq!(published_group_1.len(), 1);
1316 assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
1317 assert_eq!(published_group_1[0].value, 1);
1318
1319 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1322 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Remove(1)]);
1323 let withdrawn: Vec<RollingResult<u32, i64>> =
1324 engine.apply(&mut store, buckets, 4, row_key, sum_combine).unwrap();
1325
1326 assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
1327 assert!(
1328 matches!(withdrawn[0].kind, EmitKind::Remove),
1329 "the evicted group emptied under retraction, so the last published row must be withdrawn"
1330 );
1331 assert_eq!(
1332 withdrawn[0].value, 1,
1333 "the withdrawn value is reconstructed from the evicted group's persisted buffer"
1334 );
1335 assert_eq!(
1336 withdrawn[0].row_number, published_group_1[0].row_number,
1337 "the withdrawal targets the same row that was published for group 1"
1338 );
1339 }
1340
1341 fn describe(results: &[RollingResult<u32, i64>]) -> Vec<(u32, EmitKind, i64)> {
1342 results.iter().map(|r| (r.group, r.kind, r.value)).collect()
1343 }
1344
1345 fn describe_expiries(expiries: &[RollingExpiry<u32, i64>]) -> Vec<(u32, Option<i64>)> {
1346 expiries.iter()
1347 .map(|e| match e {
1348 RollingExpiry::Update {
1349 group,
1350 value,
1351 ..
1352 } => (*group, Some(*value)),
1353 RollingExpiry::Remove {
1354 group,
1355 ..
1356 } => (*group, None),
1357 })
1358 .collect()
1359 }
1360
1361 #[test]
1362 fn runnable_engine_matches_recombine_across_seeded_churn() {
1363 let mut recombine_store = MockStore::default();
1367 let mut runnable_store = MockStore::default();
1368 let mut recombine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1369 let mut runnable = RollingEngine::<u32, DateTime, SumAccumulator>::new_runnable(test_config());
1370
1371 let mut state = 0xDEAD_BEEF_CAFE_1234u64;
1372 let mut roll = |bound: u64| {
1373 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1374 (state >> 33) % bound
1375 };
1376 let mut coord_base = 100u64;
1377 let mut cutoff = 0u64;
1378 let mut added: Vec<(u32, u64, i64)> = Vec::new();
1379
1380 for round in 0..200u64 {
1381 let mut plan: Vec<(u32, u64, i64, bool)> = Vec::new();
1382 for _ in 0..=roll(3) {
1383 let group = roll(5) as u32;
1384 let coord = coord_base + roll(40);
1385 let value = roll(1_000) as i64 + 1;
1386 plan.push((group, coord, value, true));
1387 added.push((group, coord, value));
1388 }
1389 if round % 4 == 3 && !added.is_empty() {
1390 let (group, coord, value) = added.remove((roll(added.len() as u64)) as usize);
1391 plan.push((group, coord, value, false));
1392 }
1393 let build = |plan: &[(u32, u64, i64, bool)]| {
1394 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1395 for &(group, coord, value, is_add) in plan {
1396 let event = if is_add {
1397 AccumulatorEvent::Add(value)
1398 } else {
1399 AccumulatorEvent::Remove(value)
1400 };
1401 buckets.entry((group, at_millis(coord))).or_default().push(event);
1402 }
1403 buckets
1404 };
1405 let recombine_out = recombine
1406 .apply_evicting(
1407 &mut recombine_store,
1408 build(&plan),
1409 RollingEviction::Before(at_millis(cutoff)),
1410 row_key,
1411 SumAccumulator::default,
1412 sum_combine,
1413 )
1414 .unwrap();
1415 let runnable_out = runnable
1416 .apply_running(
1417 &mut runnable_store,
1418 build(&plan),
1419 RollingEviction::Before(at_millis(cutoff)),
1420 row_key,
1421 SumAccumulator::default,
1422 )
1423 .unwrap();
1424 assert_eq!(
1425 describe(&recombine_out),
1426 describe(&runnable_out),
1427 "apply diverged from the recombine at round {round}"
1428 );
1429
1430 if round % 5 == 4 {
1431 cutoff = coord_base.saturating_sub(30);
1432 let recombine_exp = recombine
1433 .expire_before(&mut recombine_store, at_millis(cutoff), sum_combine)
1434 .unwrap();
1435 let runnable_exp =
1436 runnable.expire_before_running(&mut runnable_store, at_millis(cutoff)).unwrap();
1437 assert_eq!(
1438 describe_expiries(&recombine_exp),
1439 describe_expiries(&runnable_exp),
1440 "expiry diverged from the recombine at round {round}"
1441 );
1442 added.retain(|(_, coord, _)| *coord > cutoff);
1443 }
1444 coord_base += roll(20);
1445 }
1446
1447 assert_eq!(
1448 recombine_store.index_entry_count(),
1449 runnable_store.index_entry_count(),
1450 "expiry-index bookkeeping diverged"
1451 );
1452
1453 let recombine_final =
1455 recombine.expire_before(&mut recombine_store, past_every_coord(), sum_combine).unwrap();
1456 let runnable_final = runnable.expire_before_running(&mut runnable_store, past_every_coord()).unwrap();
1457 assert_eq!(
1458 describe_expiries(&recombine_final),
1459 describe_expiries(&runnable_final),
1460 "terminal drain diverged"
1461 );
1462 assert!(
1463 recombine_final.iter().all(|e| matches!(e, RollingExpiry::Remove { .. })),
1464 "draining past every coord must terminally remove all groups"
1465 );
1466 }
1467
1468 #[test]
1469 fn runnable_engine_bootstraps_running_from_recombine_coords() {
1470 let mut store = MockStore::default();
1474 let mut recombine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1475 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1476 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(5)]);
1477 buckets.insert((1u32, at_millis(20)), vec![AccumulatorEvent::Add(7)]);
1478 recombine
1479 .apply_evicting(
1480 &mut store,
1481 buckets,
1482 RollingEviction::Before(at_millis(0)),
1483 row_key,
1484 SumAccumulator::default,
1485 sum_combine,
1486 )
1487 .unwrap();
1488
1489 let mut runnable = RollingEngine::<u32, DateTime, SumAccumulator>::new_runnable(test_config());
1490 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1491 buckets.insert((1u32, at_millis(30)), vec![AccumulatorEvent::Add(100)]);
1492 let out = runnable
1493 .apply_running(
1494 &mut store,
1495 buckets,
1496 RollingEviction::Before(at_millis(0)),
1497 row_key,
1498 SumAccumulator::default,
1499 )
1500 .unwrap();
1501 assert_eq!(
1502 describe(&out),
1503 vec![(1u32, EmitKind::Update, 112i64)],
1504 "bootstrap must fold the pre-existing buffer into the running sum"
1505 );
1506
1507 let expired = runnable.expire_before_running(&mut store, at_millis(20)).unwrap();
1508 assert_eq!(
1509 describe_expiries(&expired),
1510 vec![(1u32, Some(100i64))],
1511 "expiring the pre-fix coords must subtract exactly their contributions"
1512 );
1513
1514 let mut reopened = RollingEngine::<u32, DateTime, SumAccumulator>::new_runnable(test_config());
1517 let drained = reopened.expire_before_running(&mut store, past_every_coord()).unwrap();
1518 assert_eq!(
1519 describe_expiries(&drained),
1520 vec![(1u32, None)],
1521 "the last coord expiring must terminally remove"
1522 );
1523 }
1524
1525 #[test]
1526 fn per_coord_storage_leaves_nothing_behind_after_terminal_drain() {
1527 let mut store = MockStore::default();
1531 let mut recombine = RollingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1532 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1533 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(5)]);
1534 buckets.insert((1u32, at_millis(20)), vec![AccumulatorEvent::Add(7)]);
1535 recombine
1536 .apply_evicting(
1537 &mut store,
1538 buckets,
1539 RollingEviction::Before(at_millis(0)),
1540 row_key,
1541 SumAccumulator::default,
1542 sum_combine,
1543 )
1544 .unwrap();
1545 assert_eq!(
1546 store.buffer_coord_count::<SumAccumulator>(),
1547 2,
1548 "the recombine path persists both coords in the group's buffer"
1549 );
1550
1551 let mut runnable = RollingEngine::<u32, DateTime, SumAccumulator>::new_runnable(test_config());
1552 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1553 buckets.insert((2u32, at_millis(30)), vec![AccumulatorEvent::Add(1)]);
1554 buckets.insert((1u32, at_millis(30)), vec![AccumulatorEvent::Add(100)]);
1555 runnable.apply_running(
1556 &mut store,
1557 buckets,
1558 RollingEviction::Before(at_millis(0)),
1559 row_key,
1560 SumAccumulator::default,
1561 )
1562 .unwrap();
1563 assert_eq!(store.buffer_coord_count::<SumAccumulator>(), 4, "every live coord is persisted");
1564 assert_eq!(store.buffer_entry_count(), 2, "each live group persists one buffer entry");
1565 assert_eq!(store.running_entry_count(), 2, "each live group persists one running entry");
1566
1567 let drained = runnable.expire_before_running(&mut store, past_every_coord()).unwrap();
1568 assert_eq!(drained.len(), 2, "both groups drain");
1569 assert!(drained.iter().all(|e| matches!(e, RollingExpiry::Remove { .. })));
1570 assert_eq!(store.buffer_entry_count(), 0, "terminal removal must delete the group's buffer entry");
1571 assert_eq!(store.running_entry_count(), 0, "terminal removal must delete the running entry");
1572 assert_eq!(store.index_entry_count(), 0, "terminal removal must delete the expiry index entry");
1573 }
1574
1575 #[test]
1576 fn lagged_runnable_engine_matches_a_semantic_oracle_across_seeded_churn() {
1577 const LAG: u64 = 5;
1581 let mut store = MockStore::default();
1582 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new_runnable(test_config())
1583 .with_lag(millis(LAG));
1584
1585 let mut state = 0xFEED_FACE_0123_4567u64;
1586 let mut roll = |bound: u64| {
1587 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1588 (state >> 33) % bound
1589 };
1590 let mut coord_base = 100u64;
1591 let mut cutoff = 0u64;
1592 let mut added: Vec<(u32, u64, i64)> = Vec::new();
1593 let mut live: BTreeMap<(u32, u64), (i64, u64)> = BTreeMap::new();
1594 let mut group_hw: BTreeMap<u32, u64> = BTreeMap::new();
1595 let mut engine_visible: BTreeMap<u32, i64> = BTreeMap::new();
1596
1597 fn oracle_visible(
1598 live: &BTreeMap<(u32, u64), (i64, u64)>,
1599 group_hw: &BTreeMap<u32, u64>,
1600 group: u32,
1601 lag: u64,
1602 ) -> Option<i64> {
1603 let frontier = group_hw.get(&group)?.saturating_sub(lag);
1604 let mut sum = 0i64;
1605 let mut any = false;
1606 for (&(_, coord), &(coord_sum, _)) in live.range((group, 0)..=(group, u64::MAX)) {
1607 if coord <= frontier {
1608 sum += coord_sum;
1609 any = true;
1610 }
1611 }
1612 if any {
1613 Some(sum)
1614 } else {
1615 None
1616 }
1617 }
1618
1619 for round in 0..200u64 {
1620 let mut plan: Vec<(u32, u64, i64, bool)> = Vec::new();
1621 for _ in 0..=roll(3) {
1622 let group = roll(5) as u32;
1623 let coord = coord_base + roll(40);
1624 let value = roll(1_000) as i64 + 1;
1625 plan.push((group, coord, value, true));
1626 added.push((group, coord, value));
1627 }
1628 if round % 4 == 3 && !added.is_empty() {
1629 let (group, coord, value) = added.remove((roll(added.len() as u64)) as usize);
1630 plan.push((group, coord, value, false));
1631 }
1632
1633 let mut changed: BTreeSet<u32> = BTreeSet::new();
1634 for &(group, coord, value, is_add) in &plan {
1635 if is_add {
1636 let entry = live.entry((group, coord)).or_insert((0, 0));
1637 entry.0 += value;
1638 entry.1 += 1;
1639 } else if let Some(entry) = live.get_mut(&(group, coord)) {
1640 entry.0 -= value;
1641 entry.1 -= 1;
1642 if entry.1 == 0 {
1643 live.remove(&(group, coord));
1644 }
1645 } else {
1646 continue;
1647 }
1648 changed.insert(group);
1649 let hw = group_hw.entry(group).or_insert(0);
1650 *hw = (*hw).max(coord);
1651 }
1652 for &group in &changed {
1653 let dead: Vec<(u32, u64)> =
1654 live.range((group, 0)..=(group, cutoff)).map(|(&key, _)| key).collect();
1655 for key in dead {
1656 live.remove(&key);
1657 }
1658 }
1659
1660 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1661 for &(group, coord, value, is_add) in &plan {
1662 let event = if is_add {
1663 AccumulatorEvent::Add(value)
1664 } else {
1665 AccumulatorEvent::Remove(value)
1666 };
1667 buckets.entry((group, at_millis(coord))).or_default().push(event);
1668 }
1669 let out = engine
1670 .apply_running(
1671 &mut store,
1672 buckets,
1673 RollingEviction::Before(at_millis(cutoff)),
1674 row_key,
1675 SumAccumulator::default,
1676 )
1677 .unwrap();
1678 for r in &out {
1679 if matches!(r.kind, EmitKind::Remove) {
1680 let prior = engine_visible.remove(&r.group);
1681 assert_eq!(
1682 prior,
1683 Some(r.value),
1684 "withdrawn value must be the last published value (round {round})"
1685 );
1686 } else {
1687 engine_visible.insert(r.group, r.value);
1688 }
1689 }
1690 for group in 0u32..5 {
1691 assert_eq!(
1692 engine_visible.get(&group).copied(),
1693 oracle_visible(&live, &group_hw, group, LAG),
1694 "visible row diverged from the oracle for group {group} after apply round {round}"
1695 );
1696 }
1697
1698 if round % 5 == 4 {
1699 cutoff = coord_base.saturating_sub(60);
1700 let expiries = engine.expire_before_running(&mut store, at_millis(cutoff)).unwrap();
1701 let dead: Vec<(u32, u64)> = live
1702 .iter()
1703 .filter(|&(&(_, coord), _)| coord <= cutoff)
1704 .map(|(&key, _)| key)
1705 .collect();
1706 for key in dead {
1707 live.remove(&key);
1708 }
1709 added.retain(|(_, coord, _)| *coord > cutoff);
1710 for e in &expiries {
1711 match e {
1712 RollingExpiry::Update {
1713 group,
1714 value,
1715 ..
1716 } => {
1717 engine_visible.insert(*group, *value);
1718 }
1719 RollingExpiry::Remove {
1720 group,
1721 ..
1722 } => {
1723 engine_visible.remove(group);
1724 }
1725 }
1726 }
1727 for group in 0u32..5 {
1728 assert_eq!(
1729 engine_visible.get(&group).copied(),
1730 oracle_visible(&live, &group_hw, group, LAG),
1731 "visible row diverged from the oracle for group {group} after expiry round {round}"
1732 );
1733 }
1734 }
1735 coord_base += roll(20);
1736 }
1737
1738 let drained = engine.expire_before_running(&mut store, past_every_coord()).unwrap();
1739 for e in &drained {
1740 match e {
1741 RollingExpiry::Update {
1742 group,
1743 value,
1744 ..
1745 } => {
1746 engine_visible.insert(*group, *value);
1747 }
1748 RollingExpiry::Remove {
1749 group,
1750 ..
1751 } => {
1752 engine_visible.remove(group);
1753 }
1754 }
1755 }
1756 assert!(engine_visible.is_empty(), "the terminal drain must withdraw every visible row");
1757 assert_eq!(store.buffer_entry_count(), 0, "the terminal drain must delete every buffer entry");
1758 assert_eq!(store.running_entry_count(), 0, "the terminal drain must delete every running entry");
1759 assert_eq!(store.index_entry_count(), 0, "the terminal drain must delete every index entry");
1760 }
1761
1762 #[test]
1763 fn lagged_running_holds_back_coords_within_the_lag_horizon() {
1764 let mut store = MockStore::default();
1768 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new_runnable(test_config())
1769 .with_lag(millis(10));
1770
1771 let apply = |engine: &mut RollingEngine<u32, DateTime, SumAccumulator>,
1772 store: &mut MockStore,
1773 coord: u64,
1774 value: i64,
1775 is_add: bool,
1776 cutoff: u64| {
1777 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1778 let event = if is_add {
1779 AccumulatorEvent::Add(value)
1780 } else {
1781 AccumulatorEvent::Remove(value)
1782 };
1783 buckets.insert((1u32, at_millis(coord)), vec![event]);
1784 engine.apply_running(
1785 store,
1786 buckets,
1787 RollingEviction::Before(at_millis(cutoff)),
1788 row_key,
1789 SumAccumulator::default,
1790 )
1791 .unwrap()
1792 };
1793
1794 let out = apply(&mut engine, &mut store, 100, 5, true, 0);
1795 assert!(out.is_empty(), "a lone coord inside the lag horizon must publish nothing");
1796
1797 let out = apply(&mut engine, &mut store, 115, 7, true, 0);
1798 assert_eq!(
1799 describe(&out),
1800 vec![(1u32, EmitKind::Insert, 5i64)],
1801 "advancing high water to 115 merges only coord 100; coord 115 itself stays pending"
1802 );
1803
1804 let out = apply(&mut engine, &mut store, 130, 9, true, 0);
1805 assert_eq!(
1806 describe(&out),
1807 vec![(1u32, EmitKind::Update, 12i64)],
1808 "coord 115 crosses the frontier at high water 130; coord 130 stays pending"
1809 );
1810
1811 let out = apply(&mut engine, &mut store, 130, 9, false, 0);
1812 assert_eq!(
1813 describe(&out),
1814 vec![(1u32, EmitKind::Update, 12i64)],
1815 "retracting the still-pending coord 130 must not change the published aggregate"
1816 );
1817
1818 let out = apply(&mut engine, &mut store, 200, 1, true, 150);
1819 assert_eq!(
1820 describe(&out),
1821 vec![(1u32, EmitKind::Remove, 12i64)],
1822 "evicting every merged coord while coord 200 is still pending withdraws the row"
1823 );
1824 assert_eq!(
1825 store.buffer_coord_count::<SumAccumulator>(),
1826 1,
1827 "the pending coord survives the withdrawal"
1828 );
1829 assert_eq!(store.running_entry_count(), 0, "a group with no merged coord persists no running entry");
1830 }
1831
1832 #[test]
1833 fn lagged_expiry_retains_pending_coords() {
1834 let mut store = MockStore::default();
1838 let mut engine = RollingEngine::<u32, DateTime, SumAccumulator>::new_runnable(test_config())
1839 .with_lag(millis(10));
1840
1841 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1842 buckets.insert((1u32, at_millis(100)), vec![AccumulatorEvent::Add(5)]);
1843 buckets.insert((1u32, at_millis(115)), vec![AccumulatorEvent::Add(7)]);
1844 let out = engine
1845 .apply_running(
1846 &mut store,
1847 buckets,
1848 RollingEviction::Before(at_millis(0)),
1849 row_key,
1850 SumAccumulator::default,
1851 )
1852 .unwrap();
1853 assert_eq!(describe(&out), vec![(1u32, EmitKind::Insert, 5i64)]);
1854
1855 let expired = engine.expire_before_running(&mut store, at_millis(105)).unwrap();
1856 assert_eq!(
1857 describe_expiries(&expired),
1858 vec![(1u32, None)],
1859 "expiring the only merged coord withdraws the row"
1860 );
1861 assert_eq!(
1862 store.buffer_coord_count::<SumAccumulator>(),
1863 1,
1864 "the pending coord 115 must survive the expiry"
1865 );
1866 assert_eq!(store.index_entry_count(), 1, "the group stays indexed at its pending coord");
1867
1868 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
1869 buckets.insert((1u32, at_millis(130)), vec![AccumulatorEvent::Add(9)]);
1870 let out = engine
1871 .apply_running(
1872 &mut store,
1873 buckets,
1874 RollingEviction::Before(at_millis(105)),
1875 row_key,
1876 SumAccumulator::default,
1877 )
1878 .unwrap();
1879 assert_eq!(
1880 describe(&out),
1881 vec![(1u32, EmitKind::Insert, 7i64)],
1882 "the retained coord 115 crosses the frontier at high water 130 and surfaces"
1883 );
1884 }
1885}