1use std::{
5 collections::{BTreeMap, BTreeSet, HashMap},
6 fmt::Debug,
7 hash::Hash,
8 marker::PhantomData,
9 slice::from_ref,
10};
11
12use reifydb_codec::{
13 key::encoded::{EncodedKey, IntoEncodedKey},
14 row::operator::OperatorState,
15};
16use reifydb_core::{
17 key::operator_state::GroupId,
18 metrics::heap::HeapSize,
19 state::{cache::StateCache, store::StateStore},
20};
21use reifydb_value::{Result, reifydb_assertions};
22
23use crate::window::{
24 accumulator::WindowAccumulator,
25 engine::{
26 AccumulatorEvent, BatchMeta, EmitKind, GroupMeta, MetaKey, RunningKey, WindowStateKey,
27 config::WindowEngineConfig,
28 load_batch_meta, meta_key_for, persist_batch_meta,
29 rolling::{RollingBuckets, RollingBuffer, RollingResult},
30 sweep_stale_meta,
31 },
32 span::Slot,
33};
34
35type MetaLoaded<G, C> = HashMap<G, BatchMeta<C>>;
36type BufferRows<G> = HashMap<G, (GroupId, EncodedKey)>;
37
38struct GroupSlot<C, Accumulator, Running, Output> {
39 group_id: GroupId,
40 key: EncodedKey,
41 buffer: RollingBuffer<C, Accumulator>,
42 running: Running,
43 buffer_changed: bool,
44 prior_output: Option<Output>,
45}
46
47pub struct RollingIncrementalEngine<G, C, Accumulator, Running> {
48 buffers: StateCache<WindowStateKey, RollingBuffer<C, Accumulator>>,
49 running: StateCache<RunningKey, Running>,
50 meta: StateCache<MetaKey, GroupMeta<C>>,
51 meta_low_water: Option<u64>,
52 _pd: PhantomData<G>,
53}
54
55impl<G, C, Accumulator, Running> RollingIncrementalEngine<G, C, Accumulator, Running>
56where
57 G: Clone + Eq + Ord + Hash + Debug,
58 C: Slot + Hash,
59 Accumulator: WindowAccumulator,
60 Running: WindowAccumulator,
61 for<'a> &'a G: IntoEncodedKey,
62 C: HeapSize,
63 GroupMeta<C>: OperatorState,
64 RollingBuffer<C, Accumulator>: OperatorState + HeapSize,
65{
66 pub fn new(_config: WindowEngineConfig) -> Self {
67 Self {
68 buffers: StateCache::<WindowStateKey, RollingBuffer<C, Accumulator>>::new(),
69 running: StateCache::<RunningKey, Running>::new(),
70 meta: StateCache::<MetaKey, GroupMeta<C>>::new(),
71 meta_low_water: None,
72 _pd: PhantomData,
73 }
74 }
75
76 pub fn expire_meta(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<usize> {
77 sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
78 }
79
80 #[allow(clippy::too_many_arguments)]
81 pub fn apply<K, WC, CR, Output>(
82 &mut self,
83 store: &mut dyn StateStore,
84 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
85 capacity: usize,
86 row_key: K,
87 window_contribution: WC,
88 combine_running: CR,
89 ) -> Result<Vec<RollingResult<G, Output>>>
90 where
91 K: Fn(&G) -> EncodedKey,
92 WC: Fn(&Accumulator::Output) -> Running::Contribution,
93 CR: Fn(&G, &Running, &Accumulator::Output, C) -> Option<Output>,
94 {
95 if buckets.is_empty() {
96 return Ok(Vec::new());
97 }
98 let mut meta_loaded = self.load_meta(store, &buckets)?;
99 let buffer_rows = self.resolve_buffer_rows(store, &buckets, &meta_loaded, &row_key)?;
100
101 let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Running, Output>> = BTreeMap::new();
102
103 for ((group, coord), events) in buckets {
104 let meta = meta_loaded.entry(group.clone()).or_default();
105
106 let slot = match group_slots.get_mut(&group) {
107 Some(s) => s,
108 None => {
109 let (group_id, key) = match buffer_rows.get(&group) {
110 Some(resolved) => resolved.clone(),
111 None => {
112 let key = row_key(&group);
113 let group_id = store
114 .intern_groups(from_ref(&key))?
115 .into_iter()
116 .next()
117 .expect("intern_groups answers every requested key")
118 .0;
119 (group_id, key)
120 }
121 };
122 let buffer: RollingBuffer<C, Accumulator> = self
123 .buffers
124 .get(store, &WindowStateKey::new(group_id, key.clone()))?
125 .unwrap_or_default();
126 let running: Running = self
127 .running
128 .get(store, &RunningKey::new(group_id, key.clone()))?
129 .unwrap_or_default();
130 let prior_output = match buffer.iter().next_back() {
131 Some((coord, accumulator)) => {
132 accumulator.finalize().and_then(|newest| {
133 combine_running(&group, &running, &newest, *coord)
134 })
135 }
136 None => None,
137 };
138 group_slots.insert(
139 group.clone(),
140 GroupSlot {
141 group_id,
142 key,
143 buffer,
144 running,
145 buffer_changed: false,
146 prior_output,
147 },
148 );
149 group_slots.get_mut(&group).expect("just inserted")
150 }
151 };
152
153 let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
154 let old_value = accumulator.finalize();
155 let mut touched = false;
156 for event in events {
157 match event {
158 AccumulatorEvent::Add(c) => {
159 accumulator.add(&c);
160 touched = true;
161 }
162 AccumulatorEvent::Remove(c) => {
163 if accumulator.is_empty() {
164 continue;
165 }
166 accumulator.remove(&c);
167 touched = true;
168 }
169 }
170 }
171 if !touched {
172 continue;
173 }
174 let new_value = accumulator.finalize();
175
176 if let Some(old) = &old_value {
177 slot.running.remove(&window_contribution(old));
178 }
179 if let Some(new) = &new_value {
180 slot.running.add(&window_contribution(new));
181 }
182
183 if !accumulator.is_empty() {
184 slot.buffer.insert(coord, accumulator);
185 }
186 while slot.buffer.len() > capacity {
187 if let Some((_, evicted)) = slot.buffer.pop_first()
188 && let Some(value) = evicted.finalize()
189 {
190 slot.running.remove(&window_contribution(&value));
191 }
192 }
193 slot.buffer_changed = true;
194
195 meta.observe(coord);
196 }
197
198 let mut pairs: Vec<(GroupId, EncodedKey)> = Vec::new();
199 let mut pending: Vec<(G, Output, bool)> = Vec::new();
200 for (group, slot) in group_slots {
201 if !slot.buffer_changed {
202 continue;
203 }
204 let output = match slot.buffer.iter().next_back() {
205 Some((coord, accumulator)) => accumulator
206 .finalize()
207 .and_then(|newest| combine_running(&group, &slot.running, &newest, *coord)),
208 None => None,
209 };
210 self.buffers.put(store, &WindowStateKey::new(slot.group_id, slot.key.clone()), slot.buffer)?;
211 self.running.put(store, &RunningKey::new(slot.group_id, slot.key.clone()), slot.running)?;
212
213 if let Some(out) = output {
214 pairs.push((slot.group_id, slot.key));
215 pending.push((group, out, false));
216 } else if let Some(prior) = slot.prior_output {
217 pairs.push((slot.group_id, slot.key));
218 pending.push((group, prior, true));
219 }
220 }
221
222 let mut results: Vec<RollingResult<G, Output>> = Vec::with_capacity(pending.len());
223 if !pairs.is_empty() {
224 let rows = store.get_or_create_row_numbers_for_pairs(&pairs)?;
225 for (((group, value, withdrawn), (group_id, key)), (row_number, is_new)) in
226 pending.into_iter().zip(pairs).zip(rows)
227 {
228 if withdrawn {
229 store.remove_row_number(group_id, &key)?;
230 results.push(RollingResult {
231 row_number,
232 group,
233 value,
234 prior: None,
235 kind: EmitKind::Remove,
236 });
237 } else {
238 let kind = if is_new {
239 EmitKind::Insert
240 } else {
241 EmitKind::Update
242 };
243 results.push(RollingResult {
244 row_number,
245 group,
246 value,
247 prior: None,
248 kind,
249 });
250 }
251 }
252 }
253 self.persist_meta(store, meta_loaded)?;
254 Ok(results)
255 }
256
257 fn load_meta(
258 &mut self,
259 store: &mut dyn StateStore,
260 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
261 ) -> Result<MetaLoaded<G, C>> {
262 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
263 for (group, _) in buckets.keys() {
264 if !meta_loaded.contains_key(group) {
265 let batch = load_batch_meta(store, &mut self.meta, &meta_key_for(group))?;
266 meta_loaded.insert(group.clone(), batch);
267 }
268 }
269 Ok(meta_loaded)
270 }
271
272 fn resolve_buffer_rows<K>(
273 &mut self,
274 store: &mut dyn StateStore,
275 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
276 meta_loaded: &MetaLoaded<G, C>,
277 row_key: &K,
278 ) -> Result<BufferRows<G>>
279 where
280 K: Fn(&G) -> EncodedKey,
281 {
282 let mut buffer_rows: BufferRows<G> = HashMap::new();
283 let mut resolve_order: Vec<G> = Vec::new();
284 let mut group_keys: Vec<EncodedKey> = Vec::new();
285 let mut seen: BTreeSet<G> = BTreeSet::new();
286 for (group, coord) in buckets.keys() {
287 let initial_high_water = meta_loaded.get(group).and_then(|m| m.initial);
288 if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
289 resolve_order.push(group.clone());
290 group_keys.push(row_key(group));
291 }
292 }
293 if group_keys.is_empty() {
294 return Ok(buffer_rows);
295 }
296 let interned = store.intern_groups(&group_keys)?;
297 reifydb_assertions! {
298 let resolved = interned.len();
299 let requested = group_keys.len();
300 assert!(
301 resolved == requested,
302 "intern_groups returned {resolved} groups for {requested} group keys; \
303 the zip below pairs resolve_order with the interned ids by position, so a length \
304 mismatch would silently leave some groups without a buffer_rows entry and route \
305 them through the per-bucket intern fallback, diverging behaviour"
306 );
307 }
308 for ((group, key), (group_id, _)) in resolve_order.into_iter().zip(group_keys).zip(interned) {
309 buffer_rows.insert(group, (group_id, key));
310 }
311 Ok(buffer_rows)
312 }
313
314 fn persist_meta(&mut self, store: &mut dyn StateStore, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
315 persist_batch_meta(store, &mut self.meta, meta_loaded)
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use std::collections::BTreeMap;
322
323 use reifydb_codec::key::encoded::EncodedKey;
324 use reifydb_core::state::store::StateStore;
325 use reifydb_value::{factory::time::at_millis, value::datetime::DateTime};
326
327 use crate::{
328 operator::state::mock::MockStore,
329 window::{
330 accumulator::{WindowAccumulator, mock::SumAccumulator},
331 engine::{
332 AccumulatorEvent, EmitKind,
333 config::WindowEngineConfig,
334 rolling::{RollingBuckets, RollingResult},
335 rolling_incremental::RollingIncrementalEngine,
336 },
337 },
338 };
339
340 fn test_config() -> WindowEngineConfig {
341 WindowEngineConfig::builder().build()
342 }
343
344 fn row_key(group: &u32) -> EncodedKey {
345 EncodedKey::builder().u32(*group).build()
346 }
347
348 fn running_sum(_group: &u32, running: &SumAccumulator, _newest: &i64, _coord: DateTime) -> Option<i64> {
349 running.finalize()
350 }
351
352 #[test]
353 fn buffer_survives_restart_without_running_collision() {
354 let mut store = MockStore::default();
358
359 let mut engine =
360 RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
361 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
362 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(5)]);
363 let published: Vec<RollingResult<u32, i64>> =
364 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
365 assert_eq!(published.len(), 1);
366 assert!(matches!(published[0].kind, EmitKind::Insert));
367 assert_eq!(published[0].value, 5);
368
369 let mut engine =
372 RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
373 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
374 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Remove(5)]);
375 let withdrawn: Vec<RollingResult<u32, i64>> =
376 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
377
378 assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
379 assert!(
380 matches!(withdrawn[0].kind, EmitKind::Remove),
381 "the group emptied under retraction, so the last published row must be withdrawn"
382 );
383 assert_eq!(
384 withdrawn[0].value, 5,
385 "the withdrawn value is reconstructed from the persisted buffer plus running accumulator"
386 );
387 assert_eq!(
388 withdrawn[0].row_number, published[0].row_number,
389 "the withdrawal targets the same row that was published"
390 );
391 }
392
393 #[test]
394 fn a_group_whose_state_was_reclaimed_updates_its_row_rather_than_inserting_a_second() {
395 let mut store = MockStore::default();
399 let mut engine =
400 RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
401
402 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
403 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(5)]);
404 let published: Vec<RollingResult<u32, i64>> =
405 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
406 assert_eq!(published.len(), 1);
407 assert!(matches!(published[0].kind, EmitKind::Insert), "precondition: the group publishes once");
408
409 let group_id = store
410 .lookup_groups(&[row_key(&1)])
411 .unwrap()
412 .into_iter()
413 .next()
414 .unwrap()
415 .expect("precondition: the group is interned");
416 assert!(store.drop_group_data_entries() > 0, "precondition: the sweep must have erased something");
417 assert!(
418 store.contains_row_mapping(group_id, &row_key(&1)),
419 "precondition: the identity half must survive the data phase"
420 );
421
422 let mut engine =
423 RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
424 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
425 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Add(3)]);
426 let republished: Vec<RollingResult<u32, i64>> =
427 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
428
429 assert_eq!(republished.len(), 1);
430 assert_eq!(
431 republished[0].kind,
432 EmitKind::Update,
433 "the published row survived the sweep, so this is an update and not a second insert"
434 );
435 assert_eq!(
436 republished[0].row_number, published[0].row_number,
437 "the woken group keeps the row it published"
438 );
439 }
440
441 #[test]
442 fn buffer_survives_lru_eviction_without_running_collision() {
443 let mut store = MockStore::default();
447 let mut engine =
448 RollingIncrementalEngine::<u32, DateTime, SumAccumulator, SumAccumulator>::new(test_config());
449
450 let mut published_group_1: Vec<RollingResult<u32, i64>> = Vec::new();
451 for group in 1u32..=11u32 {
452 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
453 buckets.insert((group, at_millis(10)), vec![AccumulatorEvent::Add(i64::from(group))]);
454 let out: Vec<RollingResult<u32, i64>> =
455 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
456 if group == 1 {
457 published_group_1 = out;
458 }
459 }
460 assert_eq!(published_group_1.len(), 1);
461 assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
462 assert_eq!(published_group_1[0].value, 1);
463
464 let mut buckets: RollingBuckets<u32, DateTime, i64> = BTreeMap::new();
467 buckets.insert((1u32, at_millis(10)), vec![AccumulatorEvent::Remove(1)]);
468 let withdrawn: Vec<RollingResult<u32, i64>> =
469 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
470
471 assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
472 assert!(
473 matches!(withdrawn[0].kind, EmitKind::Remove),
474 "the evicted group emptied under retraction, so the last published row must be withdrawn"
475 );
476 assert_eq!(
477 withdrawn[0].value, 1,
478 "the withdrawn value is reconstructed from the evicted group's persisted buffer and running"
479 );
480 assert_eq!(
481 withdrawn[0].row_number, published_group_1[0].row_number,
482 "the withdrawal targets the same row that was published for group 1"
483 );
484 }
485}