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