1use std::{
5 collections::{BTreeMap, BTreeSet, HashMap},
6 fmt::Debug,
7 hash::Hash,
8 marker::PhantomData,
9};
10
11use reifydb_codec::key::encoded::{EncodedKey, IntoEncodedKey};
12use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
13use serde::{Serialize, de::DeserializeOwned};
14
15use crate::window::{
16 accumulator::WindowAccumulator,
17 engine::{
18 AccumulatorEvent, EmitKind, GroupMeta, MetaKey, RunningKey, WindowStateKey,
19 config::WindowEngineConfig,
20 meta_key_for,
21 rolling::{RollingBuckets, RollingBuffer, RollingResult},
22 },
23 span::Slot,
24 state::StateCache,
25 store::WindowStore,
26};
27
28type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
29type BufferRows<G> = HashMap<G, (RowNumber, bool)>;
30
31struct GroupSlot<C, Accumulator, Running, Output> {
32 row_number: RowNumber,
33 is_new: bool,
34 buffer: RollingBuffer<C, Accumulator>,
35 running: Running,
36 was_empty_before: bool,
37 buffer_changed: bool,
38 prior_output: Option<Output>,
39}
40
41pub struct RollingIncrementalEngine<G, C, Accumulator, Running> {
42 buffers: StateCache<WindowStateKey, RollingBuffer<C, Accumulator>>,
43 running: StateCache<RunningKey, Running>,
44 meta: StateCache<MetaKey, GroupMeta<C>>,
45 _pd: PhantomData<G>,
46}
47
48impl<G, C, Accumulator, Running> RollingIncrementalEngine<G, C, Accumulator, Running>
49where
50 G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
51 C: Slot + Hash + Serialize + DeserializeOwned,
52 Accumulator: WindowAccumulator,
53 Running: WindowAccumulator,
54 for<'a> &'a G: IntoEncodedKey,
55{
56 pub fn new(config: WindowEngineConfig) -> Self {
57 Self {
58 buffers: StateCache::<WindowStateKey, RollingBuffer<C, Accumulator>>::new_internal(
59 config.state_cache_capacity(),
60 ),
61 running: StateCache::<RunningKey, Running>::new_internal(config.state_cache_capacity()),
62 meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
63 _pd: PhantomData,
64 }
65 }
66
67 pub fn apply<S, K, WC, CR, Output>(
68 &mut self,
69 store: &mut S,
70 buckets: RollingBuckets<G, C, Accumulator::Contribution>,
71 capacity: usize,
72 row_key: K,
73 window_contribution: WC,
74 combine_running: CR,
75 ) -> Result<Vec<RollingResult<G, Output>>>
76 where
77 S: WindowStore,
78 K: Fn(&G) -> EncodedKey,
79 WC: Fn(&Accumulator::Output) -> Running::Contribution,
80 CR: Fn(&G, &Running, &Accumulator::Output, C) -> Option<Output>,
81 {
82 if buckets.is_empty() {
83 return Ok(Vec::new());
84 }
85 let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
86 let buffer_rows = self.resolve_buffer_rows(store, &buckets, &meta_loaded, &row_key)?;
87
88 let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, Running, Output>> = BTreeMap::new();
89
90 for ((group, coord), events) in buckets {
91 let meta = meta_loaded.entry(group.clone()).or_default();
92
93 let slot = match group_slots.get_mut(&group) {
94 Some(s) => s,
95 None => {
96 let (row_number, is_new) = match buffer_rows.get(&group) {
97 Some(&resolved) => resolved,
98 None => {
99 let key = row_key(&group);
100 store.get_or_create_row_number(&key)?
101 }
102 };
103 let buffer: RollingBuffer<C, Accumulator> = self
104 .buffers
105 .get(store, &WindowStateKey(row_number))?
106 .unwrap_or_default();
107 let running: Running =
108 self.running.get(store, &RunningKey(row_number))?.unwrap_or_default();
109 let was_empty_before = buffer.is_empty();
110 let prior_output = match buffer.iter().next_back() {
111 Some((coord, accumulator)) => {
112 accumulator.finalize().and_then(|newest| {
113 combine_running(&group, &running, &newest, *coord)
114 })
115 }
116 None => None,
117 };
118 group_slots.insert(
119 group.clone(),
120 GroupSlot {
121 row_number,
122 is_new,
123 buffer,
124 running,
125 was_empty_before,
126 buffer_changed: false,
127 prior_output,
128 },
129 );
130 group_slots.get_mut(&group).expect("just inserted")
131 }
132 };
133
134 let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
135 let old_value = accumulator.finalize();
136 let mut touched = false;
137 for event in events {
138 match event {
139 AccumulatorEvent::Add(c) => {
140 accumulator.add(&c);
141 touched = true;
142 }
143 AccumulatorEvent::Remove(c) => {
144 if accumulator.is_empty() {
145 continue;
146 }
147 accumulator.remove(&c);
148 touched = true;
149 }
150 }
151 }
152 if !touched {
153 continue;
154 }
155 let new_value = accumulator.finalize();
156
157 if let Some(old) = &old_value {
158 slot.running.remove(&window_contribution(old));
159 }
160 if let Some(new) = &new_value {
161 slot.running.add(&window_contribution(new));
162 }
163
164 if !accumulator.is_empty() {
165 slot.buffer.insert(coord, accumulator);
166 }
167 while slot.buffer.len() > capacity {
168 if let Some((_, evicted)) = slot.buffer.pop_first()
169 && let Some(value) = evicted.finalize()
170 {
171 slot.running.remove(&window_contribution(&value));
172 }
173 }
174 slot.buffer_changed = true;
175
176 meta.high_water = Some(match meta.high_water {
177 Some(hw) if hw > coord => hw,
178 _ => coord,
179 });
180 }
181
182 let mut results: Vec<RollingResult<G, Output>> = Vec::new();
183 for (group, slot) in group_slots {
184 if !slot.buffer_changed {
185 continue;
186 }
187 let output = match slot.buffer.iter().next_back() {
188 Some((coord, accumulator)) => accumulator
189 .finalize()
190 .and_then(|newest| combine_running(&group, &slot.running, &newest, *coord)),
191 None => None,
192 };
193 self.buffers.put(store, &WindowStateKey(slot.row_number), slot.buffer)?;
194 self.running.put(store, &RunningKey(slot.row_number), slot.running)?;
195
196 if let Some(out) = output {
197 let kind = if slot.is_new || slot.was_empty_before {
198 EmitKind::Insert
199 } else {
200 EmitKind::Update
201 };
202 results.push(RollingResult {
203 row_number: slot.row_number,
204 group,
205 value: out,
206 prior: None,
207 kind,
208 });
209 } else if let Some(prior) = slot.prior_output {
210 results.push(RollingResult {
211 row_number: slot.row_number,
212 group,
213 value: prior,
214 prior: None,
215 kind: EmitKind::Remove,
216 });
217 }
218 }
219 self.persist_meta(store, meta_loaded)?;
220 Ok(results)
221 }
222
223 pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
224 self.buffers.flush(store)?;
225 self.running.flush(store)?;
226 self.meta.flush(store)?;
227 Ok(())
228 }
229
230 fn warm_and_load_meta<S: WindowStore>(
231 &mut self,
232 store: &mut S,
233 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
234 ) -> Result<MetaLoaded<G, C>> {
235 let meta_keys: Vec<MetaKey> = buckets
236 .keys()
237 .map(|(group, _)| group)
238 .collect::<BTreeSet<_>>()
239 .into_iter()
240 .map(meta_key_for)
241 .collect();
242 self.meta.warm(store, &meta_keys)?;
243
244 let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
245 for (group, _) in buckets.keys() {
246 if !meta_loaded.contains_key(group) {
247 let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
248 meta_loaded.insert(group.clone(), m);
249 }
250 }
251 Ok(meta_loaded)
252 }
253
254 fn resolve_buffer_rows<S, K>(
255 &mut self,
256 store: &mut S,
257 buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
258 meta_loaded: &MetaLoaded<G, C>,
259 row_key: &K,
260 ) -> Result<BufferRows<G>>
261 where
262 S: WindowStore,
263 K: Fn(&G) -> EncodedKey,
264 {
265 let mut buffer_rows: BufferRows<G> = HashMap::new();
266 let mut resolve_order: Vec<G> = Vec::new();
267 let mut group_keys: Vec<EncodedKey> = Vec::new();
268 let mut seen: BTreeSet<G> = BTreeSet::new();
269 for (group, coord) in buckets.keys() {
270 let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
271 if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
272 resolve_order.push(group.clone());
273 group_keys.push(row_key(group));
274 }
275 }
276 let resolved_rows = store.get_or_create_row_numbers(&group_keys)?;
277 reifydb_assertions! {
278 let resolved = resolved_rows.len();
279 let requested = group_keys.len();
280 assert!(
281 resolved == requested,
282 "get_or_create_row_numbers returned {resolved} rows for {requested} group keys; \
283 the zip below pairs resolve_order with resolved_rows by position, so a length \
284 mismatch would silently leave some groups without a buffer_rows entry and route \
285 them through the per-bucket get_or_create_row_number fallback, diverging behaviour"
286 );
287 }
288 let state_keys: Vec<RowNumber> = resolved_rows.iter().map(|(rn, _)| *rn).collect();
289 let buffer_keys: Vec<WindowStateKey> = state_keys.iter().map(|rn| WindowStateKey(*rn)).collect();
290 let running_keys: Vec<RunningKey> = state_keys.iter().map(|rn| RunningKey(*rn)).collect();
291 for (group, resolved) in resolve_order.into_iter().zip(resolved_rows) {
292 buffer_rows.insert(group, resolved);
293 }
294 self.buffers.warm(store, &buffer_keys)?;
295 self.running.warm(store, &running_keys)?;
296 Ok(buffer_rows)
297 }
298
299 fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
300 for (group, meta) in meta_loaded {
301 self.meta.set(store, &meta_key_for(&group), &meta)?;
302 }
303 Ok(())
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use std::collections::BTreeMap;
310
311 use reifydb_codec::key::encoded::EncodedKey;
312
313 use crate::window::{
314 accumulator::WindowAccumulator,
315 engine::{
316 AccumulatorEvent, EmitKind,
317 config::WindowEngineConfig,
318 rolling::{RollingBuckets, RollingResult},
319 rolling_incremental::RollingIncrementalEngine,
320 test_support::{MockStore, SumAccumulator},
321 },
322 };
323
324 fn test_config() -> WindowEngineConfig {
325 WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
326 }
327
328 fn row_key(group: &u32) -> EncodedKey {
329 EncodedKey::builder().u32(*group).build()
330 }
331
332 fn running_sum(_group: &u32, running: &SumAccumulator, _newest: &i64, _coord: u64) -> Option<i64> {
333 running.finalize()
334 }
335
336 #[test]
337 fn buffer_survives_restart_without_running_collision() {
338 let mut store = MockStore::default();
349
350 let mut engine =
351 RollingIncrementalEngine::<u32, u64, SumAccumulator, SumAccumulator>::new(test_config());
352 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
353 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
354 let published: Vec<RollingResult<u32, i64>> =
355 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
356 engine.flush(&mut store).unwrap();
357 assert_eq!(published.len(), 1);
358 assert!(matches!(published[0].kind, EmitKind::Insert));
359 assert_eq!(published[0].value, 5);
360
361 let mut engine =
364 RollingIncrementalEngine::<u32, u64, SumAccumulator, SumAccumulator>::new(test_config());
365 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
366 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
367 let withdrawn: Vec<RollingResult<u32, i64>> =
368 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
369 engine.flush(&mut store).unwrap();
370
371 assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
372 assert!(
373 matches!(withdrawn[0].kind, EmitKind::Remove),
374 "the group emptied under retraction, so the last published row must be withdrawn"
375 );
376 assert_eq!(
377 withdrawn[0].value, 5,
378 "the withdrawn value is reconstructed from the persisted buffer plus running accumulator"
379 );
380 assert_eq!(
381 withdrawn[0].row_number, published[0].row_number,
382 "the withdrawal targets the same row that was published"
383 );
384 }
385
386 #[test]
387 fn buffer_survives_lru_eviction_without_running_collision() {
388 let mut store = MockStore::default();
395 let mut engine =
396 RollingIncrementalEngine::<u32, u64, SumAccumulator, SumAccumulator>::new(test_config());
397
398 let mut published_group_1: Vec<RollingResult<u32, i64>> = Vec::new();
399 for group in 1u32..=11u32 {
400 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
401 buckets.insert((group, 10u64), vec![AccumulatorEvent::Add(i64::from(group))]);
402 let out: Vec<RollingResult<u32, i64>> =
403 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
404 if group == 1 {
405 published_group_1 = out;
406 }
407 }
408 engine.flush(&mut store).unwrap();
409 assert_eq!(published_group_1.len(), 1);
410 assert!(matches!(published_group_1[0].kind, EmitKind::Insert));
411 assert_eq!(published_group_1[0].value, 1);
412
413 let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
416 buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(1)]);
417 let withdrawn: Vec<RollingResult<u32, i64>> =
418 engine.apply(&mut store, buckets, 4, row_key, |v: &i64| *v, running_sum).unwrap();
419 engine.flush(&mut store).unwrap();
420
421 assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
422 assert!(
423 matches!(withdrawn[0].kind, EmitKind::Remove),
424 "the evicted group emptied under retraction, so the last published row must be withdrawn"
425 );
426 assert_eq!(
427 withdrawn[0].value, 1,
428 "the withdrawn value is reconstructed from the evicted group's persisted buffer and running"
429 );
430 assert_eq!(
431 withdrawn[0].row_number, published_group_1[0].row_number,
432 "the withdrawal targets the same row that was published for group 1"
433 );
434 }
435}