Skip to main content

reifydb_flow/window/engine/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4pub mod config;
5pub mod rolling;
6pub mod rolling_incremental;
7pub mod rolling_top_k;
8pub mod tumbling;
9pub mod tumbling_carry;
10
11use std::{collections::HashMap, ops::Bound};
12
13use reifydb_codec::{
14	key::{encode_u64_asc, encoded::EncodedKey},
15	row::operator::state::{OperatorState, StateCodec, decode, encode},
16};
17use reifydb_core::{
18	key::{
19		operator::{
20			keyspace::window::{Emit, WindowMeta, WindowMetaSuffix},
21			state::{GroupId, GroupStateKey, IntoGroupStateKey, KeyspaceId, OperatorStateKey},
22		},
23		typed::direction::{Asc, Desc},
24	},
25	metrics::heap::HeapSize,
26	state::{
27		timer::StateStore,
28		typed::{TypedStateStore, typed_key},
29	},
30};
31use reifydb_macro::operator_state;
32use reifydb_value::{
33	Result,
34	util::hash::{Hash128, xxh3_128},
35	value::row_number::RowNumber,
36};
37use tracing::{debug, instrument};
38
39use crate::{
40	operator::{
41		state::seal::coord::Coord,
42		state_access::{get_classified, remove, set},
43	},
44	window::span::{Slot, WindowSpan},
45};
46
47pub enum AccumulatorEvent<Contribution> {
48	Add(Contribution),
49	Remove(Contribution),
50}
51
52fn note_when_expiry_capped(expired: usize, expire_batch: usize) {
53	if expired >= expire_batch {
54		debug!(expired, expire_batch, "window expiry hit per-tick batch cap, backlog deferred to next tick");
55	}
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum EmitKind {
60	Insert,
61	Update,
62	Remove,
63}
64
65pub struct WindowResult<G, Coord, Output> {
66	pub row_number: RowNumber,
67	pub group: G,
68	pub span: WindowSpan<Coord>,
69	pub value: Output,
70	pub prior: Option<Output>,
71	pub kind: EmitKind,
72}
73
74#[operator_state]
75#[derive(Debug, Clone)]
76pub struct GroupMeta<S> {
77	pub high_water: Option<S>,
78}
79
80impl<S> Default for GroupMeta<S> {
81	fn default() -> Self {
82		Self {
83			high_water: None,
84		}
85	}
86}
87
88impl<S> HeapSize for GroupMeta<S> {
89	fn heap_size(&self) -> usize {
90		0
91	}
92}
93
94pub(crate) trait MetaHighWater: OperatorState {
95	fn high_water_order(&self) -> Option<u64>;
96}
97
98impl<S: Slot> MetaHighWater for GroupMeta<S> {
99	fn high_water_order(&self) -> Option<u64> {
100		self.high_water.map(|hw| hw.order_key().to_order())
101	}
102}
103
104pub(crate) struct BatchMeta<S> {
105	pub(crate) initial: Option<S>,
106	pub(crate) bumped: Option<S>,
107}
108
109impl<S> Default for BatchMeta<S> {
110	fn default() -> Self {
111		Self {
112			initial: None,
113			bumped: None,
114		}
115	}
116}
117
118impl<S: Slot> BatchMeta<S> {
119	pub(crate) fn observe(&mut self, slot: S) {
120		match self.high_water() {
121			Some(hw) if slot > hw => self.bumped = Some(slot),
122			None => self.bumped = Some(slot),
123			_ => {}
124		}
125	}
126
127	pub(crate) fn high_water(&self) -> Option<S> {
128		self.bumped.or(self.initial)
129	}
130}
131
132pub(crate) fn load_batch_meta<S>(store: &mut dyn StateStore, key: &MetaKey) -> Result<BatchMeta<S>>
133where
134	S: Slot,
135{
136	let initial = get_classified::<_, GroupMeta<S>>(store, key)?.and_then(|meta| meta.high_water);
137	Ok(BatchMeta {
138		initial,
139		bumped: None,
140	})
141}
142
143pub(crate) fn persist_batch_meta<G, S>(store: &mut dyn StateStore, loaded: HashMap<G, BatchMeta<S>>) -> Result<()>
144where
145	G: StateCodec,
146	S: Slot,
147{
148	for (group, batch) in loaded {
149		let Some(bumped) = batch.bumped else {
150			continue;
151		};
152		set(
153			store,
154			&meta_key_for(group_hash(&group)?),
155			&GroupMeta {
156				high_water: Some(bumped),
157			},
158		)?;
159	}
160	Ok(())
161}
162
163const META_SWEEP_PAGE: usize = 1024;
164
165#[derive(Default)]
166pub(crate) struct MetaSweep {
167	low_water: Option<u64>,
168	cursor: Option<WindowMetaSuffix>,
169	surviving: Option<u64>,
170}
171
172impl MetaSweep {
173	#[instrument(name = "flow::window::sweep_stale_meta", level = "debug", skip_all)]
174	pub(crate) fn sweep<M>(&mut self, store: &mut dyn StateStore, threshold: u64) -> Result<usize>
175	where
176		M: MetaHighWater + Clone + OperatorState + HeapSize,
177	{
178		if self.cursor.is_none() && self.low_water.is_some_and(|lw| lw >= threshold) {
179			return Ok(0);
180		}
181		let cursor = self.cursor.take();
182		let page = store.state_scan_in::<WindowMeta>(
183			GroupId::ROOT,
184			match &cursor {
185				Some(key) => Bound::Excluded(key),
186				None => Bound::Unbounded,
187			},
188			Some(META_SWEEP_PAGE),
189		)?;
190		let visited = page.len();
191		let mut stale: Vec<MetaKey> = Vec::new();
192		let mut surviving = self.surviving;
193		let mut furthest: Option<WindowMetaSuffix> = None;
194		for (suffix, bytes) in page {
195			if let Some(hw) = decode::<M>(&bytes)?.high_water_order() {
196				if hw < threshold {
197					stale.push(MetaKey(suffix));
198				} else {
199					surviving = Some(surviving.map_or(hw, |m| m.min(hw)));
200				}
201			}
202			furthest = Some(suffix);
203		}
204		if visited < META_SWEEP_PAGE {
205			self.low_water = surviving;
206			self.surviving = None;
207		} else {
208			self.low_water = None;
209			self.surviving = surviving;
210			self.cursor = furthest;
211		}
212		let count = stale.len();
213		for key in &stale {
214			remove(store, key)?;
215		}
216		Ok(count)
217	}
218}
219
220#[derive(Clone, Hash, PartialEq, Eq)]
221pub struct MetaKey(pub WindowMetaSuffix);
222
223impl HeapSize for MetaKey {
224	fn heap_size(&self) -> usize {
225		self.0.heap_size()
226	}
227}
228
229#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
230pub enum KeyspaceFamily {
231	Host,
232	Guest,
233}
234
235impl KeyspaceFamily {
236	fn keyspace(&self, host: KeyspaceId, guest: KeyspaceId) -> KeyspaceId {
237		match self {
238			Self::Host => host,
239			Self::Guest => guest,
240		}
241	}
242
243	fn suffix(&self, slot: &EncodedKey) -> Vec<u8> {
244		match self {
245			Self::Host => slot.as_bytes().to_vec(),
246			Self::Guest => xxh3_128(slot.as_bytes()).0.to_be_bytes().to_vec(),
247		}
248	}
249}
250
251#[derive(Clone, Hash, PartialEq, Eq)]
252pub struct RunningKey {
253	pub family: KeyspaceFamily,
254	pub group: GroupId,
255	pub slot: EncodedKey,
256}
257
258impl RunningKey {
259	pub fn new(family: KeyspaceFamily, group: GroupId, slot: EncodedKey) -> Self {
260		Self {
261			family,
262			group,
263			slot,
264		}
265	}
266
267	pub fn of_row(family: KeyspaceFamily, group: GroupId, row: RowNumber) -> Self {
268		Self::new(family, group, EncodedKey::new(encode_u64_asc(row.0)))
269	}
270}
271
272impl HeapSize for RunningKey {
273	fn heap_size(&self) -> usize {
274		self.slot.heap_size()
275	}
276}
277
278impl IntoGroupStateKey for &RunningKey {
279	fn into_group_state_key(self) -> GroupStateKey {
280		OperatorStateKey::inner_encoded(
281			self.group,
282			self.family.keyspace(KeyspaceId::RUNNING, KeyspaceId::GUEST_RUNNING),
283			self.family.suffix(&self.slot),
284		)
285	}
286}
287
288#[derive(Clone, Hash, PartialEq, Eq)]
289pub struct WindowStateKey {
290	pub family: KeyspaceFamily,
291	pub group: GroupId,
292	pub slot: EncodedKey,
293}
294
295impl WindowStateKey {
296	pub fn new(family: KeyspaceFamily, group: GroupId, slot: EncodedKey) -> Self {
297		Self {
298			family,
299			group,
300			slot,
301		}
302	}
303
304	pub fn of_row(family: KeyspaceFamily, group: GroupId, row: RowNumber) -> Self {
305		Self::new(family, group, EncodedKey::new(encode_u64_asc(row.0)))
306	}
307}
308
309impl HeapSize for WindowStateKey {
310	fn heap_size(&self) -> usize {
311		self.slot.heap_size()
312	}
313}
314
315impl IntoGroupStateKey for &WindowStateKey {
316	fn into_group_state_key(self) -> GroupStateKey {
317		OperatorStateKey::inner_encoded(
318			self.group,
319			self.family.keyspace(KeyspaceId::ACCUMULATOR, KeyspaceId::GUEST_ACCUMULATOR),
320			self.family.suffix(&self.slot),
321		)
322	}
323}
324
325#[derive(Clone, Hash, PartialEq, Eq)]
326pub struct BufferKey {
327	pub family: KeyspaceFamily,
328	pub group: GroupId,
329	pub slot: EncodedKey,
330}
331
332impl BufferKey {
333	pub fn new(family: KeyspaceFamily, group: GroupId, slot: EncodedKey) -> Self {
334		Self {
335			family,
336			group,
337			slot,
338		}
339	}
340
341	pub fn of_row(family: KeyspaceFamily, group: GroupId, row: RowNumber) -> Self {
342		Self::new(family, group, EncodedKey::new(encode_u64_asc(row.0)))
343	}
344}
345
346impl HeapSize for BufferKey {
347	fn heap_size(&self) -> usize {
348		self.slot.heap_size()
349	}
350}
351
352impl IntoGroupStateKey for &BufferKey {
353	fn into_group_state_key(self) -> GroupStateKey {
354		OperatorStateKey::inner_encoded(
355			self.group,
356			self.family.keyspace(KeyspaceId::BUFFER, KeyspaceId::GUEST_BUFFER),
357			self.family.suffix(&self.slot),
358		)
359	}
360}
361
362#[derive(Clone, Copy, Hash, PartialEq, Eq)]
363pub struct EmitKey {
364	pub group: GroupId,
365	pub row: RowNumber,
366}
367
368impl EmitKey {
369	pub fn new(group: GroupId, row: RowNumber) -> Self {
370		Self {
371			group,
372			row,
373		}
374	}
375}
376
377impl HeapSize for EmitKey {
378	fn heap_size(&self) -> usize {
379		0
380	}
381}
382
383impl IntoGroupStateKey for &EmitKey {
384	fn into_group_state_key(self) -> GroupStateKey {
385		typed_key::<Emit>(self.group, &Asc(self.row))
386	}
387}
388
389impl IntoGroupStateKey for &MetaKey {
390	fn into_group_state_key(self) -> GroupStateKey {
391		typed_key::<WindowMeta>(GroupId::ROOT, &self.0)
392	}
393}
394
395pub(crate) fn group_hash<G: StateCodec>(group: &G) -> Result<Hash128> {
396	Ok(xxh3_128(encode(group)?.body()))
397}
398
399pub fn meta_key_for(group: Hash128) -> MetaKey {
400	MetaKey(WindowMetaSuffix {
401		window: Desc(group),
402	})
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum ExpiryAnchor {
407	Unindexed,
408	WindowStart,
409	LastEvent,
410}
411
412impl ExpiryAnchor {
413	pub fn of(&self, window_start: u64, last_event: Option<u64>) -> Option<u64> {
414		match self {
415			ExpiryAnchor::Unindexed => None,
416			ExpiryAnchor::WindowStart => Some(window_start),
417			ExpiryAnchor::LastEvent => last_event,
418		}
419	}
420}
421
422#[cfg(test)]
423mod archived_projection_tests {
424	use reifydb_value::value::datetime::DateTime;
425
426	use super::*;
427
428	/// Projects the high water the way `sweep_stale_meta` does: encode, decode, then read it.
429	fn via_storage<M: MetaHighWater>(meta: &M) -> Option<u64> {
430		let bytes = meta.encode_state().unwrap();
431		decode::<M>(&bytes).unwrap().high_water_order()
432	}
433
434	#[test]
435	fn stored_high_water_yields_the_slot_order_key() {
436		// A wrong order key silently drops a live group's meta or keeps dead meta forever.
437		let instant = DateTime::from_epoch_millis(1_700_000_000_123).unwrap();
438		let datetime_meta = GroupMeta {
439			high_water: Some(instant),
440		};
441		assert_eq!(
442			via_storage(&datetime_meta),
443			Some(instant.to_order()),
444			"the order key is the stored layout, so the projection must round-trip exactly"
445		);
446
447		// Adjacent representable instants must keep distinct order keys, or a live group is swept with a dead
448		// one.
449		let next_instant = GroupMeta {
450			high_water: Some(DateTime::from_bits(instant.to_bits() + 1)),
451		};
452		assert!(via_storage(&next_instant) > via_storage(&datetime_meta));
453	}
454
455	#[test]
456	fn a_group_that_never_advanced_projects_to_none_through_storage() {
457		// An accidental Some(0) compares below every threshold and reclaims meta for live groups.
458		let empty: GroupMeta<DateTime> = GroupMeta {
459			high_water: None,
460		};
461		assert_eq!(via_storage(&empty), None);
462	}
463}
464
465#[cfg(test)]
466mod meta_sweep_tests {
467	use reifydb_value::{factory::time::at_millis, value::datetime::DateTime};
468
469	use super::*;
470	use crate::operator::state::mock::MockStore;
471
472	fn meta_key(index: u32) -> MetaKey {
473		// Built through the production constructor so the fixture can never seed a key shape the
474		// typed sweep would refuse to read back.
475		meta_key_for(Hash128::from(index as u128))
476	}
477
478	fn seed(store: &mut MockStore, count: u32, high_water: impl Fn(u32) -> DateTime) {
479		for index in 0..count {
480			set(
481				store,
482				&meta_key(index),
483				&GroupMeta {
484					high_water: Some(high_water(index)),
485				},
486			)
487			.expect("seeding a group meta must succeed");
488		}
489	}
490
491	#[test]
492	fn a_meta_sweep_stops_at_one_page_and_resumes_past_its_cursor() {
493		// The sweep runs on every window apply over a keyspace that grows with the group count, so
494		// one call must never walk more than a page. Without the parked cursor the next call
495		// restarts at the first key and the tail is never reached at all.
496		let mut store = MockStore::default();
497		let total = META_SWEEP_PAGE as u32 + 3;
498		seed(&mut store, total, |_| at_millis(200));
499
500		let mut sweep = MetaSweep::default();
501		let threshold = at_millis(50).to_order();
502
503		assert_eq!(sweep.sweep::<GroupMeta<DateTime>>(&mut store, threshold).unwrap(), 0);
504		assert_eq!(store.rows_visited(), META_SWEEP_PAGE, "one call must visit at most one page");
505
506		assert_eq!(sweep.sweep::<GroupMeta<DateTime>>(&mut store, threshold).unwrap(), 0);
507		assert_eq!(
508			store.rows_visited(),
509			total as usize,
510			"the next call must resume past the cursor rather than rescan the first page"
511		);
512	}
513
514	#[test]
515	fn a_paged_meta_sweep_publishes_the_low_water_of_every_page_it_walked() {
516		// The low-water guard skips the whole scan while the smallest surviving high water is at or
517		// above the threshold. A pass that spans several pages must fold every page into that
518		// minimum: publishing only the final page's minimum makes the guard skip a group that has
519		// since gone stale, and its meta then leaks forever.
520		let mut store = MockStore::default();
521		let total = META_SWEEP_PAGE as u32 + 3;
522		seed(&mut store, total, |index| {
523			if index == 0 {
524				at_millis(100)
525			} else {
526				at_millis(200)
527			}
528		});
529
530		let mut sweep = MetaSweep::default();
531		let early = at_millis(50).to_order();
532		assert_eq!(sweep.sweep::<GroupMeta<DateTime>>(&mut store, early).unwrap(), 0);
533		assert_eq!(sweep.sweep::<GroupMeta<DateTime>>(&mut store, early).unwrap(), 0);
534
535		let walked = store.rows_visited();
536		assert_eq!(sweep.sweep::<GroupMeta<DateTime>>(&mut store, at_millis(90).to_order()).unwrap(), 0);
537		assert_eq!(
538			store.rows_visited(),
539			walked,
540			"a completed revolution must publish a low water the guard can skip on"
541		);
542
543		// Driven to the end of the revolution rather than one page: which page holds the stale group
544		// is a property of the key order, and the guarantee under test is that a revolution reaches it.
545		let mut reclaimed = 0;
546		for _ in 0..2 {
547			reclaimed += sweep.sweep::<GroupMeta<DateTime>>(&mut store, at_millis(150).to_order()).unwrap();
548		}
549		assert_eq!(
550			reclaimed, 1,
551			"the group whose high water sits below the threshold is stale and must be reclaimed"
552		);
553	}
554}