Skip to main content

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