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