Skip to main content

reifydb_core/window/engine/
rolling.rs

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