1use std::{
5 collections::{BTreeMap, BTreeSet, HashMap},
6 fmt::Debug,
7 hash::Hash,
8 marker::PhantomData,
9};
10
11use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13
14use crate::{
15 encoded::key::{EncodedKey, IntoEncodedKey},
16 util::encoding::keycode::encode_u64,
17 window::{
18 accumulator::WindowAccumulator,
19 engine::{
20 AccumulatorEvent, EmitKind, GroupMeta, LatePolicy, MetaKey, WindowResult, expiry_due_range,
21 expiry_key, meta_key_for,
22 },
23 span::{Slot, WindowSpan},
24 state::StateCache,
25 store::WindowStore,
26 },
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> Default for 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 fn default() -> Self {
98 Self::new()
99 }
100}
101
102impl<G, C, Accumulator> TumblingEngine<G, C, Accumulator>
103where
104 G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
105 C: Slot + Hash + Serialize + DeserializeOwned,
106 Accumulator: WindowAccumulator,
107 for<'a> &'a G: IntoEncodedKey,
108{
109 pub fn new() -> Self {
110 Self::with_late_policy(LatePolicy::Drop)
111 }
112
113 pub fn with_late_policy(late_policy: LatePolicy) -> Self {
114 Self {
115 accumulators: StateCache::<RowNumber, Accumulator>::new(8),
116 meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(64),
117 late_policy,
118 _pd: PhantomData,
119 }
120 }
121
122 pub fn apply<S, K, NA>(
123 &mut self,
124 store: &mut S,
125 buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
126 row_key: K,
127 new_accumulator: NA,
128 ) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
129 where
130 S: WindowStore,
131 K: Fn(&G, C) -> EncodedKey,
132 NA: Fn() -> Accumulator,
133 {
134 if buckets.is_empty() {
135 return Ok(Vec::new());
136 }
137 let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
138 let slot_resolved = self.resolve_survivor_rows(store, &buckets, &meta_loaded, &row_key)?;
139 let results =
140 self.apply_events(store, buckets, slot_resolved, &mut meta_loaded, &row_key, &new_accumulator)?;
141 self.persist_meta(store, meta_loaded)?;
142 Ok(results)
143 }
144
145 pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
146 self.accumulators.flush(store)?;
147 self.meta.flush(store)?;
148 Ok(())
149 }
150
151 fn warm_and_load_meta<S: WindowStore>(
152 &mut self,
153 store: &mut S,
154 buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
155 ) -> Result<MetaLoaded<G, C>> {
156 let meta_keys: Vec<MetaKey> = buckets
157 .keys()
158 .map(|(group, _)| group)
159 .collect::<BTreeSet<_>>()
160 .into_iter()
161 .map(meta_key_for)
162 .collect();
163 self.meta.warm(store, &meta_keys)?;
164
165 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
166 for (group, _) in buckets.keys() {
167 if !meta_loaded.contains_key(group) {
168 let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
169 meta_loaded.insert(group.clone(), m);
170 }
171 }
172 Ok(meta_loaded)
173 }
174
175 fn resolve_survivor_rows<S, K>(
176 &mut self,
177 store: &mut S,
178 buckets: &TumblingBuckets<G, C, Accumulator::Contribution>,
179 meta_loaded: &MetaLoaded<G, C>,
180 row_key: &K,
181 ) -> Result<SlotResolved>
182 where
183 S: WindowStore,
184 K: Fn(&G, C) -> EncodedKey,
185 {
186 let mut survivor_keys: Vec<EncodedKey> = Vec::new();
187 let mut slot_survives: Vec<bool> = Vec::with_capacity(buckets.len());
188 for (group, span) in buckets.keys() {
189 let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
190 let survives = initial_high_water.is_none_or(|hw| span.start >= hw);
191 slot_survives.push(survives);
192 if survives {
193 survivor_keys.push(row_key(group, span.start));
194 }
195 }
196 let resolved_rows = store.get_or_create_row_numbers(&survivor_keys)?;
197 reifydb_assertions! {
198 let survivors = survivor_keys.len();
199 let resolved = resolved_rows.len();
200 assert!(
201 resolved == survivors,
202 "get_or_create_row_numbers must return exactly one row per survivor key; a short batch would \
203 leave a surviving slot with no resolved row, so the slot_resolved zip below pairs it with None \
204 and apply_events silently re-creates a fresh row instead of reusing the existing window \
205 state, double-counting it (survivor_keys={survivors}, resolved_rows={resolved})"
206 );
207 }
208 let accumulator_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
209 self.accumulators.warm(store, &accumulator_keys)?;
210 let mut resolved_rows = resolved_rows.into_iter();
211 let slot_resolved: SlotResolved = slot_survives
212 .into_iter()
213 .map(|survives| {
214 if survives {
215 resolved_rows.next()
216 } else {
217 None
218 }
219 })
220 .collect();
221 Ok(slot_resolved)
222 }
223
224 fn apply_events<S, K, NA>(
225 &mut self,
226 store: &mut S,
227 buckets: TumblingBuckets<G, C, Accumulator::Contribution>,
228 slot_resolved: SlotResolved,
229 meta_loaded: &mut MetaLoaded<G, C>,
230 row_key: &K,
231 new_accumulator: &NA,
232 ) -> Result<Vec<WindowResult<G, C, Accumulator::Output>>>
233 where
234 S: WindowStore,
235 K: Fn(&G, C) -> EncodedKey,
236 NA: Fn() -> Accumulator,
237 {
238 let mut results: Vec<WindowResult<G, C, Accumulator::Output>> = Vec::new();
239
240 for (((group, span), events), slot_pre) in buckets.into_iter().zip(slot_resolved) {
241 let entry = meta_loaded.entry(group.clone()).or_default();
242 match entry.high_water {
243 Some(hw) if span.start < hw => {
244 if matches!(self.late_policy, LatePolicy::Drop) {
245 continue;
246 }
247 }
248 Some(hw) if span.start > hw => entry.high_water = Some(span.start),
249 Some(_) => {}
250 None => entry.high_water = Some(span.start),
251 }
252
253 let (row_number, is_new) = match slot_pre {
254 Some(resolved) => resolved,
255 None => {
256 let key = row_key(&group, span.start);
257 store.get_or_create_row_number(&key)?
258 }
259 };
260
261 let mut accumulator: Accumulator =
262 self.accumulators.get(store, &row_number)?.unwrap_or_else(new_accumulator);
263 let was_empty_before = accumulator.is_empty();
264 let prior = if was_empty_before {
265 None
266 } else {
267 accumulator.finalize()
268 };
269
270 for event in events {
271 match event {
272 AccumulatorEvent::Add(c) => accumulator.add(&c),
273 AccumulatorEvent::Remove(c) => accumulator.remove(&c),
274 }
275 }
276
277 let value = accumulator.finalize();
278 self.accumulators.put(store, &row_number, accumulator)?;
279
280 match value {
281 Some(value) => {
282 let kind = if is_new || was_empty_before {
283 EmitKind::Insert
284 } else {
285 EmitKind::Update
286 };
287 results.push(WindowResult {
288 row_number,
289 group,
290 span,
291 value,
292 prior,
293 kind,
294 });
295 }
296 None => {
297 if let Some(p) = prior.clone() {
298 results.push(WindowResult {
299 row_number,
300 group,
301 span,
302 value: p,
303 prior,
304 kind: EmitKind::Remove,
305 });
306 }
307 }
308 }
309 }
310 Ok(results)
311 }
312
313 pub fn expire<S: WindowStore>(
314 &mut self,
315 store: &mut S,
316 threshold: u64,
317 ) -> Result<Vec<ExpiredWindow<G, C, Accumulator::Output>>> {
318 let mut due: Vec<(EncodedKey, TumblingIndexEntry<G, C>)> = Vec::new();
319 store.internal_range_visit::<TumblingIndexEntry<G, C>>(
320 expiry_due_range(threshold),
321 &mut |key, entry| {
322 due.push((key, entry));
323 Ok(())
324 },
325 )?;
326
327 let mut out: Vec<ExpiredWindow<G, C, Accumulator::Output>> = Vec::new();
328 for (index_key, entry) in due {
329 let row_number = RowNumber(entry.row_number);
330 store.internal_drop(&index_key)?;
331 let value = self
332 .accumulators
333 .get(store, &row_number)?
334 .and_then(|accumulator| accumulator.finalize());
335 self.accumulators.remove(store, &row_number)?;
336 out.push(ExpiredWindow {
337 row_number,
338 group: entry.group,
339 window_start: entry.window_start,
340 value,
341 });
342 }
343 Ok(out)
344 }
345
346 fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
347 for (group, meta) in meta_loaded {
348 self.meta.set(store, &meta_key_for(&group), &meta)?;
349 }
350 Ok(())
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use std::collections::BTreeMap;
357
358 use crate::{
359 encoded::key::EncodedKey,
360 window::{
361 engine::{
362 AccumulatorEvent, WindowResult,
363 test_support::{MockStore, SumAccumulator},
364 tumbling::{TumblingBuckets, TumblingEngine, reindex_window},
365 },
366 span::WindowSpan,
367 },
368 };
369
370 fn row_key(group: &u32, window_start: u64) -> EncodedKey {
371 EncodedKey::builder().u32(*group).u64(window_start).build()
372 }
373
374 fn seed_window(store: &mut MockStore, window_start: u64, contribution: i64) -> WindowResult<u32, u64, i64> {
375 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
376 let mut buckets: TumblingBuckets<u32, u64, i64> = BTreeMap::new();
377 buckets.insert(
378 (1u32, WindowSpan::new(window_start, window_start + 1)),
379 vec![AccumulatorEvent::Add(contribution)],
380 );
381 let mut results = engine.apply(store, buckets, row_key, SumAccumulator::default).expect("apply");
382 engine.flush(store).expect("flush");
383 results.pop().expect("one window")
384 }
385
386 #[test]
387 fn expire_returns_only_due_windows_and_clears_their_state() {
388 let mut store = MockStore::default();
389 let w0 = seed_window(&mut store, 0, 5);
391 reindex_window(&mut store, &w0.group, w0.span.start, w0.row_number, None, Some(10)).unwrap();
392 let w100 = seed_window(&mut store, 100, 7);
393 reindex_window(&mut store, &w100.group, w100.span.start, w100.row_number, None, Some(90)).unwrap();
394 assert_eq!(store.index_entry_count(), 2, "both live windows are indexed");
395
396 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
398 let expired = engine.expire(&mut store, 10).unwrap();
399 engine.flush(&mut store).unwrap();
400 assert_eq!(expired.len(), 1, "exactly one window is due, not the whole population");
401 assert_eq!(expired[0].window_start, 0);
402 assert_eq!(expired[0].value, Some(5));
403 assert_eq!(store.index_entry_count(), 1, "the due window's index entry is gone, the other remains");
404
405 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
407 let later = engine.expire(&mut store, 1000).unwrap();
408 assert_eq!(later.len(), 1);
409 assert_eq!(later[0].window_start, 100);
410 assert_eq!(later[0].value, Some(7));
411 assert_eq!(store.index_entry_count(), 0);
412 }
413
414 #[test]
415 fn expire_threshold_is_inclusive() {
416 let mut store = MockStore::default();
417 let w = seed_window(&mut store, 0, 4);
418 reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(50)).unwrap();
419
420 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
422 assert!(engine.expire(&mut store, 49).unwrap().is_empty());
423 engine.flush(&mut store).unwrap();
424 assert_eq!(store.index_entry_count(), 1);
425
426 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
428 assert_eq!(engine.expire(&mut store, 50).unwrap().len(), 1);
429 }
430
431 #[test]
432 fn reindex_rekeys_without_leaving_a_stale_entry() {
433 let mut store = MockStore::default();
434 let w = seed_window(&mut store, 0, 9);
435 reindex_window(&mut store, &w.group, w.span.start, w.row_number, None, Some(10)).unwrap();
437 reindex_window(&mut store, &w.group, w.span.start, w.row_number, Some(10), Some(80)).unwrap();
438 assert_eq!(store.index_entry_count(), 1, "re-keying must not leave the old entry behind");
439
440 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
441 assert!(engine.expire(&mut store, 10).unwrap().is_empty(), "no longer due at the old expiry");
442 let mut engine = TumblingEngine::<u32, u64, SumAccumulator>::new();
443 assert_eq!(engine.expire(&mut store, 80).unwrap().len(), 1, "due at the new expiry");
444 }
445}