1use 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 #[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 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 self.rows.len()
488 }
489
490 fn drop_group_data_entries(&mut self) -> usize {
491 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 let _ = feed_group(engine, store, "BTC", ws, price);
650 }
651
652 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 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 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 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 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 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 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 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 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 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 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 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}