Skip to main content

reifydb_flow/window/engine/
tumbling_carry.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	slice::from_ref,
10};
11
12use reifydb_codec::{
13	key::encoded::{EncodedKey, IntoEncodedKey},
14	row::operator::{OperatorState, decode},
15};
16use reifydb_core::{
17	key::operator_state::{GroupId, GroupStateKey, IntoGroupStateKey},
18	metrics::heap::HeapSize,
19	state::{cache::StateCache, store::StateStore},
20};
21use reifydb_macro::operator_state;
22use reifydb_value::{Result, reifydb_assertions};
23
24use crate::window::{
25	accumulator::WindowAccumulator,
26	engine::{
27		AccumulatorEvent, EmitKind, MetaHighWater, MetaKey, WindowResult, WindowStateKey,
28		config::TumblingCarryConfig, meta_key_for, sweep_stale_meta, tumbling::TumblingBuckets,
29	},
30	span::{SlotSpan, WindowAnchor, WindowSpan},
31};
32
33#[operator_state]
34#[derive(Debug, Clone)]
35pub struct WindowEntry<C, Carry, Output> {
36	span: WindowSpan<C>,
37	carry_out: Option<Carry>,
38	last_output: Option<Output>,
39}
40
41impl<C: HeapSize, Carry: HeapSize, Output: HeapSize> HeapSize for WindowEntry<C, Carry, Output> {
42	fn heap_size(&self) -> usize {
43		self.span.heap_size() + self.carry_out.heap_size() + self.last_output.heap_size()
44	}
45}
46
47#[operator_state]
48#[derive(Debug, Clone)]
49pub struct CarryMeta<C, Carry, Output> {
50	high_water: Option<C>,
51	sealed_up_to: Option<C>,
52	sealed_carry: Option<Carry>,
53	windows: BTreeMap<C, WindowEntry<C, Carry, Output>>,
54}
55
56impl<C: HeapSize, Carry: HeapSize, Output: HeapSize> HeapSize for CarryMeta<C, Carry, Output> {
57	fn heap_size(&self) -> usize {
58		self.high_water.heap_size()
59			+ self.sealed_up_to.heap_size()
60			+ self.sealed_carry.heap_size()
61			+ self.windows.heap_size()
62	}
63}
64
65impl<C, Carry, Output> Default for CarryMeta<C, Carry, Output> {
66	fn default() -> Self {
67		Self {
68			high_water: None,
69			sealed_up_to: None,
70			sealed_carry: None,
71			windows: BTreeMap::new(),
72		}
73	}
74}
75
76impl<C: WindowAnchor, Carry, Output> MetaHighWater for CarryMeta<C, Carry, Output>
77where
78	Self: OperatorState,
79{
80	fn high_water_order(&self) -> Option<u64> {
81		self.high_water.map(|hw| hw.order_key().to_order())
82	}
83}
84
85type MetaLoaded<G, C, Carry, Output> = HashMap<G, CarryMeta<C, Carry, Output>>;
86type SlotResolved = Vec<Option<(GroupId, EncodedKey)>>;
87
88struct PendingCarry<C, Output> {
89	group_id: GroupId,
90	key: EncodedKey,
91	span: WindowSpan<C>,
92	value: Output,
93	withdraw: bool,
94}
95
96pub struct TumblingCarryEngine<G, C: WindowAnchor, Accumulator, Carry, Output> {
97	accumulators: StateCache<WindowStateKey, Accumulator>,
98	meta: StateCache<MetaKey, CarryMeta<C, Carry, Output>>,
99	meta_low_water: Option<u64>,
100	retention: Option<SlotSpan<C>>,
101	_pd: PhantomData<G>,
102}
103
104impl<G, C, Accumulator, Carry, Output> TumblingCarryEngine<G, C, Accumulator, Carry, Output>
105where
106	G: Clone + Eq + Ord + Hash + Debug,
107	C: WindowAnchor + Hash,
108	Accumulator: WindowAccumulator,
109	Carry: Clone + Debug,
110	Output: Clone + Debug,
111	for<'a> &'a G: IntoEncodedKey,
112	C: HeapSize,
113	Carry: HeapSize,
114	Output: HeapSize,
115	CarryMeta<C, Carry, Output>: OperatorState,
116{
117	pub fn new(config: TumblingCarryConfig<C>) -> Self {
118		Self {
119			accumulators: StateCache::<WindowStateKey, Accumulator>::new(),
120			meta: StateCache::<MetaKey, CarryMeta<C, Carry, Output>>::new(),
121			meta_low_water: None,
122			retention: config.retention(),
123			_pd: PhantomData,
124		}
125	}
126
127	pub fn expire_meta(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<usize> {
128		sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
129	}
130
131	#[allow(clippy::too_many_arguments)]
132	pub fn apply<K, NA, BO, CF>(
133		&mut self,
134		store: &mut dyn StateStore,
135		buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
136		row_key: K,
137		new_accumulator: NA,
138		build_output: BO,
139		carry_forward: CF,
140	) -> Result<Vec<WindowResult<G, C, Output>>>
141	where
142		K: Fn(&G, C) -> EncodedKey,
143		NA: Fn() -> Accumulator,
144		BO: Fn(&G, WindowSpan<C>, &Accumulator::Output, Option<&Carry>) -> Option<Output>,
145		CF: Fn(&Accumulator::Output, Option<&Carry>) -> Option<Carry>,
146	{
147		if buckets.is_empty() {
148			return Ok(Vec::new());
149		}
150		let retention = self.retention;
151		let mut meta_loaded = self.load_meta(store, &buckets)?;
152		let slot_resolved = self.resolve_survivor_rows(store, &buckets, &meta_loaded, &row_key)?;
153
154		let mut earliest_affected: HashMap<G, C> = HashMap::new();
155		for (((group, span), events), slot_pre) in buckets.into_iter().zip(slot_resolved) {
156			let entry = meta_loaded.entry(group.clone()).or_default();
157			if matches!(entry.sealed_up_to, Some(s) if span.start <= s) {
158				continue;
159			}
160			let slot_key = row_key(&group, span.start);
161			let group_id = match &slot_pre {
162				Some((gid, _)) => *gid,
163				None => store.intern_groups(from_ref(&slot_key))?.into_iter().next().unwrap().0,
164			};
165			if !entry.windows.contains_key(&span.start) && slot_pre.is_none() {
166				continue;
167			}
168
169			let mut accumulator: Accumulator = self
170				.accumulators
171				.get(store, &WindowStateKey::new(group_id, slot_key.clone()))?
172				.unwrap_or_else(&new_accumulator);
173			let mut changed = false;
174			for event in events {
175				match event {
176					AccumulatorEvent::Add(c) => {
177						accumulator.add(&c);
178						changed = true;
179					}
180					AccumulatorEvent::Remove(c) => {
181						if accumulator.is_empty() {
182							continue;
183						}
184						accumulator.remove(&c);
185						changed = true;
186					}
187				}
188			}
189			if !changed {
190				continue;
191			}
192			self.accumulators.put(store, &WindowStateKey::new(group_id, slot_key), accumulator)?;
193
194			entry.windows.entry(span.start).or_insert_with(|| WindowEntry {
195				span,
196				carry_out: None,
197				last_output: None,
198			});
199			if entry.high_water.is_none_or(|hw| span.start > hw) {
200				entry.high_water = Some(span.start);
201			}
202
203			let e = earliest_affected.entry(group).or_insert(span.start);
204			if span.start < *e {
205				*e = span.start;
206			}
207		}
208
209		let mut results: Vec<WindowResult<G, C, Output>> = Vec::new();
210		for (group, start) in earliest_affected {
211			let meta = meta_loaded.get_mut(&group).expect("affected group has meta");
212
213			let mut prev_carry: Option<Carry> = match meta.windows.range(..start).next_back() {
214				Some((_, w)) => w.carry_out.clone(),
215				None => meta.sealed_carry.clone(),
216			};
217
218			let coords: Vec<C> = meta.windows.range(start..).map(|(c, _)| *c).collect();
219			let coord_keys: Vec<EncodedKey> = coords.iter().map(|coord| row_key(&group, *coord)).collect();
220			let coord_groups = store.lookup_groups(&coord_keys)?;
221
222			let mut emptied: Vec<C> = Vec::new();
223			let mut pending: Vec<PendingCarry<C, Output>> = Vec::new();
224			for ((coord, slot_key), coord_group) in coords.into_iter().zip(coord_keys).zip(coord_groups) {
225				let span = meta.windows.get(&coord).expect("window entry present").span;
226				let finalized = match coord_group {
227					Some(coord_group) => self
228						.accumulators
229						.get(store, &WindowStateKey::new(coord_group, slot_key.clone()))?
230						.and_then(|a| a.finalize())
231						.map(|value| (coord_group, value)),
232					None => None,
233				};
234				let emitted = finalized.as_ref().and_then(|(coord_group, value)| {
235					build_output(&group, span, value, prev_carry.as_ref())
236						.map(|out| (*coord_group, value, out))
237				});
238				match emitted {
239					Some((coord_group, value, out)) => {
240						let new_carry = carry_forward(value, prev_carry.as_ref());
241						let w = meta.windows.get_mut(&coord).expect("window entry present");
242						w.carry_out = new_carry.clone();
243						w.last_output = Some(out.clone());
244						if new_carry.is_some() {
245							prev_carry = new_carry;
246						}
247						pending.push(PendingCarry {
248							group_id: coord_group,
249							key: slot_key,
250							span,
251							value: out,
252							withdraw: false,
253						});
254					}
255					None => {
256						if let Some(prev) =
257							meta.windows.get(&coord).and_then(|w| w.last_output.clone())
258							&& let Some(coord_group) = coord_group
259						{
260							pending.push(PendingCarry {
261								group_id: coord_group,
262								key: slot_key,
263								span,
264								value: prev,
265								withdraw: true,
266							});
267						}
268						emptied.push(coord);
269					}
270				}
271			}
272
273			let pairs: Vec<(GroupId, EncodedKey)> =
274				pending.iter().map(|p| (p.group_id, p.key.clone())).collect();
275			let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
276			reifydb_assertions! {
277				let requested = pairs.len();
278				let returned = rows.len();
279				assert!(
280					returned == requested,
281					"the identity batch must return one row per publishing window; a short batch makes \
282					 the zip below drop the tail, so those windows publish nothing while their carry \
283					 meta already advanced (requested={requested}, returned={returned})"
284				);
285			}
286			for (emit, (row_number, is_new)) in pending.into_iter().zip(rows) {
287				let kind = if emit.withdraw {
288					store.remove_row_number(emit.group_id, &emit.key)?;
289					EmitKind::Remove
290				} else if is_new {
291					EmitKind::Insert
292				} else {
293					EmitKind::Update
294				};
295				results.push(WindowResult {
296					row_number,
297					group: group.clone(),
298					span: emit.span,
299					value: emit.value,
300					prior: None,
301					kind,
302				});
303			}
304
305			for coord in emptied {
306				meta.windows.remove(&coord);
307			}
308
309			if let (Some(retention), Some(hw)) = (retention, meta.high_water) {
310				let to_seal: Vec<C> = meta
311					.windows
312					.keys()
313					.copied()
314					.take_while(|first| hw.span_since(*first) > retention)
315					.collect();
316				let sealed_keys: Vec<EncodedKey> =
317					to_seal.iter().map(|first| row_key(&group, *first)).collect();
318				let sealed_groups = store.lookup_groups(&sealed_keys)?;
319				for ((first, sealed_key), sealed_group) in
320					to_seal.into_iter().zip(sealed_keys).zip(sealed_groups)
321				{
322					let carry_out = meta
323						.windows
324						.get(&first)
325						.expect("sealed window entry present")
326						.carry_out
327						.clone();
328					meta.windows.remove(&first);
329					meta.sealed_up_to = Some(first);
330					meta.sealed_carry = carry_out;
331					if let Some(sealed_group) = sealed_group {
332						self.accumulators.remove(
333							store,
334							&WindowStateKey::new(sealed_group, sealed_key.clone()),
335						)?;
336						store.remove_row_number(sealed_group, &sealed_key)?;
337					}
338				}
339			}
340		}
341
342		self.persist_meta(store, meta_loaded)?;
343		Ok(results)
344	}
345
346	fn load_meta(
347		&mut self,
348		store: &mut dyn StateStore,
349		buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
350	) -> Result<MetaLoaded<G, C, Carry, Output>> {
351		let mut meta_loaded: MetaLoaded<G, C, Carry, Output> = HashMap::new();
352		let mut by_key: HashMap<GroupStateKey, G> = HashMap::new();
353		for (group, _) in buckets.keys() {
354			if meta_loaded.contains_key(group) {
355				continue;
356			}
357			meta_loaded.insert(group.clone(), CarryMeta::default());
358			by_key.insert((&meta_key_for(group)).into_group_state_key(), group.clone());
359		}
360		let keys: Vec<GroupStateKey> = by_key.keys().cloned().collect();
361		store.state_get_many_visit(&keys, &mut |key, bytes| {
362			if let Some(group) = by_key.get(&key) {
363				meta_loaded.insert(group.clone(), decode::<CarryMeta<C, Carry, Output>>(&bytes)?);
364			}
365			Ok(())
366		})?;
367		Ok(meta_loaded)
368	}
369
370	fn resolve_survivor_rows<K>(
371		&mut self,
372		store: &mut dyn StateStore,
373		buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
374		meta_loaded: &MetaLoaded<G, C, Carry, Output>,
375		row_key: &K,
376	) -> Result<SlotResolved>
377	where
378		K: Fn(&G, C) -> EncodedKey,
379	{
380		let mut survivor_keys: Vec<EncodedKey> = Vec::new();
381		let mut slot_survives: Vec<bool> = Vec::with_capacity(buckets.len());
382		for (group, span) in buckets.keys() {
383			let meta = meta_loaded.get(group);
384			let sealed = matches!(meta.and_then(|m| m.sealed_up_to), Some(s) if span.start <= s);
385			let survives = !sealed;
386			slot_survives.push(survives);
387			if survives {
388				survivor_keys.push(row_key(group, span.start));
389			}
390		}
391		let interned = store.intern_groups(&survivor_keys)?;
392		let resolved_rows: Vec<(GroupId, EncodedKey)> =
393			survivor_keys.iter().cloned().zip(interned).map(|(key, (group, _))| (group, key)).collect();
394		reifydb_assertions! {
395			let survivors = survivor_keys.len();
396			let resolved = resolved_rows.len();
397			assert!(
398				resolved == survivors,
399				"intern_group must return exactly one group per survivor key; a short batch would \
400				 leave a surviving slot with no resolved group, so the slot_resolved zip below pairs it with None \
401				 and apply silently skips the slot instead of folding into the existing window state \
402				 (survivor_keys={survivors}, resolved_rows={resolved})"
403			);
404		}
405		let mut resolved_rows = resolved_rows.into_iter();
406		Ok(slot_survives
407			.into_iter()
408			.map(|survives| {
409				if survives {
410					resolved_rows.next()
411				} else {
412					None
413				}
414			})
415			.collect())
416	}
417
418	fn persist_meta(
419		&mut self,
420		store: &mut dyn StateStore,
421		meta_loaded: MetaLoaded<G, C, Carry, Output>,
422	) -> Result<()> {
423		for (group, meta) in meta_loaded {
424			self.meta.put(store, &meta_key_for(&group), meta)?;
425		}
426		Ok(())
427	}
428}
429
430#[cfg(test)]
431mod tests {
432	use std::{collections::HashMap, ops::Bound};
433
434	use reifydb_codec::{
435		key::encoded::EncodedKeyRange,
436		row::operator::{EncodedOperatorRow, decode},
437	};
438	use reifydb_core::{
439		key::operator_state::{GroupStateKey, Keyspace, OperatorStateKey},
440		state::store::{TimerKind, TimerStore},
441	};
442	use reifydb_value::{
443		factory::time::{at_millis, millis},
444		value::{datetime::DateTime, duration::Duration, row_number::RowNumber},
445	};
446
447	use super::*;
448	use crate::{
449		operator::state::seal::coord::Coord,
450		window::{
451			accumulator::invertible::retained_map::RetainedAccumulator, engine::config::WindowEngineConfig,
452		},
453	};
454
455	// Allocates a distinct row number per key; the state.rs mock collapses every key onto row 1,
456	// which would alias all window accumulators and defeat a storage-bound test.
457	#[derive(Default)]
458	struct CountingStore {
459		data: HashMap<Vec<u8>, EncodedOperatorRow>,
460		groups: HashMap<Vec<u8>, GroupId>,
461		rows: HashMap<(GroupId, Vec<u8>), RowNumber>,
462		next_row: u64,
463	}
464
465	impl CountingStore {
466		fn keyspace_count(&self, keyspace: Keyspace) -> usize {
467			self.data
468				.keys()
469				.filter(|k| {
470					OperatorStateKey::decode_inner(k).is_some_and(|(_, found, _)| found == keyspace)
471				})
472				.count()
473		}
474
475		fn accumulator_count(&self) -> usize {
476			// Meta and the expiry index share the store, so count only the accumulator keyspace.
477			self.keyspace_count(Keyspace::ACCUMULATOR)
478		}
479
480		fn meta_entry_count(&self) -> usize {
481			self.keyspace_count(Keyspace::WINDOW_META)
482		}
483
484		fn row_mapping_count(&self) -> usize {
485			// One mapping is minted per (group, window), so this is what proves a sealed window
486			// reclaims its own.
487			self.rows.len()
488		}
489
490		fn drop_group_data_entries(&mut self) -> usize {
491			// Phase-1 reclamation clears every data keyspace inside a real group but leaves the root group
492			// alone; row-number mappings live outside `data` and survive it the same way production does.
493			let keys: Vec<Vec<u8>> = self
494				.data
495				.keys()
496				.filter(|k| {
497					OperatorStateKey::decode_inner(k)
498						.is_some_and(|(group, found, _)| !group.is_root() && found.is_data())
499				})
500				.cloned()
501				.collect();
502			for key in &keys {
503				self.data.remove(key);
504			}
505			keys.len()
506		}
507	}
508
509	impl TimerStore for CountingStore {
510		fn arm_timer(&mut self, _due: DateTime, _kind: TimerKind, _key: &EncodedKey) -> Result<()> {
511			unreachable!("the window engine never arms timers; only the shell above it does")
512		}
513
514		fn disarm_timer(&mut self, _due: DateTime, _kind: TimerKind, _key: &EncodedKey) -> Result<()> {
515			unreachable!("the window engine never disarms timers; only the shell above it does")
516		}
517
518		fn flow_watermark(&mut self) -> Result<Option<DateTime>> {
519			Ok(None)
520		}
521	}
522
523	impl CountingStore {
524		fn row_number_for(&mut self, group: GroupId, key: &EncodedKey) -> (RowNumber, bool) {
525			let slot = (group, key.as_bytes().to_vec());
526			if let Some(rn) = self.rows.get(&slot) {
527				return (*rn, false);
528			}
529			self.next_row += 1;
530			let rn = RowNumber(self.next_row);
531			self.rows.insert(slot, rn);
532			(rn, true)
533		}
534	}
535
536	impl StateStore for CountingStore {
537		fn intern_groups(&mut self, groups: &[EncodedKey]) -> Result<Vec<(GroupId, bool)>> {
538			let mut interned = Vec::with_capacity(groups.len());
539			for group in groups {
540				let bytes = group.as_bytes().to_vec();
541				match self.groups.get(&bytes) {
542					Some(id) => interned.push((*id, false)),
543					None => {
544						let next = GroupId(self.groups.len() as u64 + GroupId::FIRST.0);
545						self.groups.insert(bytes, next);
546						interned.push((next, true));
547					}
548				}
549			}
550			Ok(interned)
551		}
552
553		fn lookup_groups(&mut self, groups: &[EncodedKey]) -> Result<Vec<Option<GroupId>>> {
554			Ok(groups.iter().map(|group| self.groups.get(group.as_bytes()).copied()).collect())
555		}
556
557		fn state_get(&mut self, key: &GroupStateKey) -> Result<Option<EncodedOperatorRow>> {
558			Ok(self.data.get(key.as_slice()).cloned())
559		}
560		fn state_get_many_visit(
561			&mut self,
562			keys: &[GroupStateKey],
563			visit: &mut dyn FnMut(GroupStateKey, EncodedOperatorRow) -> Result<()>,
564		) -> Result<()> {
565			for key in keys {
566				if let Some(b) = self.data.get(key.as_slice()) {
567					visit(key.clone(), b.clone())?;
568				}
569			}
570			Ok(())
571		}
572		fn state_set(&mut self, key: &GroupStateKey, payload: EncodedOperatorRow) -> Result<()> {
573			self.data.insert(key.as_slice().to_vec(), payload);
574			Ok(())
575		}
576		fn state_remove(&mut self, key: &GroupStateKey) -> Result<()> {
577			self.data.remove(key.as_slice());
578			Ok(())
579		}
580		fn state_range_visit(
581			&mut self,
582			range: EncodedKeyRange,
583			limit: Option<usize>,
584			visit: &mut dyn FnMut(GroupStateKey, EncodedOperatorRow) -> Result<()>,
585		) -> Result<()> {
586			let after_start = |k: &[u8]| match &range.start {
587				Bound::Included(s) => k >= s.as_bytes(),
588				Bound::Excluded(s) => k > s.as_bytes(),
589				Bound::Unbounded => true,
590			};
591			let before_end = |k: &[u8]| match &range.end {
592				Bound::Included(e) => k <= e.as_bytes(),
593				Bound::Excluded(e) => k < e.as_bytes(),
594				Bound::Unbounded => true,
595			};
596			let mut matched: Vec<(Vec<u8>, EncodedOperatorRow)> = self
597				.data
598				.iter()
599				.filter(|(k, _)| after_start(k) && before_end(k))
600				.map(|(k, v)| (k.clone(), v.clone()))
601				.collect();
602			matched.sort_by(|a, b| a.0.cmp(&b.0));
603			if let Some(limit) = limit {
604				matched.truncate(limit);
605			}
606			for (k, b) in matched {
607				let k = GroupStateKey::from_framed(EncodedKey::new(k))
608					.expect("fake store holds an unframed state key");
609				visit(k, b)?;
610			}
611			Ok(())
612		}
613		fn get_or_create_row_numbers(
614			&mut self,
615			group: GroupId,
616			keys: &[EncodedKey],
617		) -> Result<Vec<(RowNumber, bool)>> {
618			Ok(keys.iter().map(|key| self.row_number_for(group, key)).collect())
619		}
620		fn get_or_create_row_numbers_for_pairs(
621			&mut self,
622			pairs: &[(GroupId, EncodedKey)],
623		) -> Result<Vec<(RowNumber, bool)>> {
624			Ok(pairs.iter().map(|(group, key)| self.row_number_for(*group, key)).collect())
625		}
626		fn remove_row_number(&mut self, group: GroupId, key: &EncodedKey) -> Result<()> {
627			self.rows.remove(&(group, key.as_bytes().to_vec()));
628			Ok(())
629		}
630		fn written_at(&self) -> DateTime {
631			DateTime::EPOCH
632		}
633	}
634
635	type Engine = TumblingCarryEngine<String, DateTime, RetainedAccumulator<u64, f64>, f64, f64>;
636
637	const WINDOW: u64 = 60;
638
639	fn order(millis: u64) -> u64 {
640		at_millis(millis).to_order()
641	}
642
643	fn carry_config(retention: Option<Duration>) -> TumblingCarryConfig<DateTime> {
644		TumblingCarryConfig::builder(WindowEngineConfig::builder().build()).retention(retention).build()
645	}
646
647	fn feed(engine: &mut Engine, store: &mut CountingStore, ws: DateTime, price: f64) {
648		// One event per batch, so the high-water mark advances one window per call.
649		let _ = feed_group(engine, store, "BTC", ws, price);
650	}
651
652	// Distinct groups get distinct accumulator rows, which is what the eviction test needs to
653	// overflow the 8-slot accumulator cache.
654	fn feed_group(
655		engine: &mut Engine,
656		store: &mut CountingStore,
657		group: &str,
658		ws: DateTime,
659		price: f64,
660	) -> Vec<WindowResult<String, DateTime, f64>> {
661		let mut buckets: TumblingBuckets<String, DateTime, (u64, f64)> = BTreeMap::new();
662		let span = WindowSpan::for_coord(ws, millis(WINDOW));
663		buckets.insert((group.to_string(), span), vec![AccumulatorEvent::Add((ws.to_order(), price))]);
664		engine.apply(
665			store,
666			buckets,
667			|g: &String, w: DateTime| EncodedKey::builder().str(g).u64(w.to_order()).build(),
668			RetainedAccumulator::<u64, f64>::default,
669			|_g: &String, _s: WindowSpan<DateTime>, v: &BTreeMap<u64, f64>, _p: Option<&f64>| {
670				(!v.is_empty()).then(|| v.values().sum::<f64>())
671			},
672			|v: &BTreeMap<u64, f64>, _p: Option<&f64>| v.last_key_value().map(|(_, val)| *val),
673		)
674		.expect("apply")
675	}
676
677	#[test]
678	fn retention_seals_old_windows_and_reclaims_accumulator_rows() {
679		// With a 2-window retention horizon, older windows must seal to the O(1) carry scalar and
680		// drop their accumulator row, so the live row count stays bounded by the horizon rather
681		// than growing with the number of windows seen.
682		let mut store = CountingStore::default();
683		let mut engine = Engine::new(carry_config(Some(millis(2 * WINDOW))));
684		for i in 0..60u64 {
685			feed(&mut engine, &mut store, at_millis(i * WINDOW), i as f64);
686		}
687		assert!(
688			store.accumulator_count() <= 4,
689			"sealed windows must reclaim their accumulator rows; found {} live rows after 60 windows",
690			store.accumulator_count()
691		);
692	}
693
694	#[test]
695	fn retention_seals_old_windows_and_reclaims_row_number_mappings() {
696		// The per-(group, window) mapping is keyed by row_key, not row_number, so accumulator
697		// eviction does not reclaim it. Sealing past retention must drop it alongside the
698		// accumulator or the mapping keyspace grows per window forever.
699		let mut store = CountingStore::default();
700		let mut engine = Engine::new(carry_config(Some(millis(2 * WINDOW))));
701		for i in 0..60u64 {
702			feed(&mut engine, &mut store, at_millis(i * WINDOW), i as f64);
703		}
704		assert!(
705			store.row_mapping_count() <= 4,
706			"sealed windows must reclaim their row-number mappings; found {} live mappings after 60 windows",
707			store.row_mapping_count()
708		);
709	}
710
711	#[test]
712	fn a_window_whose_state_was_reclaimed_updates_its_row_rather_than_inserting_a_second() {
713		// The data phase takes the accumulator, the carry meta and last_output at once while the
714		// mapping stays addressable, so what comes back has no memory of what it published but the
715		// sink still holds that row. A second insert would lay a duplicate row over a live one.
716		let mut store = CountingStore::default();
717		let mut engine = Engine::new(carry_config(None));
718		let published = feed_group(&mut engine, &mut store, "BTC", at_millis(0), 5.0);
719		assert_eq!(published.len(), 1);
720		assert!(matches!(published[0].kind, EmitKind::Insert), "precondition: the window publishes once");
721
722		assert!(store.drop_group_data_entries() > 0, "precondition: the sweep must have erased something");
723		assert_eq!(store.row_mapping_count(), 1, "precondition: the identity half must survive the data phase");
724
725		let mut engine = Engine::new(carry_config(None));
726		let republished = feed_group(&mut engine, &mut store, "BTC", at_millis(0), 3.0);
727
728		assert_eq!(republished.len(), 1);
729		assert_eq!(
730			republished[0].kind,
731			EmitKind::Update,
732			"the published row survived the sweep, so this is an update and not a second insert"
733		);
734		assert_eq!(
735			republished[0].row_number, published[0].row_number,
736			"the woken window keeps the row it published"
737		);
738	}
739
740	#[test]
741	fn every_successive_window_emits_its_own_result() {
742		// Production symptom: a `apply twap { window_duration: '1m' }` ladder over an advancing
743		// event-time stream published output for the first window only and then froze, while its
744		// source view kept advancing. Every existing test here feeds many windows but only asserts
745		// on reclamation counts, so a driver that stops emitting after the first window passes them
746		// all. Each successive window carries its own events, so each must publish its own row.
747		let mut store = CountingStore::default();
748		let mut engine = Engine::new(carry_config(None));
749		let mut emitted_windows = Vec::new();
750		for i in 0..5u64 {
751			let out = feed_group(&mut engine, &mut store, "BTC", at_millis(i * WINDOW), i as f64 + 1.0);
752			println!(
753				"[win-probe] fed window_start={} results={} kinds={:?}",
754				i * WINDOW,
755				out.len(),
756				out.iter().map(|r| (r.span.start, r.kind)).collect::<Vec<_>>()
757			);
758			if !out.is_empty() {
759				emitted_windows.push(i * WINDOW);
760			}
761		}
762		assert_eq!(
763			emitted_windows,
764			vec![0, WINDOW, 2 * WINDOW, 3 * WINDOW, 4 * WINDOW],
765			"each window that received an event must publish; a ladder that stops after the first \
766			 window is the production freeze"
767		);
768	}
769
770	#[test]
771	fn meta_survives_while_group_high_water_at_or_after_threshold() {
772		// An active group whose high water is at or beyond the threshold must keep its meta: the
773		// carry it holds still seeds the next window.
774		let mut store = CountingStore::default();
775		let mut engine = Engine::new(carry_config(Some(millis(2 * WINDOW))));
776		for i in 0..3u64 {
777			feed(&mut engine, &mut store, at_millis(i * WINDOW), i as f64);
778		}
779		let dropped = engine.expire_meta(&mut store, WINDOW).unwrap();
780		assert_eq!(dropped, 0, "high water (2*WINDOW) is not below the threshold (WINDOW)");
781		assert_eq!(store.meta_entry_count(), 1, "an active group within the horizon keeps its meta");
782		assert!(store.accumulator_count() > 0, "live windows within retention keep their accumulators");
783	}
784
785	#[test]
786	fn meta_reclaimed_when_group_stale_past_threshold() {
787		// A carry group whose high water falls below the threshold is dead, and the sweep reclaims
788		// its meta and sealed carry; otherwise `persist_meta` leaks one key per group forever.
789		let mut store = CountingStore::default();
790		let mut engine = Engine::new(carry_config(Some(millis(2 * WINDOW))));
791		for i in 0..3u64 {
792			feed(&mut engine, &mut store, at_millis(i * WINDOW), i as f64);
793		}
794		assert_eq!(store.meta_entry_count(), 1);
795
796		let dropped = engine.expire_meta(&mut store, order(100 * WINDOW)).unwrap();
797		assert_eq!(dropped, 1, "the quiet group's high water is far below the threshold");
798		assert_eq!(store.meta_entry_count(), 0, "a dead carry group must not leak its meta");
799	}
800
801	#[test]
802	fn without_retention_every_window_accumulator_is_retained() {
803		// The contrast that proves the bound above comes from sealing and not some other cap: with
804		// no retention configured the engine keeps every window's accumulator forever.
805		let mut store = CountingStore::default();
806		let mut engine = Engine::new(carry_config(None));
807		for i in 0..60u64 {
808			feed(&mut engine, &mut store, at_millis(i * WINDOW), i as f64);
809		}
810		assert_eq!(
811			store.accumulator_count(),
812			60,
813			"with no retention the carry engine retains every window's accumulator row"
814		);
815	}
816
817	#[test]
818	fn terminal_remove_after_restart_uses_persisted_last_output() {
819		// A carry window's withdrawn value cannot be recomputed from its own surviving state: an
820		// emptied accumulator finalizes to nothing, and the output also depended on the value
821		// carried in from earlier windows. `last_output` must therefore be durable, not ephemeral.
822		let mut store = CountingStore::default();
823
824		let mut engine = Engine::new(carry_config(None));
825		feed(&mut engine, &mut store, at_millis(0), 5.0);
826
827		let mut engine = Engine::new(carry_config(None));
828		let span = WindowSpan::for_coord(at_millis(0), millis(WINDOW));
829		let mut buckets: TumblingBuckets<String, DateTime, (u64, f64)> = BTreeMap::new();
830		buckets.insert(("BTC".to_string(), span), vec![AccumulatorEvent::Remove((0, 5.0))]);
831		let withdrawn: Vec<WindowResult<String, DateTime, f64>> = engine
832			.apply(
833				&mut store,
834				buckets,
835				|g: &String, w: DateTime| EncodedKey::builder().str(g).u64(w.to_order()).build(),
836				RetainedAccumulator::<u64, f64>::default,
837				|_g: &String, _s: WindowSpan<DateTime>, v: &BTreeMap<u64, f64>, _p: Option<&f64>| {
838					(!v.is_empty()).then(|| v.values().sum::<f64>())
839				},
840				|v: &BTreeMap<u64, f64>, _p: Option<&f64>| v.last_key_value().map(|(_, val)| *val),
841			)
842			.expect("apply");
843
844		assert_eq!(withdrawn.len(), 1, "emptying the window emits exactly one terminal diff");
845		assert!(
846			matches!(withdrawn[0].kind, EmitKind::Remove),
847			"the window emptied under retraction, so the last published row must be withdrawn"
848		);
849		assert_eq!(
850			withdrawn[0].value, 5.0,
851			"the withdrawn value is the persisted last_output, recovered across the restart"
852		);
853	}
854
855	#[test]
856	fn last_output_survives_lru_eviction() {
857		// The other way the persisted state is read back is LRU eviction, with no restart: the
858		// accumulator cache holds 8 windows, so tracking more evicts the oldest and the next access
859		// re-reads it from the store.
860		let mut store = CountingStore::default();
861		let mut engine = Engine::new(carry_config(None));
862
863		let mut published_g00: Vec<WindowResult<String, DateTime, f64>> = Vec::new();
864		for i in 0..11u64 {
865			let group = format!("G{i:02}");
866			let out = feed_group(&mut engine, &mut store, &group, at_millis(0), (i + 1) as f64);
867			if i == 0 {
868				published_g00 = out;
869			}
870		}
871		assert_eq!(published_g00.len(), 1);
872		assert!(matches!(published_g00[0].kind, EmitKind::Insert));
873		assert_eq!(published_g00[0].value, 1.0);
874
875		// G00's window was pushed out of the 8-slot accumulator cache by the later groups, so the
876		// engine must re-read its accumulator from the store to apply this retraction.
877		let span = WindowSpan::for_coord(at_millis(0), millis(WINDOW));
878		let mut buckets: TumblingBuckets<String, DateTime, (u64, f64)> = BTreeMap::new();
879		buckets.insert(("G00".to_string(), span), vec![AccumulatorEvent::Remove((0, 1.0))]);
880		let withdrawn: Vec<WindowResult<String, DateTime, f64>> = engine
881			.apply(
882				&mut store,
883				buckets,
884				|g: &String, w: DateTime| EncodedKey::builder().str(g).u64(w.to_order()).build(),
885				RetainedAccumulator::<u64, f64>::default,
886				|_g: &String, _s: WindowSpan<DateTime>, v: &BTreeMap<u64, f64>, _p: Option<&f64>| {
887					(!v.is_empty()).then(|| v.values().sum::<f64>())
888				},
889				|v: &BTreeMap<u64, f64>, _p: Option<&f64>| v.last_key_value().map(|(_, val)| *val),
890			)
891			.expect("apply");
892
893		assert_eq!(withdrawn.len(), 1, "emptying the evicted window emits exactly one terminal diff");
894		assert!(
895			matches!(withdrawn[0].kind, EmitKind::Remove),
896			"the evicted window emptied under retraction, so the last published row must be withdrawn"
897		);
898		assert_eq!(
899			withdrawn[0].value, 1.0,
900			"the withdrawn value is the persisted last_output for G00, recovered after eviction"
901		);
902		assert_eq!(
903			withdrawn[0].row_number, published_g00[0].row_number,
904			"the withdrawal targets the same row that was published for G00"
905		);
906	}
907
908	#[test]
909	fn carry_meta_projects_its_high_water_independently_of_its_window_map() {
910		// The meta sweep reclaims on this projection alone, so window entries must never skew it.
911		let mut meta: CarryMeta<DateTime, i64, i64> = CarryMeta::default();
912		let empty_bytes = meta.encode_state(DateTime::EPOCH).unwrap();
913		assert_eq!(
914			decode::<CarryMeta<DateTime, i64, i64>>(&empty_bytes).unwrap().high_water_order(),
915			None,
916			"a default CarryMeta has no high water"
917		);
918
919		meta.high_water = Some(at_millis(99));
920		meta.windows.insert(
921			at_millis(10),
922			WindowEntry {
923				span: WindowSpan::new(at_millis(10), at_millis(20)),
924				carry_out: Some(7i64),
925				last_output: Some(3i64),
926			},
927		);
928		let bytes = meta.encode_state(DateTime::EPOCH).unwrap();
929		let projected = decode::<CarryMeta<DateTime, i64, i64>>(&bytes).unwrap().high_water_order();
930		assert_eq!(projected, Some(order(99)), "the populated window map must not disturb the high water");
931	}
932}