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