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