1use 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 #[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 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 self.rows.len()
466 }
467
468 fn drop_group_data_entries(&mut self) -> usize {
469 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 let _ = feed_group(engine, store, "BTC", ws, price);
616 }
617
618 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 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 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 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 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 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 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 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 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 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 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 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}