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