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