1use std::{
5 collections::{BTreeMap, HashMap},
6 fmt::Debug,
7 hash::Hash,
8 marker::PhantomData,
9};
10
11use reifydb_codec::{
12 key::{
13 encode_u64,
14 encoded::{EncodedKey, IntoEncodedKey},
15 },
16 row::operator::OperatorState,
17};
18use reifydb_core::{
19 key::operator_state::{GroupId, GroupStateKey},
20 state::{cache::StateCache, store::StateStore},
21};
22use reifydb_macro::operator_state;
23use reifydb_value::{Result, reifydb_assertions};
24
25use crate::{
26 operator::state::{
27 expiry::{expiry_drop, expiry_due, expiry_key, expiry_set},
28 reaper::Reaper,
29 },
30 window::{
31 accumulator::WindowAccumulator,
32 engine::{
33 AccumulatorEvent, BatchMeta, EmitKind, GroupMeta, MetaKey, WindowResult, WindowStateKey,
34 config::WindowEngineConfig, decode_window_state_key, load_batch_meta, meta_key_for,
35 note_when_expiry_capped, persist_batch_meta, sweep_stale_meta,
36 },
37 span::{WindowAnchor, WindowSpan},
38 },
39};
40
41pub type TumblingBuckets<G, C, Contribution> = BTreeMap<(G, WindowSpan<C>), Vec<AccumulatorEvent<Contribution>>>;
42
43type MetaLoaded<G, C> = HashMap<G, BatchMeta<C>>;
44
45#[derive(Clone)]
46struct ResolvedSlot {
47 group: GroupId,
48 key: EncodedKey,
49}
50
51type SlotResolved<G, C> = HashMap<(G, WindowSpan<C>), ResolvedSlot>;
52
53struct PendingEmit<G, C, Output> {
54 group_id: GroupId,
55 key: EncodedKey,
56 group: G,
57 span: WindowSpan<C>,
58 value: Output,
59 prior: Option<Output>,
60 withdraw: bool,
61}
62
63pub struct ExpiredWindow<G, C> {
64 pub group: G,
65 pub group_id: GroupId,
66 pub window_start: C,
67}
68
69#[operator_state]
70#[derive(Clone)]
71pub struct TumblingIndexEntry<G, C> {
72 group: G,
73 window_start: C,
74 group_id: u64,
75 slot_key: Vec<u8>,
76}
77
78impl<G, C, Accumulator> Reaper for TumblingEngine<G, C, Accumulator>
79where
80 C: WindowAnchor + Hash,
81 Accumulator: WindowAccumulator,
82{
83 fn reap(&mut self, store: &mut dyn StateStore, key: &GroupStateKey) -> Result<()> {
84 match decode_window_state_key(key.as_encoded()) {
85 Some(slot) => self.accumulators.remove(store, &slot),
86 None => store.state_remove(key),
87 }
88 }
89}
90
91pub struct TumblingEngine<G, C, Accumulator> {
92 accumulators: StateCache<WindowStateKey, Accumulator>,
93 meta: StateCache<MetaKey, GroupMeta<C>>,
94 meta_low_water: Option<u64>,
95 expire_batch: usize,
96 dropped_retractions: u64,
97 _pd: PhantomData<G>,
98}
99
100impl<G, C, Accumulator> TumblingEngine<G, C, Accumulator>
101where
102 G: Clone + Eq + Ord + Hash + Debug,
103 C: WindowAnchor + Hash,
104 Accumulator: WindowAccumulator,
105 for<'a> &'a G: IntoEncodedKey,
106 GroupMeta<C>: OperatorState,
107 TumblingIndexEntry<G, C>: OperatorState,
108{
109 pub fn new(config: WindowEngineConfig) -> Self {
110 Self {
111 accumulators: StateCache::<WindowStateKey, Accumulator>::new(),
112 meta: StateCache::<MetaKey, GroupMeta<C>>::new(),
113 meta_low_water: None,
114 expire_batch: config.expire_batch(),
115 dropped_retractions: 0,
116 _pd: PhantomData,
117 }
118 }
119
120 pub fn dropped_retractions(&self) -> u64 {
121 self.dropped_retractions
122 }
123
124 #[allow(clippy::too_many_arguments)]
125 pub fn reindex_window(
126 &mut self,
127 store: &mut dyn StateStore,
128 group: &G,
129 window_start: C,
130 id: GroupId,
131 slot: &EncodedKey,
132 prior: Option<u64>,
133 new: Option<u64>,
134 ) -> Result<()> {
135 if prior == new {
136 return Ok(());
137 }
138 let suffix = encode_u64(window_start.order_key().to_order());
139 if let Some(old) = prior {
140 expiry_drop(store, &expiry_key(old, group, &suffix))?;
141 }
142 if let Some(new) = new {
143 let entry = TumblingIndexEntry {
144 group: group.clone(),
145 window_start,
146 group_id: id.0,
147 slot_key: slot.as_bytes().to_vec(),
148 };
149 expiry_set(store, expiry_key(new, group, &suffix), entry)?;
150 }
151 Ok(())
152 }
153
154 pub fn apply<K, NA>(
155 &mut self,
156 store: &mut dyn StateStore,
157 buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
158 order: &[(G, WindowSpan<C>)],
159 slot_key: K,
160 new_accumulator: NA,
161 ) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
162 where
163 K: Fn(&G, C) -> (GroupId, EncodedKey),
164 NA: Fn() -> Accumulator,
165 {
166 self.dropped_retractions = 0;
167 if buckets.is_empty() {
168 return Ok(Vec::new());
169 }
170 let mut meta_loaded = self.load_meta(store, &buckets)?;
171 let slot_resolved = Self::resolve_slots(order, &slot_key);
172 reifydb_assertions! {
173 let ordered = slot_resolved.len();
174 let bucketed = buckets.len();
175 assert!(
176 ordered == bucketed,
177 "the resolution order must name every bucket exactly once; a bucket missing from it \
178 gets no row number and would be dropped from this batch, while a duplicate silently \
179 renumbers a window that already published under another row \
180 (order={ordered}, buckets={bucketed})"
181 );
182 }
183 let results =
184 self.apply_events(store, buckets, order, &slot_resolved, &mut meta_loaded, &new_accumulator)?;
185 self.persist_meta(store, meta_loaded)?;
186 Ok(results)
187 }
188
189 fn load_meta(
190 &mut self,
191 store: &mut dyn StateStore,
192 buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
193 ) -> Result<MetaLoaded<G, C>> {
194 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
195 for (group, _) in buckets.keys() {
196 if !meta_loaded.contains_key(group) {
197 let batch = load_batch_meta(store, &mut self.meta, &meta_key_for(group))?;
198 meta_loaded.insert(group.clone(), batch);
199 }
200 }
201 Ok(meta_loaded)
202 }
203
204 fn resolve_slots<K>(order: &[(G, WindowSpan<C>)], slot_key: &K) -> SlotResolved<G, C>
205 where
206 K: Fn(&G, C) -> (GroupId, EncodedKey),
207 {
208 let mut resolved: SlotResolved<G, C> = HashMap::with_capacity(order.len());
209 for (group, span) in order {
210 let (id, key) = slot_key(group, span.start);
211 resolved.insert(
212 (group.clone(), *span),
213 ResolvedSlot {
214 group: id,
215 key,
216 },
217 );
218 }
219 resolved
220 }
221
222 fn apply_events<NA>(
223 &mut self,
224 store: &mut dyn StateStore,
225 mut buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
226 order: &[(G, WindowSpan<C>)],
227 slot_resolved: &SlotResolved<G, C>,
228 meta_loaded: &mut MetaLoaded<G, C>,
229 new_accumulator: &NA,
230 ) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
231 where
232 NA: Fn() -> Accumulator,
233 {
234 let mut pending: Vec<PendingEmit<G, C, Accumulator::Output>> = Vec::new();
235
236 for ordered in order {
237 let Some(events) = buckets.remove(ordered) else {
238 continue;
239 };
240 let (group, span) = ordered.clone();
241 meta_loaded.entry(group.clone()).or_default().observe(span.start);
242
243 let Some(ResolvedSlot {
244 group: id,
245 key,
246 }) = slot_resolved.get(&(group.clone(), span)).cloned()
247 else {
248 continue;
249 };
250 let state_key = WindowStateKey::new(id, key.clone());
251
252 let mut accumulator: Accumulator =
253 self.accumulators.get(store, &state_key)?.unwrap_or_else(new_accumulator);
254 let was_empty_before = accumulator.is_empty();
255 let prior = if was_empty_before {
256 None
257 } else {
258 accumulator.finalize()
259 };
260
261 for event in events {
262 match event {
263 AccumulatorEvent::Add(c) => {
264 accumulator.add(&c);
265 }
266 AccumulatorEvent::Remove(c) => {
267 if accumulator.is_empty() {
268 self.dropped_retractions += 1;
269 continue;
270 }
271 accumulator.remove(&c);
272 }
273 }
274 }
275
276 let value = accumulator.finalize();
277 self.accumulators.put(store, &state_key, accumulator)?;
278
279 match value {
280 Some(value) => pending.push(PendingEmit {
281 group_id: id,
282 key,
283 group,
284 span,
285 value,
286 prior,
287 withdraw: false,
288 }),
289 None => {
290 if let Some(p) = prior.clone() {
291 pending.push(PendingEmit {
292 group_id: id,
293 key,
294 group,
295 span,
296 value: p,
297 prior,
298 withdraw: true,
299 });
300 }
301 }
302 }
303 }
304 reifydb_assertions! {
305 assert!(
306 buckets.is_empty(),
307 "the resolution order must drain every bucket; a leftover bucket's events were \
308 silently dropped and its window never gets a row number (leftovers={})",
309 buckets.len()
310 );
311 }
312
313 let pairs: Vec<(GroupId, EncodedKey)> = pending.iter().map(|p| (p.group_id, p.key.clone())).collect();
314 let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
315 reifydb_assertions! {
316 let requested = pairs.len();
317 let returned = rows.len();
318 assert!(
319 returned == requested,
320 "the identity batch must return one row per publishing window; a short batch makes the \
321 zip below drop the tail, so those windows publish nothing while their accumulators \
322 already advanced (requested={requested}, returned={returned})"
323 );
324 }
325
326 let mut results: Vec<WindowResult<G, C, Accumulator::Output>> = Vec::with_capacity(pending.len());
327 for (emit, (row_number, is_new)) in pending.into_iter().zip(rows) {
328 let kind = if emit.withdraw {
329 reifydb_assertions! {
330 let group_id = emit.group_id;
331 assert!(
332 !is_new,
333 "a window holding a prior output must already own its mapping; minting \
334 one here means the identity was released while the row it addresses \
335 was still live, and this withdrawal names a row no sink can find \
336 (group={group_id:?}, row={row_number:?})"
337 );
338 }
339 store.remove_row_number(emit.group_id, &emit.key)?;
340 EmitKind::Remove
341 } else if is_new {
342 EmitKind::Insert
343 } else {
344 EmitKind::Update
345 };
346 results.push(WindowResult {
347 row_number,
348 group: emit.group,
349 span: emit.span,
350 value: emit.value,
351 prior: emit.prior,
352 kind,
353 });
354 }
355 Ok(results)
356 }
357
358 pub fn expire(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<Vec<ExpiredWindow<G, C>>> {
359 let due: Vec<(GroupStateKey, TumblingIndexEntry<G, C>)> =
360 expiry_due(store, threshold, self.expire_batch)?;
361
362 let mut out: Vec<ExpiredWindow<G, C>> = Vec::new();
363 for (index_key, entry) in due {
364 expiry_drop(store, &index_key)?;
365 out.push(ExpiredWindow {
366 group: entry.group,
367 group_id: GroupId(entry.group_id),
368 window_start: entry.window_start,
369 });
370 }
371 note_when_expiry_capped(out.len(), self.expire_batch);
372 Ok(out)
373 }
374
375 fn persist_meta(&mut self, store: &mut dyn StateStore, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
376 persist_batch_meta(store, &mut self.meta, meta_loaded)
377 }
378
379 pub fn expire_meta(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<usize> {
380 sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use std::{
387 collections::BTreeMap,
388 sync::atomic::{AtomicUsize, Ordering},
389 };
390
391 use reifydb_codec::key::encoded::EncodedKey;
392 use reifydb_core::{key::operator_state::GroupId, metrics::heap::HeapSize};
393 use reifydb_macro::operator_state;
394 use reifydb_value::{Result, factory::time::at_millis, value::datetime::DateTime};
395
396 use crate::{
397 operator::state::{mock::MockStore, seal::coord::Coord},
398 window::{
399 accumulator::{WindowAccumulator, mock::SumAccumulator},
400 engine::{
401 AccumulatorEvent, EmitKind, GroupMeta, MetaHighWater, WindowResult,
402 config::WindowEngineConfig,
403 meta_key_for,
404 tumbling::{TumblingBuckets, TumblingEngine},
405 },
406 span::WindowSpan,
407 },
408 };
409
410 fn test_config() -> WindowEngineConfig {
411 WindowEngineConfig::builder().build()
412 }
413
414 fn row_key(group: &u32, window_start: DateTime) -> EncodedKey {
415 EncodedKey::builder().u32(*group).u64(window_start.to_order()).build()
416 }
417
418 fn slot_key(group: &u32, window_start: DateTime) -> (GroupId, EncodedKey) {
419 (GroupId::ROOT, row_key(group, window_start))
420 }
421
422 fn order_of<C>(buckets: &TumblingBuckets<u32, DateTime, C>) -> Vec<(u32, WindowSpan<DateTime>)> {
423 buckets.keys().cloned().collect()
424 }
425
426 fn apply_sums(
427 engine: &mut TumblingEngine<u32, DateTime, SumAccumulator>,
428 store: &mut MockStore,
429 buckets: TumblingBuckets<u32, DateTime, i64>,
430 ) -> Result<Vec<WindowResult<u32, DateTime, i64>>> {
431 let order = order_of(&buckets);
432 engine.apply(store, buckets, &order, slot_key, SumAccumulator::default)
433 }
434
435 fn apply_counting(
436 engine: &mut TumblingEngine<u32, DateTime, CountingAcc>,
437 store: &mut MockStore,
438 buckets: TumblingBuckets<u32, DateTime, i64>,
439 ) -> Result<Vec<WindowResult<u32, DateTime, i64>>> {
440 let order = order_of(&buckets);
441 engine.apply(store, buckets, &order, slot_key, CountingAcc::default)
442 }
443
444 fn reindex_window(
447 store: &mut MockStore,
448 group: &u32,
449 window_start: DateTime,
450 prior: Option<u64>,
451 new: Option<u64>,
452 ) -> Result<()> {
453 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
454 engine.reindex_window(
455 store,
456 group,
457 window_start,
458 GroupId::ROOT,
459 &row_key(group, window_start),
460 prior,
461 new,
462 )
463 }
464
465 fn order(millis: u64) -> u64 {
466 at_millis(millis).to_order()
467 }
468
469 fn seed_window(
470 store: &mut MockStore,
471 window_start: u64,
472 contribution: i64,
473 ) -> WindowResult<u32, DateTime, i64> {
474 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
475 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
476 buckets.insert(
477 (1u32, WindowSpan::new(at_millis(window_start), at_millis(window_start + 1))),
478 vec![AccumulatorEvent::Add(contribution)],
479 );
480 let mut results = apply_sums(&mut engine, store, buckets).expect("apply");
481 results.pop().expect("one window")
482 }
483
484 fn apply_event(store: &mut MockStore, window_start: u64, event: AccumulatorEvent<i64>) {
485 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
486 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
487 buckets.insert(
488 (1u32, WindowSpan::new(at_millis(window_start), at_millis(window_start + 1))),
489 vec![event],
490 );
491 apply_sums(&mut engine, store, buckets).expect("apply");
492 }
493
494 fn group_slot(group: &u32, window_start: DateTime) -> (GroupId, EncodedKey) {
495 (GroupId(u64::from(*group) * 1_000_000 + window_start.to_order()), EncodedKey::new(Vec::new()))
498 }
499
500 fn apply_group_scoped(
501 engine: &mut TumblingEngine<u32, DateTime, SumAccumulator>,
502 store: &mut MockStore,
503 buckets: TumblingBuckets<u32, DateTime, i64>,
504 ) -> Vec<WindowResult<u32, DateTime, i64>> {
505 let order = order_of(&buckets);
506 let out = engine.apply(store, buckets, &order, group_slot, SumAccumulator::default).expect("apply");
507 out
508 }
509
510 fn one_bucket(group: u32, window_start: u64, contribution: i64) -> TumblingBuckets<u32, DateTime, i64> {
511 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
512 buckets.insert(
513 (group, WindowSpan::new(at_millis(window_start), at_millis(window_start + 1))),
514 vec![AccumulatorEvent::Add(contribution)],
515 );
516 buckets
517 }
518
519 #[test]
520 fn group_scoped_windows_keep_separate_state_under_one_shared_row_key() {
521 let mut store = MockStore::default();
525 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
526
527 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
528 buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(5)]);
529 buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(7)]);
530 buckets.insert((2u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(9)]);
531 let results = apply_group_scoped(&mut engine, &mut store, buckets);
532
533 assert_eq!(results.len(), 3);
534 let mut rows: Vec<u64> = results.iter().map(|r| r.row_number.0).collect();
535 rows.sort_unstable();
536 rows.dedup();
537 assert_eq!(rows.len(), 3, "each window must own a row of its own despite the shared row key");
538 let mut values: Vec<i64> = results.iter().map(|r| r.value).collect();
539 values.sort_unstable();
540 assert_eq!(values, vec![5, 7, 9], "no window may see another's contributions");
541
542 let mut restarted = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
544 let out = apply_group_scoped(&mut restarted, &mut store, one_bucket(1, 0, 1));
545 assert_eq!(out[0].value, 6, "the reloaded accumulator carries only window (1, 0)");
546 }
547
548 #[test]
549 fn an_expired_window_names_the_group_its_state_lived_in() {
550 let mut store = MockStore::default();
554 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
555 let results = apply_group_scoped(&mut engine, &mut store, one_bucket(3, 40, 5));
556 let published = &results[0];
557 let (group, _) = group_slot(&published.group, published.span.start);
558 let (_, slot) = group_slot(&published.group, published.span.start);
559 engine.reindex_window(&mut store, &published.group, published.span.start, group, &slot, None, Some(10))
560 .unwrap();
561
562 let mut restarted = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
563 let expired = restarted.expire(&mut store, 10).unwrap();
564
565 assert_eq!(expired.len(), 1);
566 assert_eq!(expired[0].group_id, group, "the entry must name the group whose state it drained");
567 assert!(
568 store.contains_row_mapping(group, &EncodedKey::new(Vec::new())),
569 "the identity the driver is about to release must still be resolvable from that group alone"
570 );
571 assert!(
572 store.contains_row_mapping(group, &EncodedKey::new(Vec::new())),
573 "sealing releases no identity of its own; the reaper collects it at or below the ledger"
574 );
575 }
576
577 #[test]
578 fn expire_returns_only_due_windows_and_drops_only_their_index_entries() {
579 let mut store = MockStore::default();
580 let w0 = seed_window(&mut store, 0, 5);
582 reindex_window(&mut store, &w0.group, w0.span.start, None, Some(10)).unwrap();
583 let w100 = seed_window(&mut store, 100, 7);
584 reindex_window(&mut store, &w100.group, w100.span.start, None, Some(90)).unwrap();
585 assert_eq!(store.index_entry_count(), 2, "both live windows are indexed");
586
587 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
589 let expired = engine.expire(&mut store, 10).unwrap();
590 assert_eq!(expired.len(), 1, "exactly one window is due, not the whole population");
591 assert_eq!(expired[0].window_start, at_millis(0));
592 assert_eq!(store.index_entry_count(), 1, "the due window's index entry is gone, the other remains");
593
594 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
597 let later = engine.expire(&mut store, 1000).unwrap();
598 assert_eq!(later.len(), 1);
599 assert_eq!(later[0].window_start, at_millis(100));
600 assert_eq!(store.index_entry_count(), 0);
601 }
602
603 #[test]
604 fn an_expiry_entry_whose_state_was_reclaimed_still_drains() {
605 let mut store = MockStore::default();
609 let w = seed_window(&mut store, 0, 5);
610 reindex_window(&mut store, &w.group, w.span.start, None, Some(10)).unwrap();
611 assert_eq!(store.index_entry_count(), 1, "precondition: the window is indexed");
612 assert_eq!(store.drop_accumulator_entries(), 1, "precondition: the reaper erased the accumulator");
613
614 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
615 let expired = engine.expire(&mut store, 10).unwrap();
616
617 assert_eq!(expired.len(), 1, "the stale entry must still drain, or the index would grow forever");
618 assert_eq!(store.index_entry_count(), 0, "a stale entry drops itself on the scan that finds it");
619 }
620
621 #[test]
622 fn a_window_whose_accumulator_was_reclaimed_updates_its_row_rather_than_inserting_a_second() {
623 let mut store = MockStore::default();
627 let published = seed_window(&mut store, 0, 5);
628 assert_eq!(store.drop_accumulator_entries(), 1, "precondition: reclaim erased the accumulator");
629 assert!(
630 store.contains_row_mapping(GroupId::ROOT, &row_key(&1, at_millis(0))),
631 "precondition: the identity half must survive the data phase"
632 );
633
634 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
635 let results = apply_sums(&mut engine, &mut store, one_bucket(1, 0, 3)).expect("apply");
636
637 assert_eq!(results.len(), 1);
638 assert_eq!(results[0].row_number, published.row_number, "the woken window keeps the row it published");
639 assert_eq!(
640 results[0].kind,
641 EmitKind::Update,
642 "the published row survived the sweep, so this is an update and not a second insert"
643 );
644 }
645
646 #[test]
647 fn a_window_that_publishes_nothing_mints_no_identity() {
648 let mut store = MockStore::default();
652 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
653
654 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
655 buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Remove(5)]);
656 let results = apply_sums(&mut engine, &mut store, buckets).expect("apply");
657
658 assert!(results.is_empty(), "a window that finalizes to nothing publishes nothing");
659 assert!(
660 !store.contains_row_mapping(GroupId::ROOT, &row_key(&1, at_millis(0))),
661 "and must leave no identity behind for a row it never published"
662 );
663
664 let mut woken = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
665 let out = apply_sums(&mut woken, &mut store, one_bucket(1, 0, 4)).expect("apply");
666
667 assert_eq!(out[0].kind, EmitKind::Insert, "the first row this window publishes is an insert");
668 }
669
670 #[test]
671 fn an_emptied_window_seals_without_releasing_anything() {
672 let mut store = MockStore::default();
676 let w = seed_window(&mut store, 0, 5);
677 apply_event(&mut store, 0, AccumulatorEvent::Remove(5));
678 reindex_window(&mut store, &w.group, w.span.start, None, Some(10)).unwrap();
679
680 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
681 let expired = engine.expire(&mut store, 10).unwrap();
682
683 assert_eq!(expired.len(), 1);
684 assert_eq!(
685 store.drop_accumulator_entries(),
686 1,
687 "the emptied accumulator outlives the seal and is the reaper's to collect"
688 );
689 }
690
691 #[test]
692 fn meta_reclaimed_when_group_stale_past_threshold() {
693 let mut store = MockStore::default();
697 seed_window(&mut store, 0, 5);
698 assert_eq!(store.meta_entry_count(), 1, "applying a window persisted the group's meta");
699
700 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
701 let dropped = engine.expire_meta(&mut store, 100).unwrap();
702 assert_eq!(dropped, 1, "the group's high water (0) is below the threshold (100)");
703 assert_eq!(store.meta_entry_count(), 0, "a stale group must not leak its GroupMeta");
704 }
705
706 #[test]
707 fn meta_survives_while_group_high_water_at_or_after_threshold() {
708 let mut store = MockStore::default();
711 seed_window(&mut store, 100, 7);
712 assert_eq!(store.meta_entry_count(), 1);
713
714 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
715 let dropped = engine.expire_meta(&mut store, 50).unwrap();
716 assert_eq!(dropped, 0, "high water (100) is not below the threshold (50)");
717 assert_eq!(store.meta_entry_count(), 1, "a group within the staleness horizon keeps its meta");
718 }
719
720 #[test]
721 fn meta_sweep_leaves_row_number_mappings_intact() {
722 let mut store = MockStore::default();
725 seed_window(&mut store, 0, 5);
726 store.seed_mapping_key(0x01);
727 assert_eq!(store.mapping_entry_count(), 1);
728
729 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
730 engine.expire_meta(&mut store, 100).unwrap();
731 assert_eq!(store.meta_entry_count(), 0, "the stale group's meta is swept");
732 assert_eq!(store.mapping_entry_count(), 1, "the sweep must not touch row-number mapping keys");
733 }
734
735 #[test]
736 fn meta_sweep_skips_then_reclaims_as_threshold_advances() {
737 let mut store = MockStore::default();
741 seed_window(&mut store, 100, 7);
742
743 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
744 assert_eq!(engine.expire_meta(&mut store, order(50)).unwrap(), 0);
746 assert_eq!(store.meta_entry_count(), 1);
747 assert_eq!(engine.expire_meta(&mut store, order(100)).unwrap(), 0);
749 assert_eq!(store.meta_entry_count(), 1);
750 assert_eq!(engine.expire_meta(&mut store, order(101)).unwrap(), 1);
752 assert_eq!(store.meta_entry_count(), 0, "the guard must not permanently skip a group that goes stale");
753 }
754
755 #[test]
756 fn expire_threshold_is_inclusive() {
757 let mut store = MockStore::default();
758 let w = seed_window(&mut store, 0, 4);
759 reindex_window(&mut store, &w.group, w.span.start, None, Some(50)).unwrap();
760
761 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
763 assert!(engine.expire(&mut store, 49).unwrap().is_empty());
764 assert_eq!(store.index_entry_count(), 1);
765
766 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
768 assert_eq!(engine.expire(&mut store, 50).unwrap().len(), 1);
769 }
770
771 #[test]
772 fn expire_processes_at_most_expire_batch_then_resumes_next_tick() {
773 let mut store = MockStore::default();
777 for (start, due) in [(0u64, 10u64), (100, 20), (200, 30)] {
778 let w = seed_window(&mut store, start, 1);
779 reindex_window(&mut store, &w.group, w.span.start, None, Some(due)).unwrap();
780 }
781 assert_eq!(store.index_entry_count(), 3);
782
783 let capped = WindowEngineConfig::builder().expire_batch(2).build();
784
785 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(capped.clone());
786 let first = engine.expire(&mut store, 1000).unwrap();
787 assert_eq!(first.len(), 2, "one tick drains at most expire_batch windows");
788 assert_eq!(first[0].window_start, at_millis(200), "inverted key order: newest due drains first");
789 assert_eq!(first[1].window_start, at_millis(100));
790 assert_eq!(store.index_entry_count(), 1, "the deferred window keeps its index entry");
791
792 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(capped);
793 let second = engine.expire(&mut store, 1000).unwrap();
794 assert_eq!(second.len(), 1, "the next tick picks up the deferred backlog");
795 assert_eq!(second[0].window_start, at_millis(0));
796 assert_eq!(store.index_entry_count(), 0);
797 }
798
799 #[test]
800 fn reindex_rekeys_without_leaving_a_stale_entry() {
801 let mut store = MockStore::default();
802 let w = seed_window(&mut store, 0, 9);
803 reindex_window(&mut store, &w.group, w.span.start, None, Some(10)).unwrap();
805 reindex_window(&mut store, &w.group, w.span.start, Some(10), Some(80)).unwrap();
806 assert_eq!(store.index_entry_count(), 1, "re-keying must not leave the old entry behind");
807
808 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
809 assert!(engine.expire(&mut store, 10).unwrap().is_empty(), "no longer due at the old expiry");
810 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
811 assert_eq!(engine.expire(&mut store, 80).unwrap().len(), 1, "due at the new expiry");
812 }
813
814 #[test]
815 fn accumulator_survives_restart() {
816 let mut store = MockStore::default();
820
821 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
822 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
823 buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(5)]);
824 let published: Vec<WindowResult<u32, DateTime, i64>> =
825 apply_sums(&mut engine, &mut store, buckets).unwrap();
826 assert_eq!(published.len(), 1);
827 assert!(matches!(published[0].kind, EmitKind::Insert));
828 assert_eq!(published[0].value, 5);
829
830 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
832 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
833 buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Remove(5)]);
834 let withdrawn: Vec<WindowResult<u32, DateTime, i64>> =
835 apply_sums(&mut engine, &mut store, buckets).unwrap();
836
837 assert_eq!(withdrawn.len(), 1, "emptying the window emits exactly one terminal diff");
838 assert!(
839 matches!(withdrawn[0].kind, EmitKind::Remove),
840 "the window emptied under retraction, so the last published row must be withdrawn"
841 );
842 assert_eq!(withdrawn[0].value, 5, "the withdrawn value is the reloaded pre-batch accumulator output");
843 assert_eq!(
844 withdrawn[0].row_number, published[0].row_number,
845 "the withdrawal targets the same row that was published"
846 );
847 }
848
849 #[test]
850 fn accumulator_survives_lru_eviction() {
851 let mut store = MockStore::default();
854 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
855
856 let mut published_group_1: Vec<WindowResult<u32, DateTime, i64>> = Vec::new();
857 for group in 1u32..=11u32 {
858 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
859 buckets.insert(
860 (group, WindowSpan::new(at_millis(0), at_millis(1))),
861 vec![AccumulatorEvent::Add(i64::from(group))],
862 );
863 let out: Vec<WindowResult<u32, DateTime, i64>> =
864 apply_sums(&mut engine, &mut store, buckets).unwrap();
865 if group == 1 {
866 published_group_1 = out;
867 }
868 }
869 assert_eq!(published_group_1.len(), 1);
870 assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
871 assert_eq!(published_group_1[0].value, 1);
872
873 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
876 buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Remove(1)]);
877 let withdrawn: Vec<WindowResult<u32, DateTime, i64>> =
878 apply_sums(&mut engine, &mut store, buckets).unwrap();
879
880 assert_eq!(withdrawn.len(), 1, "emptying the evicted window emits exactly one terminal diff");
881 assert!(
882 matches!(withdrawn[0].kind, EmitKind::Remove),
883 "the evicted window emptied under retraction, so the last published row must be withdrawn"
884 );
885 assert_eq!(withdrawn[0].value, 1, "the withdrawn value is the reloaded accumulator output for group 1");
886 assert_eq!(
887 withdrawn[0].row_number, published_group_1[0].row_number,
888 "the withdrawal targets the same row that was published for group 1"
889 );
890 }
891
892 static COUNTING_ACC_CLONES: AtomicUsize = AtomicUsize::new(0);
895
896 #[operator_state]
897 #[derive(Debug, Default)]
898 struct CountingAcc {
899 sum: i64,
900 count: u64,
901 }
902
903 impl Clone for CountingAcc {
904 fn clone(&self) -> Self {
905 COUNTING_ACC_CLONES.fetch_add(1, Ordering::SeqCst);
906 Self {
907 sum: self.sum,
908 count: self.count,
909 }
910 }
911 }
912
913 impl HeapSize for CountingAcc {
914 fn heap_size(&self) -> usize {
915 0
916 }
917 }
918
919 impl WindowAccumulator for CountingAcc {
920 type Contribution = i64;
921 type Output = i64;
922
923 fn add(&mut self, contribution: &i64) {
924 self.sum += *contribution;
925 self.count += 1;
926 }
927 fn remove(&mut self, contribution: &i64) {
928 self.sum -= *contribution;
929 self.count = self.count.saturating_sub(1);
930 }
931 fn finalize(&self) -> Option<i64> {
932 (self.count > 0).then_some(self.sum)
933 }
934 fn is_empty(&self) -> bool {
935 self.count == 0
936 }
937 fn merge(&mut self, other: &Self) {
938 self.sum += other.sum;
939 self.count += other.count;
940 }
941 fn unmerge(&mut self, other: &Self) {
942 self.sum -= other.sum;
943 self.count = self.count.saturating_sub(other.count);
944 }
945 }
946
947 #[test]
948 fn expire_touches_no_accumulator_on_either_residency_path() {
949 let mut store = MockStore::default();
953 let mut engine = TumblingEngine::<u32, DateTime, CountingAcc>::new(test_config());
954 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
955 buckets.insert((1u32, WindowSpan::new(at_millis(0), at_millis(1))), vec![AccumulatorEvent::Add(5)]);
956 buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(7)]);
957 let results = apply_counting(&mut engine, &mut store, buckets).unwrap();
958 assert_eq!(results.len(), 2);
959 for w in &results {
960 let expiry = if w.span.start == at_millis(0) {
961 10
962 } else {
963 90
964 };
965 reindex_window(&mut store, &w.group, w.span.start, None, Some(expiry)).unwrap();
966 }
967
968 let before = COUNTING_ACC_CLONES.load(Ordering::SeqCst);
969
970 let expired = engine.expire(&mut store, 10).unwrap();
972 assert_eq!(expired.len(), 1);
973 assert_eq!(expired[0].window_start, at_millis(0), "Native-resident path");
974
975 let mut fresh = TumblingEngine::<u32, DateTime, CountingAcc>::new(test_config());
978 let expired = fresh.expire(&mut store, 1000).unwrap();
979 assert_eq!(expired.len(), 1);
980 assert_eq!(expired[0].window_start, at_millis(100), "archived path");
981
982 assert_eq!(
983 COUNTING_ACC_CLONES.load(Ordering::SeqCst) - before,
984 0,
985 "expire must not clone accumulators on either the Native or the archived path"
986 );
987 }
988
989 fn read_high_water(
990 engine: &mut TumblingEngine<u32, DateTime, SumAccumulator>,
991 store: &mut MockStore,
992 group: u32,
993 ) -> Option<u64> {
994 engine.meta.get(store, &meta_key_for(&group)).unwrap().and_then(|meta| meta.high_water_order())
995 }
996
997 #[test]
998 fn warmed_meta_high_water_advances_across_engine_restarts() {
999 let mut store = MockStore::default();
1001 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1002 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
1003 buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(5)]);
1004 apply_sums(&mut engine, &mut store, buckets).unwrap();
1005
1006 let mut fresh = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1007 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
1008 buckets.insert((1u32, WindowSpan::new(at_millis(200), at_millis(201))), vec![AccumulatorEvent::Add(7)]);
1009 apply_sums(&mut fresh, &mut store, buckets).unwrap();
1010
1011 assert_eq!(
1012 read_high_water(&mut fresh, &mut store, 1),
1013 Some(order(200)),
1014 "the bump must be durable the moment it is applied"
1015 );
1016
1017 let mut third = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1018 assert_eq!(
1019 read_high_water(&mut third, &mut store, 1),
1020 Some(order(200)),
1021 "the write round-trips through the store"
1022 );
1023 }
1024
1025 #[test]
1026 fn a_persisted_none_high_water_still_accepts_a_bump() {
1027 let mut store = MockStore::default();
1029 let mut engine = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1030 engine.meta
1031 .put(
1032 &mut store,
1033 &meta_key_for(&1u32),
1034 GroupMeta {
1035 high_water: None,
1036 },
1037 )
1038 .unwrap();
1039
1040 let mut fresh = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1041 let mut buckets: TumblingBuckets<u32, DateTime, i64> = BTreeMap::new();
1042 buckets.insert((1u32, WindowSpan::new(at_millis(100), at_millis(101))), vec![AccumulatorEvent::Add(7)]);
1043 apply_sums(&mut fresh, &mut store, buckets).unwrap();
1044
1045 let mut third = TumblingEngine::<u32, DateTime, SumAccumulator>::new(test_config());
1046 assert_eq!(
1047 read_high_water(&mut third, &mut store, 1),
1048 Some(order(100)),
1049 "a none high water must advance to the first observed coordinate"
1050 );
1051 }
1052}