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, 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		// Invariant: a group whose high water has fallen below the staleness threshold has gone
1107		// quiet; its per-group GroupMeta ('W') must be reclaimed. `persist_meta` never removes it,
1108		// so without the sweep a quiet group leaks one internal-state key forever.
1109		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		// Safety boundary: a group whose high water is at or beyond the threshold is still live and
1134		// must keep its meta.
1135		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		// Before(0) evicts nothing at apply (all coords > 0), so the buffer keeps 10,20,30.
1165		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		// A tick with no new events for this group evicts coords <= 20; coord 30 survives.
1178		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		// The next tick evicts the last coord: the group empties and is removed.
1198		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		// A further tick finds nothing due.
1214		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		// Cutoff 5 is due only for group 2 (oldest coord 5); group 1 (oldest 100) is untouched.
1238		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		// Same guard rail as the tumbling engine: a due-group burst must not be drained in a
1249		// single tick, because all node ticks run serialized in the flow actor and one bloated
1250		// operator would stall every other flow. Capped groups stay in the due index and drain
1251		// on later ticks. The due index sorts by inverted coord (encode_u64), so the scan
1252		// yields the newest-due groups first and the oldest backlog defers.
1253		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		// Evict accumulators stamped <= 20; the stamp-30 entry survives.
1314		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		// The terminal Remove emitted when a rolling group empties must carry the value that was
1333		// last published for that group. `prior_output` is never persisted; it is recomputed as
1334		// `combine(buffer)` from the persisted buffer at the start of the batch. This test drops the
1335		// engine between the publish and the retraction (a restart / panic-recovery) and asserts the
1336		// withdrawn value still equals the originally published value. That proves the reconstruction
1337		// is exact and depends on no in-memory state - it holds only because `combine` is a pure
1338		// function of the persisted buffer. If a future combine read non-persisted state, or the
1339		// reconstruction were sourced from an ephemeral cache instead of the buffer, the second engine
1340		// would withdraw a wrong or empty value and this test would fail.
1341		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		// Restart: a brand new engine with no in-memory GroupSlot / prior_output, reading only the
1354		// persisted buffer left behind by the first engine.
1355		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		// The other way a read reaches the store is LRU eviction, no restart needed: the state cache
1380		// holds only 8 groups, so tracking more evicts the oldest and the next access re-reads it from
1381		// the store. This exercises the same persist/reload path as the restart test within a single
1382		// long-lived engine. We publish 11 groups so group 1 is evicted, flush, then retract group 1
1383		// and assert its buffer is read back intact - the terminal Remove carries the originally
1384		// published value. It would fail if the buffer failed to round-trip through the store (a
1385		// serialization break, or a second Data cache colliding on the same key).
1386		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		// Group 1 was published first and pushed out of the 8-slot cache by the later groups, so the
1405		// same engine must re-read its buffer from the store to apply this retraction.
1406		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		// The runnable engine replaces the O(buffer) recombine with a running
1450		// accumulator maintained by merge/unmerge. Its observable behavior -
1451		// emitted kinds, values, expiry updates, terminal removes, and the
1452		// expiry-index bookkeeping - must be indistinguishable from the recombine
1453		// engine on an identical seeded add/remove/expire workload; integer sums
1454		// make the comparison exact. A divergence means the running maintenance
1455		// missed a mutation path.
1456		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		// Drain everything: terminal removes must match group-for-group.
1544		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		// The recombine and running paths share per-coord storage: coords
1560		// written by the recombine path (apply_evicting) must be folded into
1561		// the running accumulator the first time the runnable path touches the
1562		// group, both on the apply path and on the expiry path.
1563		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		// A fresh runnable engine over the flushed state reads the persisted
1607		// running entry back (no bootstrap) and drains to a terminal remove.
1608		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		// Per-coord persistence must clean up completely: after every group
1620		// expires, no coord entries, running entries, or expiry-index entries
1621		// may remain. The recombine (apply_evicting) and running (apply_running)
1622		// paths share the same per-coord storage, so coords written by one are
1623		// picked up by the other. Leaked entries are exactly the kind of
1624		// unbounded state growth this engine exists to prevent.
1625		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		// The lagged fast path maintains a running accumulator plus a merge
1671		// frontier at high_water - lag instead of recombining the buffer on
1672		// every touch. This drives a seeded add/retract/evict/expire workload
1673		// and checks emissions against an independently computed oracle: a
1674		// coord contributes exactly when it sits at or below the group's
1675		// monotone high water minus lag, coords survive until an eviction or
1676		// expiry cutoff passes them, and pending coords survive expiry even
1677		// while the group's visible row is withdrawn (the deliberate fix over
1678		// the blob recombine, which destroyed them). Emissions fold into a
1679		// visible-row map that must equal the oracle after every round, so an
1680		// early merge, a missed crossing, a double count, or a missed emission
1681		// surfaces as a state mismatch at the exact round.
1682		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		// With lag 10, a coord contributes only once the group's high water
1867		// has moved at least lag past it. This pins the full pending
1868		// lifecycle: a first event emits nothing (the lagged window is still
1869		// empty), later events pull older coords across the frontier one
1870		// batch at a time, a retraction of a still-pending coord never
1871		// touches the published aggregate, and evicting every merged coord
1872		// while only pending ones remain withdraws the row.
1873		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		// Deliberate divergence from the blob recombine, which destroys the
1937		// whole buffer when a due group has no coord older than newest - lag,
1938		// silently losing pending coords that would have slid into the lagged
1939		// window later. The fast path must withdraw the visible row but keep
1940		// the pending coords, and a later event that advances the frontier
1941		// must surface their contribution.
1942		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}