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