Skip to main content

reifydb_flow/window/engine/
tumbling.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::{BTreeMap, HashMap},
6	fmt::Debug,
7	hash::Hash,
8	marker::PhantomData,
9};
10
11use reifydb_codec::{
12	key::{
13		encode_u64,
14		encoded::{EncodedKey, IntoEncodedKey},
15	},
16	row::operator::OperatorState,
17};
18use reifydb_core::{
19	key::operator_state::{GroupId, GroupStateKey},
20	state::{cache::StateCache, store::StateStore},
21};
22use reifydb_macro::operator_state;
23use reifydb_value::{Result, reifydb_assertions};
24
25use crate::{
26	operator::state::{
27		expiry::{expiry_drop, expiry_due, expiry_key, expiry_set},
28		reaper::Reaper,
29	},
30	window::{
31		accumulator::WindowAccumulator,
32		engine::{
33			AccumulatorEvent, BatchMeta, EmitKind, GroupMeta, MetaKey, WindowResult, WindowStateKey,
34			config::WindowEngineConfig, decode_window_state_key, load_batch_meta, meta_key_for,
35			note_when_expiry_capped, persist_batch_meta, sweep_stale_meta,
36		},
37		span::{WindowAnchor, WindowSpan},
38	},
39};
40
41pub type TumblingBuckets<G, C, Contribution> = BTreeMap<(G, WindowSpan<C>), Vec<AccumulatorEvent<Contribution>>>;
42
43type MetaLoaded<G, C> = HashMap<G, BatchMeta<C>>;
44
45#[derive(Clone)]
46struct ResolvedSlot {
47	group: GroupId,
48	key: EncodedKey,
49}
50
51type SlotResolved<G, C> = HashMap<(G, WindowSpan<C>), ResolvedSlot>;
52
53struct PendingEmit<G, C, Output> {
54	group_id: GroupId,
55	key: EncodedKey,
56	group: G,
57	span: WindowSpan<C>,
58	value: Output,
59	prior: Option<Output>,
60	withdraw: bool,
61}
62
63pub struct ExpiredWindow<G, C> {
64	pub group: G,
65	pub group_id: GroupId,
66	pub window_start: C,
67}
68
69#[operator_state]
70#[derive(Clone)]
71pub struct TumblingIndexEntry<G, C> {
72	group: G,
73	window_start: C,
74	group_id: u64,
75	slot_key: Vec<u8>,
76}
77
78impl<G, C, Accumulator> Reaper for TumblingEngine<G, C, Accumulator>
79where
80	C: WindowAnchor + Hash,
81	Accumulator: WindowAccumulator,
82{
83	fn reap(&mut self, store: &mut dyn StateStore, key: &GroupStateKey) -> Result<()> {
84		match decode_window_state_key(key.as_encoded()) {
85			Some(slot) => self.accumulators.remove(store, &slot),
86			None => store.state_remove(key),
87		}
88	}
89}
90
91pub struct TumblingEngine<G, C, Accumulator> {
92	accumulators: StateCache<WindowStateKey, Accumulator>,
93	meta: StateCache<MetaKey, GroupMeta<C>>,
94	meta_low_water: Option<u64>,
95	expire_batch: usize,
96	dropped_retractions: u64,
97	_pd: PhantomData<G>,
98}
99
100impl<G, C, Accumulator> TumblingEngine<G, C, Accumulator>
101where
102	G: Clone + Eq + Ord + Hash + Debug,
103	C: WindowAnchor + Hash,
104	Accumulator: WindowAccumulator,
105	for<'a> &'a G: IntoEncodedKey,
106	GroupMeta<C>: OperatorState,
107	TumblingIndexEntry<G, C>: OperatorState,
108{
109	pub fn new(config: WindowEngineConfig) -> Self {
110		Self {
111			accumulators: StateCache::<WindowStateKey, Accumulator>::new(),
112			meta: StateCache::<MetaKey, GroupMeta<C>>::new(),
113			meta_low_water: None,
114			expire_batch: config.expire_batch(),
115			dropped_retractions: 0,
116			_pd: PhantomData,
117		}
118	}
119
120	pub fn dropped_retractions(&self) -> u64 {
121		self.dropped_retractions
122	}
123
124	#[allow(clippy::too_many_arguments)]
125	pub fn reindex_window(
126		&mut self,
127		store: &mut dyn StateStore,
128		group: &G,
129		window_start: C,
130		id: GroupId,
131		slot: &EncodedKey,
132		prior: Option<u64>,
133		new: Option<u64>,
134	) -> Result<()> {
135		if prior == new {
136			return Ok(());
137		}
138		let suffix = encode_u64(window_start.order_key().to_order());
139		if let Some(old) = prior {
140			expiry_drop(store, &expiry_key(old, group, &suffix))?;
141		}
142		if let Some(new) = new {
143			let entry = TumblingIndexEntry {
144				group: group.clone(),
145				window_start,
146				group_id: id.0,
147				slot_key: slot.as_bytes().to_vec(),
148			};
149			expiry_set(store, expiry_key(new, group, &suffix), entry)?;
150		}
151		Ok(())
152	}
153
154	pub fn apply<K, NA>(
155		&mut self,
156		store: &mut dyn StateStore,
157		buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
158		order: &[(G, WindowSpan<C>)],
159		slot_key: K,
160		new_accumulator: NA,
161	) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
162	where
163		K: Fn(&G, C) -> (GroupId, EncodedKey),
164		NA: Fn() -> Accumulator,
165	{
166		self.dropped_retractions = 0;
167		if buckets.is_empty() {
168			return Ok(Vec::new());
169		}
170		let mut meta_loaded = self.load_meta(store, &buckets)?;
171		let slot_resolved = Self::resolve_slots(order, &slot_key);
172		reifydb_assertions! {
173			let ordered = slot_resolved.len();
174			let bucketed = buckets.len();
175			assert!(
176				ordered == bucketed,
177				"the resolution order must name every bucket exactly once; a bucket missing from it \
178				 gets no row number and would be dropped from this batch, while a duplicate silently \
179				 renumbers a window that already published under another row \
180				 (order={ordered}, buckets={bucketed})"
181			);
182		}
183		let results =
184			self.apply_events(store, buckets, order, &slot_resolved, &mut meta_loaded, &new_accumulator)?;
185		self.persist_meta(store, meta_loaded)?;
186		Ok(results)
187	}
188
189	fn load_meta(
190		&mut self,
191		store: &mut dyn StateStore,
192		buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
193	) -> Result<MetaLoaded<G, C>> {
194		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
195		for (group, _) in buckets.keys() {
196			if !meta_loaded.contains_key(group) {
197				let batch = load_batch_meta(store, &mut self.meta, &meta_key_for(group))?;
198				meta_loaded.insert(group.clone(), batch);
199			}
200		}
201		Ok(meta_loaded)
202	}
203
204	fn resolve_slots<K>(order: &[(G, WindowSpan<C>)], slot_key: &K) -> SlotResolved<G, C>
205	where
206		K: Fn(&G, C) -> (GroupId, EncodedKey),
207	{
208		let mut resolved: SlotResolved<G, C> = HashMap::with_capacity(order.len());
209		for (group, span) in order {
210			let (id, key) = slot_key(group, span.start);
211			resolved.insert(
212				(group.clone(), *span),
213				ResolvedSlot {
214					group: id,
215					key,
216				},
217			);
218		}
219		resolved
220	}
221
222	fn apply_events<NA>(
223		&mut self,
224		store: &mut dyn StateStore,
225		mut buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
226		order: &[(G, WindowSpan<C>)],
227		slot_resolved: &SlotResolved<G, C>,
228		meta_loaded: &mut MetaLoaded<G, C>,
229		new_accumulator: &NA,
230	) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
231	where
232		NA: Fn() -> Accumulator,
233	{
234		let mut pending: Vec<PendingEmit<G, C, Accumulator::Output>> = Vec::new();
235
236		for ordered in order {
237			let Some(events) = buckets.remove(ordered) else {
238				continue;
239			};
240			let (group, span) = ordered.clone();
241			meta_loaded.entry(group.clone()).or_default().observe(span.start);
242
243			let Some(ResolvedSlot {
244				group: id,
245				key,
246			}) = slot_resolved.get(&(group.clone(), span)).cloned()
247			else {
248				continue;
249			};
250			let state_key = WindowStateKey::new(id, key.clone());
251
252			let mut accumulator: Accumulator =
253				self.accumulators.get(store, &state_key)?.unwrap_or_else(new_accumulator);
254			let was_empty_before = accumulator.is_empty();
255			let prior = if was_empty_before {
256				None
257			} else {
258				accumulator.finalize()
259			};
260
261			for event in events {
262				match event {
263					AccumulatorEvent::Add(c) => {
264						accumulator.add(&c);
265					}
266					AccumulatorEvent::Remove(c) => {
267						if accumulator.is_empty() {
268							self.dropped_retractions += 1;
269							continue;
270						}
271						accumulator.remove(&c);
272					}
273				}
274			}
275
276			let value = accumulator.finalize();
277			self.accumulators.put(store, &state_key, accumulator)?;
278
279			match value {
280				Some(value) => pending.push(PendingEmit {
281					group_id: id,
282					key,
283					group,
284					span,
285					value,
286					prior,
287					withdraw: false,
288				}),
289				None => {
290					if let Some(p) = prior.clone() {
291						pending.push(PendingEmit {
292							group_id: id,
293							key,
294							group,
295							span,
296							value: p,
297							prior,
298							withdraw: true,
299						});
300					}
301				}
302			}
303		}
304		reifydb_assertions! {
305			assert!(
306				buckets.is_empty(),
307				"the resolution order must drain every bucket; a leftover bucket's events were \
308				 silently dropped and its window never gets a row number (leftovers={})",
309				buckets.len()
310			);
311		}
312
313		let pairs: Vec<(GroupId, EncodedKey)> = pending.iter().map(|p| (p.group_id, p.key.clone())).collect();
314		let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
315		reifydb_assertions! {
316			let requested = pairs.len();
317			let returned = rows.len();
318			assert!(
319				returned == requested,
320				"the identity batch must return one row per publishing window; a short batch makes the \
321				 zip below drop the tail, so those windows publish nothing while their accumulators \
322				 already advanced (requested={requested}, returned={returned})"
323			);
324		}
325
326		let mut results: Vec<WindowResult<G, C, Accumulator::Output>> = Vec::with_capacity(pending.len());
327		for (emit, (row_number, is_new)) in pending.into_iter().zip(rows) {
328			let kind = if emit.withdraw {
329				reifydb_assertions! {
330					let group_id = emit.group_id;
331					assert!(
332						!is_new,
333						"a window holding a prior output must already own its mapping; minting \
334						 one here means the identity was released while the row it addresses \
335						 was still live, and this withdrawal names a row no sink can find \
336						 (group={group_id:?}, row={row_number:?})"
337					);
338				}
339				store.remove_row_number(emit.group_id, &emit.key)?;
340				EmitKind::Remove
341			} else if is_new {
342				EmitKind::Insert
343			} else {
344				EmitKind::Update
345			};
346			results.push(WindowResult {
347				row_number,
348				group: emit.group,
349				span: emit.span,
350				value: emit.value,
351				prior: emit.prior,
352				kind,
353			});
354		}
355		Ok(results)
356	}
357
358	pub fn expire(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<Vec<ExpiredWindow<G, C>>> {
359		let due: Vec<(GroupStateKey, TumblingIndexEntry<G, C>)> =
360			expiry_due(store, threshold, self.expire_batch)?;
361
362		let mut out: Vec<ExpiredWindow<G, C>> = Vec::new();
363		for (index_key, entry) in due {
364			expiry_drop(store, &index_key)?;
365			out.push(ExpiredWindow {
366				group: entry.group,
367				group_id: GroupId(entry.group_id),
368				window_start: entry.window_start,
369			});
370		}
371		note_when_expiry_capped(out.len(), self.expire_batch);
372		Ok(out)
373	}
374
375	fn persist_meta(&mut self, store: &mut dyn StateStore, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
376		persist_batch_meta(store, &mut self.meta, meta_loaded)
377	}
378
379	pub fn expire_meta(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<usize> {
380		sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
381	}
382}
383
384#[cfg(test)]
385mod tests {
386	use std::{
387		collections::BTreeMap,
388		sync::atomic::{AtomicUsize, Ordering},
389	};
390
391	use reifydb_codec::key::encoded::EncodedKey;
392	use reifydb_core::{key::operator_state::GroupId, metrics::heap::HeapSize};
393	use reifydb_macro::operator_state;
394	use reifydb_value::{Result, factory::time::at_millis, value::datetime::DateTime};
395
396	use crate::{
397		operator::state::{mock::MockStore, seal::coord::Coord},
398		window::{
399			accumulator::{WindowAccumulator, mock::SumAccumulator},
400			engine::{
401				AccumulatorEvent, EmitKind, GroupMeta, MetaHighWater, WindowResult,
402				config::WindowEngineConfig,
403				meta_key_for,
404				tumbling::{TumblingBuckets, TumblingEngine},
405			},
406			span::WindowSpan,
407		},
408	};
409
410	fn test_config() -> WindowEngineConfig {
411		WindowEngineConfig::builder().build()
412	}
413
414	fn row_key(group: &u32, window_start: DateTime) -> EncodedKey {
415		EncodedKey::builder().u32(*group).u64(window_start.to_order()).build()
416	}
417
418	fn slot_key(group: &u32, window_start: DateTime) -> (GroupId, EncodedKey) {
419		(GroupId::ROOT, row_key(group, window_start))
420	}
421
422	fn order_of<C>(buckets: &TumblingBuckets<u32, DateTime, C>) -> Vec<(u32, WindowSpan<DateTime>)> {
423		buckets.keys().cloned().collect()
424	}
425
426	fn apply_sums(
427		engine: &mut TumblingEngine<u32, DateTime, SumAccumulator>,
428		store: &mut MockStore,
429		buckets: TumblingBuckets<u32, DateTime, i64>,
430	) -> Result<Vec<WindowResult<u32, DateTime, i64>>> {
431		let order = order_of(&buckets);
432		engine.apply(store, buckets, &order, slot_key, SumAccumulator::default)
433	}
434
435	fn apply_counting(
436		engine: &mut TumblingEngine<u32, DateTime, CountingAcc>,
437		store: &mut MockStore,
438		buckets: TumblingBuckets<u32, DateTime, i64>,
439	) -> Result<Vec<WindowResult<u32, DateTime, i64>>> {
440		let order = order_of(&buckets);
441		engine.apply(store, buckets, &order, slot_key, CountingAcc::default)
442	}
443
444	// Faces reindex through a long-lived engine, but these tests seed with throwaway ones, so the
445	// write-through store copy is what the expiring engine's mirror hydrates from - the restart path.
446	fn reindex_window(
447		store: &mut MockStore,
448		group: &u32,
449		window_start: DateTime,
450		prior: Option<u64>,
451		new: Option<u64>,
452	) -> Result<()> {
453		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
454		engine.reindex_window(
455			store,
456			group,
457			window_start,
458			GroupId::ROOT,
459			&row_key(group, window_start),
460			prior,
461			new,
462		)
463	}
464
465	fn order(millis: u64) -> u64 {
466		at_millis(millis).to_order()
467	}
468
469	fn seed_window(
470		store: &mut MockStore,
471		window_start: u64,
472		contribution: i64,
473	) -> WindowResult<u32, DateTime, i64> {
474		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
475		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
476		buckets.insert(
477			(1u32, WindowSpan::new(at_millis(window_start), at_millis(window_start + 1))),
478			vec![AccumulatorEvent::Add(contribution)],
479		);
480		let mut results = apply_sums(&mut engine, store, buckets).expect("apply");
481		results.pop().expect("one window")
482	}
483
484	fn apply_event(store: &mut MockStore, window_start: u64, event: AccumulatorEvent<i64>) {
485		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
486		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
487		buckets.insert(
488			(1u32, WindowSpan::new(at_millis(window_start), at_millis(window_start + 1))),
489			vec![event],
490		);
491		apply_sums(&mut engine, store, buckets).expect("apply");
492	}
493
494	fn group_slot(group: &u32, window_start: DateTime) -> (GroupId, EncodedKey) {
495		// The shape a sub-flow window driver installs: every window interns as its own
496		// (partition, coord) group sharing one empty row key, so the group alone separates them.
497		(GroupId(u64::from(*group) * 1_000_000 + window_start.to_order()), EncodedKey::new(Vec::new()))
498	}
499
500	fn apply_group_scoped(
501		engine: &mut TumblingEngine<u32, DateTime, SumAccumulator>,
502		store: &mut MockStore,
503		buckets: TumblingBuckets<u32, DateTime, i64>,
504	) -> Vec<WindowResult<u32, DateTime, i64>> {
505		let order = order_of(&buckets);
506		let out = engine.apply(store, buckets, &order, group_slot, SumAccumulator::default).expect("apply");
507		out
508	}
509
510	fn one_bucket(group: u32, window_start: u64, contribution: i64) -> TumblingBuckets<u32, DateTime, i64> {
511		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
512		buckets.insert(
513			(group, WindowSpan::new(at_millis(window_start), at_millis(window_start + 1))),
514			vec![AccumulatorEvent::Add(contribution)],
515		);
516		buckets
517	}
518
519	#[test]
520	fn group_scoped_windows_keep_separate_state_under_one_shared_row_key() {
521		// A group-scoped driver addresses every window with the same empty row key and leans on the
522		// group to separate them. If the group fell out of the accumulator key or the row-number
523		// lookup, every window of the operator would fold into one accumulator under one row.
524		let mut store = MockStore::default();
525		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
526
527		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
528		buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(5)]);
529		buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(7)]);
530		buckets.insert((2u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(9)]);
531		let results = apply_group_scoped(&mut engine, &mut store, buckets);
532
533		assert_eq!(results.len(), 3);
534		let mut rows: Vec<u64> = results.iter().map(|r| r.row_number.0).collect();
535		rows.sort_unstable();
536		rows.dedup();
537		assert_eq!(rows.len(), 3, "each window must own a row of its own despite the shared row key");
538		let mut values: Vec<i64> = results.iter().map(|r| r.value).collect();
539		values.sort_unstable();
540		assert_eq!(values, vec![5, 7, 9], "no window may see another's contributions");
541
542		// A restart reloads one window's accumulator; it must be that window's alone.
543		let mut restarted = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
544		let out = apply_group_scoped(&mut restarted, &mut store, one_bucket(1, 0, 1));
545		assert_eq!(out[0].value, 6, "the reloaded accumulator carries only window (1, 0)");
546	}
547
548	#[test]
549	fn an_expired_window_names_the_group_its_state_lived_in() {
550		// The expiry index lives in the root group ordered by due time, so the group rides in the key tail and
551		// the entry - the driver needs that id to drop the per-window meta and release the row number, or it
552		// strands both or erases another group's identity.
553		let mut store = MockStore::default();
554		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
555		let results = apply_group_scoped(&mut engine, &mut store, one_bucket(3, 40, 5));
556		let published = &results[0];
557		let (group, _) = group_slot(&published.group, published.span.start);
558		let (_, slot) = group_slot(&published.group, published.span.start);
559		engine.reindex_window(&mut store, &published.group, published.span.start, group, &slot, None, Some(10))
560			.unwrap();
561
562		let mut restarted = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
563		let expired = restarted.expire(&mut store, 10).unwrap();
564
565		assert_eq!(expired.len(), 1);
566		assert_eq!(expired[0].group_id, group, "the entry must name the group whose state it drained");
567		assert!(
568			store.contains_row_mapping(group, &EncodedKey::new(Vec::new())),
569			"the identity the driver is about to release must still be resolvable from that group alone"
570		);
571		assert!(
572			store.contains_row_mapping(group, &EncodedKey::new(Vec::new())),
573			"sealing releases no identity of its own; the reaper collects it at or below the ledger"
574		);
575	}
576
577	#[test]
578	fn expire_returns_only_due_windows_and_drops_only_their_index_entries() {
579		let mut store = MockStore::default();
580		// Two live windows; the face indexes each by its last_event_time (10 and 90).
581		let w0 = seed_window(&mut store, 0, 5);
582		reindex_window(&mut store, &w0.group, w0.span.start, None, Some(10)).unwrap();
583		let w100 = seed_window(&mut store, 100, 7);
584		reindex_window(&mut store, &w100.group, w100.span.start, None, Some(90)).unwrap();
585		assert_eq!(store.index_entry_count(), 2, "both live windows are indexed");
586
587		// Threshold 10: only the window whose expiry (10) is at/under the threshold is due.
588		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
589		let expired = engine.expire(&mut store, 10).unwrap();
590		assert_eq!(expired.len(), 1, "exactly one window is due, not the whole population");
591		assert_eq!(expired[0].window_start, at_millis(0));
592		assert_eq!(store.index_entry_count(), 1, "the due window's index entry is gone, the other remains");
593
594		// Expiry is the seal's own index and nothing else: the accumulators behind both windows are
595		// still on disk here, and the reaper is what collects them at or below the ledger.
596		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
597		let later = engine.expire(&mut store, 1000).unwrap();
598		assert_eq!(later.len(), 1);
599		assert_eq!(later[0].window_start, at_millis(100));
600		assert_eq!(store.index_entry_count(), 0);
601	}
602
603	#[test]
604	fn an_expiry_entry_whose_state_was_reclaimed_still_drains() {
605		// The expiry index is ordered by due time, so a group's entries sit outside its key range
606		// and survive reclamation of its data, left to drain on their own. An entry that refused to
607		// drop because the state behind it was gone would sit in the index forever.
608		let mut store = MockStore::default();
609		let w = seed_window(&mut store, 0, 5);
610		reindex_window(&mut store, &w.group, w.span.start, None, Some(10)).unwrap();
611		assert_eq!(store.index_entry_count(), 1, "precondition: the window is indexed");
612		assert_eq!(store.drop_accumulator_entries(), 1, "precondition: the reaper erased the accumulator");
613
614		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
615		let expired = engine.expire(&mut store, 10).unwrap();
616
617		assert_eq!(expired.len(), 1, "the stale entry must still drain, or the index would grow forever");
618		assert_eq!(store.index_entry_count(), 0, "a stale entry drops itself on the scan that finds it");
619	}
620
621	#[test]
622	fn a_window_whose_accumulator_was_reclaimed_updates_its_row_rather_than_inserting_a_second() {
623		// Phase-1 reclamation erases the accumulator and keeps the row-number mapping, so the
624		// published row stays addressable and a later batch must update it in place. A second
625		// insert lays a duplicate row over a live one, which a sink folding the stream cannot place.
626		let mut store = MockStore::default();
627		let published = seed_window(&mut store, 0, 5);
628		assert_eq!(store.drop_accumulator_entries(), 1, "precondition: reclaim erased the accumulator");
629		assert!(
630			store.contains_row_mapping(GroupId::ROOT, &row_key(&1, at_millis(0))),
631			"precondition: the identity half must survive the data phase"
632		);
633
634		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
635		let results = apply_sums(&mut engine, &mut store, one_bucket(1, 0, 3)).expect("apply");
636
637		assert_eq!(results.len(), 1);
638		assert_eq!(results[0].row_number, published.row_number, "the woken window keeps the row it published");
639		assert_eq!(
640			results[0].kind,
641			EmitKind::Update,
642			"the published row survived the sweep, so this is an update and not a second insert"
643		);
644	}
645
646	#[test]
647	fn a_window_that_publishes_nothing_mints_no_identity() {
648		// Minting a row number before the publish decision leaves a mapping addressing a row the
649		// view never held. The window then looks published, so its next value goes out as an update
650		// whose pre-image is absent.
651		let mut store = MockStore::default();
652		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
653
654		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
655		buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Remove(5)]);
656		let results = apply_sums(&mut engine, &mut store, buckets).expect("apply");
657
658		assert!(results.is_empty(), "a window that finalizes to nothing publishes nothing");
659		assert!(
660			!store.contains_row_mapping(GroupId::ROOT, &row_key(&1, at_millis(0))),
661			"and must leave no identity behind for a row it never published"
662		);
663
664		let mut woken = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
665		let out = apply_sums(&mut woken, &mut store, one_bucket(1, 0, 4)).expect("apply");
666
667		assert_eq!(out[0].kind, EmitKind::Insert, "the first row this window publishes is an insert");
668	}
669
670	#[test]
671	fn an_emptied_window_seals_without_releasing_anything() {
672		// An accumulator drained to zero still exists and still owns its mapping, but finalizes to
673		// none - indistinguishable by value from a group the reaper erased. Sealing releases no
674		// identity at all, so the distinction it used to depend on cannot be got wrong.
675		let mut store = MockStore::default();
676		let w = seed_window(&mut store, 0, 5);
677		apply_event(&mut store, 0, AccumulatorEvent::Remove(5));
678		reindex_window(&mut store, &w.group, w.span.start, None, Some(10)).unwrap();
679
680		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
681		let expired = engine.expire(&mut store, 10).unwrap();
682
683		assert_eq!(expired.len(), 1);
684		assert_eq!(
685			store.drop_accumulator_entries(),
686			1,
687			"the emptied accumulator outlives the seal and is the reaper's to collect"
688		);
689	}
690
691	#[test]
692	fn meta_reclaimed_when_group_stale_past_threshold() {
693		// A group whose high water falls below the staleness threshold has stopped advancing and its
694		// GroupMeta must be reclaimed. `persist_meta` writes one meta per group and never removes
695		// it, so without the sweep one internal-state key leaks per distinct group forever.
696		let mut store = MockStore::default();
697		seed_window(&mut store, 0, 5);
698		assert_eq!(store.meta_entry_count(), 1, "applying a window persisted the group's meta");
699
700		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
701		let dropped = engine.expire_meta(&mut store, 100).unwrap();
702		assert_eq!(dropped, 1, "the group's high water (0) is below the threshold (100)");
703		assert_eq!(store.meta_entry_count(), 0, "a stale group must not leak its GroupMeta");
704	}
705
706	#[test]
707	fn meta_survives_while_group_high_water_at_or_after_threshold() {
708		// A group whose high water is at or beyond the threshold is still live, its late-event
709		// horizon not yet passed, and must keep its meta.
710		let mut store = MockStore::default();
711		seed_window(&mut store, 100, 7);
712		assert_eq!(store.meta_entry_count(), 1);
713
714		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
715		let dropped = engine.expire_meta(&mut store, 50).unwrap();
716		assert_eq!(dropped, 0, "high water (100) is not below the threshold (50)");
717		assert_eq!(store.meta_entry_count(), 1, "a group within the staleness horizon keeps its meta");
718	}
719
720	#[test]
721	fn meta_sweep_leaves_row_number_mappings_intact() {
722		// The sweep targets only meta keys and must not touch the write-once row-number mappings
723		// that share the same tier; deleting those corrupts the operator.
724		let mut store = MockStore::default();
725		seed_window(&mut store, 0, 5);
726		store.seed_mapping_key(0x01);
727		assert_eq!(store.mapping_entry_count(), 1);
728
729		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
730		engine.expire_meta(&mut store, 100).unwrap();
731		assert_eq!(store.meta_entry_count(), 0, "the stale group's meta is swept");
732		assert_eq!(store.mapping_entry_count(), 1, "the sweep must not touch row-number mapping keys");
733	}
734
735	#[test]
736	fn meta_sweep_skips_then_reclaims_as_threshold_advances() {
737		// The low-water guard skips the scan while the smallest high water is at or above the
738		// threshold, but must still reclaim once the threshold advances past it: it is an
739		// optimization to avoid scanning every apply, never a correctness hole.
740		let mut store = MockStore::default();
741		seed_window(&mut store, 100, 7);
742
743		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
744		// Below the group's high water: nothing stale; the sweep records the low-water bound (100).
745		assert_eq!(engine.expire_meta(&mut store, order(50)).unwrap(), 0);
746		assert_eq!(store.meta_entry_count(), 1);
747		// Threshold equals the bound: still nothing strictly below it, a no-op skip.
748		assert_eq!(engine.expire_meta(&mut store, order(100)).unwrap(), 0);
749		assert_eq!(store.meta_entry_count(), 1);
750		// Threshold crosses the group's high water: it is now stale and reclaimed.
751		assert_eq!(engine.expire_meta(&mut store, order(101)).unwrap(), 1);
752		assert_eq!(store.meta_entry_count(), 0, "the guard must not permanently skip a group that goes stale");
753	}
754
755	#[test]
756	fn expire_threshold_is_inclusive() {
757		let mut store = MockStore::default();
758		let w = seed_window(&mut store, 0, 4);
759		reindex_window(&mut store, &w.group, w.span.start, None, Some(50)).unwrap();
760
761		// One below the expiry: not due, and the scan leaves the index intact.
762		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
763		assert!(engine.expire(&mut store, 49).unwrap().is_empty());
764		assert_eq!(store.index_entry_count(), 1);
765
766		// Exactly at the expiry: due (the face folds the strict close boundary into the threshold).
767		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
768		assert_eq!(engine.expire(&mut store, 50).unwrap().len(), 1);
769	}
770
771	#[test]
772	fn expire_processes_at_most_expire_batch_then_resumes_next_tick() {
773		// Node ticks run serialized, so draining every due window in one tick lets a burst on one
774		// bloated operator stall the whole actor pass. The cap bounds one tick and the remainder
775		// stays in the due index, which sorts by inverted expiry so the oldest backlog defers.
776		let mut store = MockStore::default();
777		for (start, due) in [(0u64, 10u64), (100, 20), (200, 30)] {
778			let w = seed_window(&mut store, start, 1);
779			reindex_window(&mut store, &w.group, w.span.start, None, Some(due)).unwrap();
780		}
781		assert_eq!(store.index_entry_count(), 3);
782
783		let capped = WindowEngineConfig::builder().expire_batch(2).build();
784
785		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(capped.clone());
786		let first = engine.expire(&mut store, 1000).unwrap();
787		assert_eq!(first.len(), 2, "one tick drains at most expire_batch windows");
788		assert_eq!(first[0].window_start, at_millis(200), "inverted key order: newest due drains first");
789		assert_eq!(first[1].window_start, at_millis(100));
790		assert_eq!(store.index_entry_count(), 1, "the deferred window keeps its index entry");
791
792		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(capped);
793		let second = engine.expire(&mut store, 1000).unwrap();
794		assert_eq!(second.len(), 1, "the next tick picks up the deferred backlog");
795		assert_eq!(second[0].window_start, at_millis(0));
796		assert_eq!(store.index_entry_count(), 0);
797	}
798
799	#[test]
800	fn reindex_rekeys_without_leaving_a_stale_entry() {
801		let mut store = MockStore::default();
802		let w = seed_window(&mut store, 0, 9);
803		// Index at 10, then a later event advances the window's expiry to 80.
804		reindex_window(&mut store, &w.group, w.span.start, None, Some(10)).unwrap();
805		reindex_window(&mut store, &w.group, w.span.start, Some(10), Some(80)).unwrap();
806		assert_eq!(store.index_entry_count(), 1, "re-keying must not leave the old entry behind");
807
808		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
809		assert!(engine.expire(&mut store, 10).unwrap().is_empty(), "no longer due at the old expiry");
810		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
811		assert_eq!(engine.expire(&mut store, 80).unwrap().len(), 1, "due at the new expiry");
812	}
813
814	#[test]
815	fn accumulator_survives_restart() {
816		// A window emptying under retraction emits a terminal Remove carrying the accumulator's
817		// pre-batch finalize, read back from the store. Dropping the engine between publish and
818		// retraction forces that read rather than serving it from the in-memory cache.
819		let mut store = MockStore::default();
820
821		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
822		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
823		buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(5)]);
824		let published: Vec<WindowResult<u32, DateTime, i64>> =
825			apply_sums(&mut engine, &mut store, buckets).unwrap();
826		assert_eq!(published.len(), 1);
827		assert!(matches!(published[0].kind, EmitKind::Insert));
828		assert_eq!(published[0].value, 5);
829
830		// A brand new engine with empty caches, forced to read the persisted accumulator back.
831		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
832		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
833		buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Remove(5)]);
834		let withdrawn: Vec<WindowResult<u32, DateTime, i64>> =
835			apply_sums(&mut engine, &mut store, buckets).unwrap();
836
837		assert_eq!(withdrawn.len(), 1, "emptying the window emits exactly one terminal diff");
838		assert!(
839			matches!(withdrawn[0].kind, EmitKind::Remove),
840			"the window emptied under retraction, so the last published row must be withdrawn"
841		);
842		assert_eq!(withdrawn[0].value, 5, "the withdrawn value is the reloaded pre-batch accumulator output");
843		assert_eq!(
844			withdrawn[0].row_number, published[0].row_number,
845			"the withdrawal targets the same row that was published"
846		);
847	}
848
849	#[test]
850	fn accumulator_survives_lru_eviction() {
851		// The other way a read reaches the store is LRU eviction, with no restart: the accumulator
852		// cache holds 8 windows, so more than that evicts the oldest and the next access re-reads it.
853		let mut store = MockStore::default();
854		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
855
856		let mut published_group_1: Vec<WindowResult<u32, DateTime, i64>> = Vec::new();
857		for group in 1u32..=11u32 {
858			let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
859			buckets.insert(
860				(group, WindowSpan::new(at_millis(0), at_millis(1))),
861				vec![AccumulatorEvent::Add(i64::from(group))],
862			);
863			let out: Vec<WindowResult<u32, DateTime, i64>> =
864				apply_sums(&mut engine, &mut store, buckets).unwrap();
865			if group == 1 {
866				published_group_1 = out;
867			}
868		}
869		assert_eq!(published_group_1.len(), 1);
870		assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
871		assert_eq!(published_group_1[0].value, 1);
872
873		// Group 1's window was pushed out of the 8-slot cache by the later groups, so the same
874		// engine must re-read its accumulator from the store to apply this retraction.
875		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
876		buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Remove(1)]);
877		let withdrawn: Vec<WindowResult<u32, DateTime, i64>> =
878			apply_sums(&mut engine, &mut store, buckets).unwrap();
879
880		assert_eq!(withdrawn.len(), 1, "emptying the evicted window emits exactly one terminal diff");
881		assert!(
882			matches!(withdrawn[0].kind, EmitKind::Remove),
883			"the evicted window emptied under retraction, so the last published row must be withdrawn"
884		);
885		assert_eq!(withdrawn[0].value, 1, "the withdrawn value is the reloaded accumulator output for group 1");
886		assert_eq!(
887			withdrawn[0].row_number, published_group_1[0].row_number,
888			"the withdrawal targets the same row that was published for group 1"
889		);
890	}
891
892	// Counts clones of the probe accumulator, so a test can prove expire() finalizes from the
893	// cache-resident view instead of deep-cloning.
894	static COUNTING_ACC_CLONES: AtomicUsize = AtomicUsize::new(0);
895
896	#[operator_state]
897	#[derive(Debug, Default)]
898	struct CountingAcc {
899		sum: i64,
900		count: u64,
901	}
902
903	impl Clone for CountingAcc {
904		fn clone(&self) -> Self {
905			COUNTING_ACC_CLONES.fetch_add(1, Ordering::SeqCst);
906			Self {
907				sum: self.sum,
908				count: self.count,
909			}
910		}
911	}
912
913	impl HeapSize for CountingAcc {
914		fn heap_size(&self) -> usize {
915			0
916		}
917	}
918
919	impl WindowAccumulator for CountingAcc {
920		type Contribution = i64;
921		type Output = i64;
922
923		fn add(&mut self, contribution: &i64) {
924			self.sum += *contribution;
925			self.count += 1;
926		}
927		fn remove(&mut self, contribution: &i64) {
928			self.sum -= *contribution;
929			self.count = self.count.saturating_sub(1);
930		}
931		fn finalize(&self) -> Option<i64> {
932			(self.count > 0).then_some(self.sum)
933		}
934		fn is_empty(&self) -> bool {
935			self.count == 0
936		}
937		fn merge(&mut self, other: &Self) {
938			self.sum += other.sum;
939			self.count += other.count;
940		}
941		fn unmerge(&mut self, other: &Self) {
942			self.sum -= other.sum;
943			self.count = self.count.saturating_sub(other.count);
944		}
945	}
946
947	#[test]
948	fn expire_touches_no_accumulator_on_either_residency_path() {
949		// expire() is the seal's index scan and nothing more: the seal publishes nothing, so it must
950		// read no accumulator on either the same-engine Native path or the store-loaded archived
951		// one, and the clone counter must stay at zero for both.
952		let mut store = MockStore::default();
953		let mut engine = TumblingEngine::<u32, DateTime, CountingAcc>::new(test_config());
954		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
955		buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(5)]);
956		buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(7)]);
957		let results = apply_counting(&mut engine, &mut store, buckets).unwrap();
958		assert_eq!(results.len(), 2);
959		for w in &results {
960			let expiry = if w.span.start == at_millis(0) {
961				10
962			} else {
963				90
964			};
965			reindex_window(&mut store, &w.group, w.span.start, None, Some(expiry)).unwrap();
966		}
967
968		let before = COUNTING_ACC_CLONES.load(Ordering::SeqCst);
969
970		// Same engine: the window-0 accumulator is clean Native after flush.
971		let expired = engine.expire(&mut store, 10).unwrap();
972		assert_eq!(expired.len(), 1);
973		assert_eq!(expired[0].window_start, at_millis(0), "Native-resident path");
974
975		// Fresh engine: the window-100 entry would have loaded from the store and finalized through
976		// the archived view.
977		let mut fresh = TumblingEngine::<u32, DateTime, CountingAcc>::new(test_config());
978		let expired = fresh.expire(&mut store, 1000).unwrap();
979		assert_eq!(expired.len(), 1);
980		assert_eq!(expired[0].window_start, at_millis(100), "archived path");
981
982		assert_eq!(
983			COUNTING_ACC_CLONES.load(Ordering::SeqCst) - before,
984			0,
985			"expire must not clone accumulators on either the Native or the archived path"
986		);
987	}
988
989	fn read_high_water(
990		engine: &mut TumblingEngine<u32, DateTime, SumAccumulator>,
991		store: &mut MockStore,
992		group: u32,
993	) -> Option<u64> {
994		engine.meta.get(store, &meta_key_for(&group)).unwrap().and_then(|meta| meta.high_water_order())
995	}
996
997	#[test]
998	fn warmed_meta_high_water_advances_across_engine_restarts() {
999		// A bump applied by one engine must be visible to the next, or a restart replays late events.
1000		let mut store = MockStore::default();
1001		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1002		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
1003		buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(5)]);
1004		apply_sums(&mut engine, &mut store, buckets).unwrap();
1005
1006		let mut fresh = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1007		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
1008		buckets.insert((1u32, WindowSpan::new(at_millis(200), at_millis(201))), vec![AccumulatorEvent::Add(7)]);
1009		apply_sums(&mut fresh, &mut store, buckets).unwrap();
1010
1011		assert_eq!(
1012			read_high_water(&mut fresh, &mut store, 1),
1013			Some(order(200)),
1014			"the bump must be durable the moment it is applied"
1015		);
1016
1017		let mut third = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1018		assert_eq!(
1019			read_high_water(&mut third, &mut store, 1),
1020			Some(order(200)),
1021			"the write round-trips through the store"
1022		);
1023	}
1024
1025	#[test]
1026	fn a_persisted_none_high_water_still_accepts_a_bump() {
1027		// Retraction-only groups persist a none high water; refusing to advance it strands them.
1028		let mut store = MockStore::default();
1029		let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1030		engine.meta
1031			.put(
1032				&mut store,
1033				&meta_key_for(&1u32),
1034				GroupMeta {
1035					high_water: None,
1036				},
1037			)
1038			.unwrap();
1039
1040		let mut fresh = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1041		let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
1042		buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(7)]);
1043		apply_sums(&mut fresh, &mut store, buckets).unwrap();
1044
1045		let mut third = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1046		assert_eq!(
1047			read_high_water(&mut third, &mut store, 1),
1048			Some(order(100)),
1049			"a none high water must advance to the first observed coordinate"
1050		);
1051	}
1052}