1use std::{
5 collections::{BTreeMap, BTreeSet, HashMap},
6 fmt::Debug,
7 hash::Hash,
8 marker::PhantomData,
9};
10
11use reifydb_codec::key::{
12 encode_u64,
13 encoded::{EncodedKey, IntoEncodedKey},
14};
15use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
16use serde::{Deserialize, Serialize, de::DeserializeOwned};
17
18use crate::window::{
19 accumulator::WindowAccumulator,
20 engine::{
21 AccumulatorEvent, EmitKind, GroupMeta, LatePolicy, MetaKey, WindowResult, config::WindowEngineConfig,
22 expiry_due_range, expiry_key, meta_key_for,
23 },
24 span::{Slot, WindowSpan},
25 state::StateCache,
26 store::WindowStore,
27};
28
29pub type TumblingBuckets<G, C, Contribution> = BTreeMap<(G, WindowSpan<C>), Vec<AccumulatorEvent<Contribution>>>;
30
31type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
32type SlotResolved = Vec<Option<(RowNumber, bool)>>;
33
34pub struct ExpiredWindow<G, C, Output> {
35 pub row_number: RowNumber,
36 pub group: G,
37 pub window_start: C,
38 pub value: Option<Output>,
39}
40
41#[derive(Serialize, Deserialize)]
42#[serde(bound(serialize = "G: Serialize, C: Serialize", deserialize = "G: DeserializeOwned, C: DeserializeOwned"))]
43struct TumblingIndexEntry<G, C> {
44 group: G,
45 window_start: C,
46 row_number: u64,
47}
48
49pub fn reindex_window<S, G, C>(
50 store: &mut S,
51 group: &G,
52 window_start: C,
53 row_number: RowNumber,
54 prior: Option<u64>,
55 new: Option<u64>,
56) -> Result<()>
57where
58 S: WindowStore,
59 G: Clone + Serialize,
60 C: Slot + Serialize,
61 for<'a> &'a G: IntoEncodedKey,
62{
63 if prior == new {
64 return Ok(());
65 }
66 let suffix = encode_u64(window_start.order_key());
67 if let Some(old) = prior {
68 store.internal_drop(&expiry_key(old, group, &suffix))?;
69 }
70 if let Some(new) = new {
71 store.internal_set(
72 &expiry_key(new, group, &suffix),
73 &TumblingIndexEntry {
74 group: group.clone(),
75 window_start,
76 row_number: row_number.0,
77 },
78 )?;
79 }
80 Ok(())
81}
82
83pub struct TumblingEngine<G, C, Accumulator> {
84 accumulators: StateCache<RowNumber, Accumulator>,
85 meta: StateCache<MetaKey, GroupMeta<C>>,
86 late_policy: LatePolicy,
87 _pd: PhantomData<G>,
88}
89
90impl<G, C, Accumulator> TumblingEngine<G, C, Accumulator>
91where
92 G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
93 C: Slot + Hash + Serialize + DeserializeOwned,
94 Accumulator: WindowAccumulator,
95 for<'a> &'a G: IntoEncodedKey,
96{
97 pub fn new(config: WindowEngineConfig) -> Self {
98 Self {
99 accumulators: StateCache::<RowNumber, Accumulator>::new(config.state_cache_capacity()),
100 meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
101 late_policy: config.late_policy(),
102 _pd: PhantomData,
103 }
104 }
105
106 pub fn apply<S, K, NA>(
107 &mut self,
108 store: &mut S,
109 buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
110 row_key: K,
111 new_accumulator: NA,
112 ) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
113 where
114 S: WindowStore,
115 K: Fn(&G, C) -> EncodedKey,
116 NA: Fn() -> Accumulator,
117 {
118 if buckets.is_empty() {
119 return Ok(Vec::new());
120 }
121 let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
122 let slot_resolved = self.resolve_survivor_rows(store, &buckets, &meta_loaded, &row_key)?;
123 let results =
124 self.apply_events(store, buckets, slot_resolved, &mut meta_loaded, &row_key, &new_accumulator)?;
125 self.persist_meta(store, meta_loaded)?;
126 Ok(results)
127 }
128
129 pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
130 self.accumulators.flush(store)?;
131 self.meta.flush(store)?;
132 Ok(())
133 }
134
135 fn warm_and_load_meta<S: WindowStore>(
136 &mut self,
137 store: &mut S,
138 buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
139 ) -> Result<MetaLoaded<G, C>> {
140 let meta_keys: Vec<MetaKey> = buckets
141 .keys()
142 .map(|(group, _)| group)
143 .collect::<BTreeSet<_>>()
144 .into_iter()
145 .map(meta_key_for)
146 .collect();
147 self.meta.warm(store, &meta_keys)?;
148
149 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
150 for (group, _) in buckets.keys() {
151 if !meta_loaded.contains_key(group) {
152 let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
153 meta_loaded.insert(group.clone(), m);
154 }
155 }
156 Ok(meta_loaded)
157 }
158
159 fn resolve_survivor_rows<S, K>(
160 &mut self,
161 store: &mut S,
162 buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
163 meta_loaded: &MetaLoaded<G, C>,
164 row_key: &K,
165 ) -> Result<SlotResolved>
166 where
167 S: WindowStore,
168 K: Fn(&G, C) -> EncodedKey,
169 {
170 let mut survivor_keys: Vec<EncodedKey> = Vec::new();
171 let mut slot_survives: Vec<bool> = Vec::with_capacity(buckets.len());
172 for (group, span) in buckets.keys() {
173 let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
174 let survives = initial_high_water.is_none_or(|hw| span.start >= hw);
175 slot_survives.push(survives);
176 if survives {
177 survivor_keys.push(row_key(group, span.start));
178 }
179 }
180 let resolved_rows = store.get_or_create_row_numbers(&survivor_keys)?;
181 reifydb_assertions! {
182 let survivors = survivor_keys.len();
183 let resolved = resolved_rows.len();
184 assert!(
185 resolved == survivors,
186 "get_or_create_row_numbers must return exactly one row per survivor key; a short batch would \
187 leave a surviving slot with no resolved row, so the slot_resolved zip below pairs it with None \
188 and apply_events silently re-creates a fresh row instead of reusing the existing window \
189 state, double-counting it (survivor_keys={survivors}, resolved_rows={resolved})"
190 );
191 }
192 let accumulator_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
193 self.accumulators.warm(store, &accumulator_keys)?;
194 let mut resolved_rows = resolved_rows.into_iter();
195 let slot_resolved: SlotResolved = slot_survives
196 .into_iter()
197 .map(|survives| {
198 if survives {
199 resolved_rows.next()
200 } else {
201 None
202 }
203 })
204 .collect();
205 Ok(slot_resolved)
206 }
207
208 fn apply_events<S, K, NA>(
209 &mut self,
210 store: &mut S,
211 buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
212 slot_resolved: SlotResolved,
213 meta_loaded: &mut MetaLoaded<G, C>,
214 row_key: &K,
215 new_accumulator: &NA,
216 ) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
217 where
218 S: WindowStore,
219 K: Fn(&G, C) -> EncodedKey,
220 NA: Fn() -> Accumulator,
221 {
222 let mut results: Vec<WindowResult<G, C, Accumulator::Output>> = Vec::new();
223
224 for (((group, span), events), slot_pre) in buckets.into_iter().zip(slot_resolved) {
225 let entry = meta_loaded.entry(group.clone()).or_default();
226 let late = matches!(entry.high_water, Some(hw) if span.start < hw);
227 let drop_late_adds = late && matches!(self.late_policy, LatePolicy::Drop);
228 if drop_late_adds && !events.iter().any(|e| matches!(e, AccumulatorEvent::Remove(_))) {
229 continue;
230 }
231 match entry.high_water {
232 Some(hw) if span.start > hw => entry.high_water = Some(span.start),
233 None => entry.high_water = Some(span.start),
234 _ => {}
235 }
236
237 let (row_number, is_new) = match slot_pre {
238 Some(resolved) => resolved,
239 None => {
240 let key = row_key(&group, span.start);
241 store.get_or_create_row_number(&key)?
242 }
243 };
244
245 let mut accumulator: Accumulator =
246 self.accumulators.get(store, &row_number)?.unwrap_or_else(new_accumulator);
247 let was_empty_before = accumulator.is_empty();
248 let prior = if was_empty_before {
249 None
250 } else {
251 accumulator.finalize()
252 };
253
254 for event in events {
255 match event {
256 AccumulatorEvent::Add(c) => {
257 if drop_late_adds {
258 continue;
259 }
260 accumulator.add(&c);
261 }
262 AccumulatorEvent::Remove(c) => {
263 if accumulator.is_empty() {
264 continue;
265 }
266 if drop_late_adds {
267 accumulator.remove_if_present(&c);
268 } else {
269 accumulator.remove(&c);
270 }
271 }
272 }
273 }
274
275 let value = accumulator.finalize();
276 self.accumulators.put(store, &row_number, accumulator)?;
277
278 match value {
279 Some(value) => {
280 let kind = if is_new || was_empty_before {
281 EmitKind::Insert
282 } else {
283 EmitKind::Update
284 };
285 results.push(WindowResult {
286 row_number,
287 group,
288 span,
289 value,
290 prior,
291 kind,
292 });
293 }
294 None => {
295 if let Some(p) = prior.clone() {
296 results.push(WindowResult {
297 row_number,
298 group,
299 span,
300 value: p,
301 prior,
302 kind: EmitKind::Remove,
303 });
304 }
305 }
306 }
307 }
308 Ok(results)
309 }
310
311 pub fn expire<S: WindowStore>(
312 &mut self,
313 store: &mut S,
314 threshold: u64,
315 ) -> Result<Vec<ExpiredWindow<G, C, Accumulator::Output>>> {
316 let mut due: Vec<(EncodedKey, TumblingIndexEntry<G, C>)> = Vec::new();
317 store.internal_range_visit::<TumblingIndexEntry<G, C>>(
318 expiry_due_range(threshold),
319 &mut |key, entry| {
320 due.push((key, entry));
321 Ok(())
322 },
323 )?;
324
325 let mut out: Vec<ExpiredWindow<G, C, Accumulator::Output>> = Vec::new();
326 for (index_key, entry) in due {
327 let row_number = RowNumber(entry.row_number);
328 store.internal_drop(&index_key)?;
329 let value = self
330 .accumulators
331 .get(store, &row_number)?
332 .and_then(|accumulator| accumulator.finalize());
333 self.accumulators.remove(store, &row_number)?;
334 out.push(ExpiredWindow {
335 row_number,
336 group: entry.group,
337 window_start: entry.window_start,
338 value,
339 });
340 }
341 Ok(out)
342 }
343
344 fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
345 for (group, meta) in meta_loaded {
346 self.meta.set(store, &meta_key_for(&group), &meta)?;
347 }
348 Ok(())
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use std::collections::BTreeMap;
355
356 use reifydb_codec::key::encoded::EncodedKey;
357
358 use crate::window::{
359 engine::{
360 AccumulatorEvent, EmitKind, WindowResult,
361 config::WindowEngineConfig,
362 test_support::{MockStore, SumAccumulator},
363 tumbling::{TumblingBuckets, TumblingEngine, reindex_window},
364 },
365 span::WindowSpan,
366 };
367
368 fn test_config() -> WindowEngineConfig {
369 WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
370 }
371
372 fn row_key(group: &u32, window_start: u64) -> EncodedKey {
373 EncodedKey::builder().u32(*group).u64(window_start).build()
374 }
375
376 fn seed_window(store: &mut MockStore, window_start: u64, contribution: i64) -> WindowResult<u32, u64, i64> {
377 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
378 let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
379 buckets.insert(
380 (1u32, WindowSpan::new(window_start, window_start + 1)),
381 vec![AccumulatorEvent::Add(contribution)],
382 );
383 let mut results = engine.apply(store, buckets, row_key, SumAccumulator::default).expect("apply");
384 engine.flush(store).expect("flush");
385 results.pop().expect("one window")
386 }
387
388 #[test]
389 fn expire_returns_only_due_windows_and_clears_their_state() {
390 let mut store = MockStore::default();
391 let w0 = seed_window(&mut store, 0, 5);
393 reindex_window(&mut store, &w0.group, w0.span.start, w0.row_number, None, Some(10)).unwrap();
394 let w100 = seed_window(&mut store, 100, 7);
395 reindex_window(&mut store, &w100.group, w100.span.start, w100.row_number, None, Some(90)).unwrap();
396 assert_eq!(store.index_entry_count(), 2, "both live windows are indexed");
397
398 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
400 let expired = engine.expire(&mut store, 10).unwrap();
401 engine.flush(&mut store).unwrap();
402 assert_eq!(expired.len(), 1, "exactly one window is due, not the whole population");
403 assert_eq!(expired[0].window_start, 0);
404 assert_eq!(expired[0].value, Some(5));
405 assert_eq!(store.index_entry_count(), 1, "the due window's index entry is gone, the other remains");
406
407 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
409 let later = engine.expire(&mut store, 1000).unwrap();
410 assert_eq!(later.len(), 1);
411 assert_eq!(later[0].window_start, 100);
412 assert_eq!(later[0].value, Some(7));
413 assert_eq!(store.index_entry_count(), 0);
414 }
415
416 #[test]
417 fn expire_threshold_is_inclusive() {
418 let mut store = MockStore::default();
419 let w = seed_window(&mut store, 0, 4);
420 reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(50)).unwrap();
421
422 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
424 assert!(engine.expire(&mut store, 49).unwrap().is_empty());
425 engine.flush(&mut store).unwrap();
426 assert_eq!(store.index_entry_count(), 1);
427
428 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
430 assert_eq!(engine.expire(&mut store, 50).unwrap().len(), 1);
431 }
432
433 #[test]
434 fn reindex_rekeys_without_leaving_a_stale_entry() {
435 let mut store = MockStore::default();
436 let w = seed_window(&mut store, 0, 9);
437 reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(10)).unwrap();
439 reindex_window(&mut store, &w.group, w.span.start, w.row_number, Some(10), Some(80)).unwrap();
440 assert_eq!(store.index_entry_count(), 1, "re-keying must not leave the old entry behind");
441
442 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
443 assert!(engine.expire(&mut store, 10).unwrap().is_empty(), "no longer due at the old expiry");
444 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
445 assert_eq!(engine.expire(&mut store, 80).unwrap().len(), 1, "due at the new expiry");
446 }
447
448 #[test]
449 fn accumulator_survives_restart() {
450 let mut store = MockStore::default();
457
458 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
459 let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
460 buckets.insert((1u32, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Add(5)]);
461 let published: Vec<WindowResult<u32, u64, i64>> =
462 engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
463 engine.flush(&mut store).unwrap();
464 assert_eq!(published.len(), 1);
465 assert!(matches!(published[0].kind, EmitKind::Insert));
466 assert_eq!(published[0].value, 5);
467
468 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
470 let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
471 buckets.insert((1u32, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Remove(5)]);
472 let withdrawn: Vec<WindowResult<u32, u64, i64>> =
473 engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
474 engine.flush(&mut store).unwrap();
475
476 assert_eq!(withdrawn.len(), 1, "emptying the window emits exactly one terminal diff");
477 assert!(
478 matches!(withdrawn[0].kind, EmitKind::Remove),
479 "the window emptied under retraction, so the last published row must be withdrawn"
480 );
481 assert_eq!(withdrawn[0].value, 5, "the withdrawn value is the reloaded pre-batch accumulator output");
482 assert_eq!(
483 withdrawn[0].row_number, published[0].row_number,
484 "the withdrawal targets the same row that was published"
485 );
486 }
487
488 #[test]
489 fn accumulator_survives_lru_eviction() {
490 let mut store = MockStore::default();
495 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new(test_config());
496
497 let mut published_group_1: Vec<WindowResult<u32, u64, i64>> = Vec::new();
498 for group in 1u32..=11u32 {
499 let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
500 buckets.insert((group, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Add(i64::from(group))]);
501 let out: Vec<WindowResult<u32, u64, i64>> =
502 engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
503 if group == 1 {
504 published_group_1 = out;
505 }
506 }
507 engine.flush(&mut store).unwrap();
508 assert_eq!(published_group_1.len(), 1);
509 assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
510 assert_eq!(published_group_1[0].value, 1);
511
512 let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
515 buckets.insert((1u32, WindowSpan::new(0, 1)), vec![AccumulatorEvent::Remove(1)]);
516 let withdrawn: Vec<WindowResult<u32, u64, i64>> =
517 engine.apply(&mut store, buckets, row_key, SumAccumulator::default).unwrap();
518 engine.flush(&mut store).unwrap();
519
520 assert_eq!(withdrawn.len(), 1, "emptying the evicted window emits exactly one terminal diff");
521 assert!(
522 matches!(withdrawn[0].kind, EmitKind::Remove),
523 "the evicted window emptied under retraction, so the last published row must be withdrawn"
524 );
525 assert_eq!(withdrawn[0].value, 1, "the withdrawn value is the reloaded accumulator output for group 1");
526 assert_eq!(
527 withdrawn[0].row_number, published_group_1[0].row_number,
528 "the withdrawal targets the same row that was published for group 1"
529 );
530 }
531}