Skip to main content

reifydb_core/
operator_with.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{collections::HashMap, sync::Arc};
5
6use reifydb_codec::value::{decode_params, encode_params};
7use reifydb_value::{
8	Result,
9	params::Params,
10	value::{Value, duration::Duration},
11};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15	common::{WindowKind, WindowSize},
16	error::CoreError,
17	internal_error,
18	row::{JoinPick, JoinRetention, OperatorRetention},
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum WithSpan {
23	Duration(Duration),
24	Count(u64),
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct WindowWith {
29	pub kind: WindowKind,
30	pub lateness: Option<Duration>,
31	pub immutable: Option<Duration>,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
35pub struct JoinWith {
36	pub retention: Option<JoinRetention>,
37	pub snapshot: bool,
38	pub pick: Option<JoinPick>,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct LookupWith {
43	pub retention: Option<OperatorRetention>,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ApplyWith {
48	pub window: Option<WindowKind>,
49	pub lateness: Option<WithSpan>,
50	pub immutable: Option<WithSpan>,
51	pub retention: Option<Duration>,
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct AggregateWith {}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct DistinctWith {}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct WindowSealing {
62	pub lateness: Option<Duration>,
63	pub immutable: Option<Duration>,
64}
65
66impl WindowSealing {
67	pub fn from_operator_with(with: &ApplyWith) -> Result<Self> {
68		let sealing = Self {
69			lateness: duration_span("lateness", with.lateness)?,
70			immutable: duration_span("immutable", with.immutable)?,
71		};
72		if let (Some(lateness), Some(immutable)) = (sealing.lateness, sealing.immutable)
73			&& immutable >= lateness
74		{
75			return Err(CoreError::OperatorWithImmutableNotBelowLateness {
76				immutable,
77				lateness,
78			}
79			.into());
80		}
81		Ok(sealing)
82	}
83}
84
85impl ApplyWith {
86	pub fn window_duration(&self) -> Result<Duration> {
87		let Some(kind) = &self.window else {
88			return Err(CoreError::OperatorWithWindowMissing.into());
89		};
90		match kind.size() {
91			None => Err(CoreError::OperatorWithWindowKindUnsupported {
92				kind: kind.name(),
93				supported: "tumbling, sliding or rolling".to_string(),
94			}
95			.into()),
96			Some(WindowSize::Duration(d)) => Ok(*d),
97			Some(WindowSize::Count(count)) => Err(CoreError::OperatorWithWindowSizeCount {
98				count: *count,
99			}
100			.into()),
101		}
102	}
103
104	pub fn window_slots(&self) -> Result<u64> {
105		let Some(kind) = &self.window else {
106			return Err(CoreError::OperatorWithWindowMissing.into());
107		};
108		match kind.size() {
109			None => Err(CoreError::OperatorWithWindowKindUnsupported {
110				kind: kind.name(),
111				supported: "tumbling, sliding or rolling".to_string(),
112			}
113			.into()),
114			Some(WindowSize::Count(n)) => Ok(*n),
115			Some(WindowSize::Duration(d)) => Err(CoreError::OperatorWithWindowSizeDuration {
116				size: *d,
117			}
118			.into()),
119		}
120	}
121
122	pub fn window_slide_duration(&self) -> Result<Option<Duration>> {
123		match &self.window {
124			Some(WindowKind::Sliding {
125				slide,
126				..
127			}) => match slide {
128				WindowSize::Duration(d) => Ok(Some(*d)),
129				WindowSize::Count(count) => Err(CoreError::OperatorWithWindowSlideCount {
130					count: *count,
131				}
132				.into()),
133			},
134			_ => Ok(None),
135		}
136	}
137
138	pub fn window_slide_slots(&self) -> Result<Option<u64>> {
139		match &self.window {
140			Some(WindowKind::Sliding {
141				slide,
142				..
143			}) => match slide {
144				WindowSize::Count(n) => Ok(Some(*n)),
145				WindowSize::Duration(d) => Err(CoreError::OperatorWithWindowSlideDuration {
146					slide: *d,
147				}
148				.into()),
149			},
150			_ => Ok(None),
151		}
152	}
153
154	pub fn window_session_gap(&self) -> Option<Duration> {
155		match &self.window {
156			Some(WindowKind::Session {
157				gap,
158			}) => Some(*gap),
159			_ => None,
160		}
161	}
162
163	pub fn check_session_window(&self) -> Result<()> {
164		let Some(gap) = self.window_session_gap() else {
165			return Ok(());
166		};
167		if !gap.is_positive() {
168			return Err(CoreError::OperatorWithSessionZeroGap.into());
169		}
170		match self.lateness_duration()? {
171			Some(lateness) if lateness.is_positive() => Err(CoreError::OperatorWithSessionLateness {
172				lateness,
173			}
174			.into()),
175			_ => Ok(()),
176		}
177	}
178
179	pub fn effective_retention(&self) -> Result<Option<Duration>> {
180		match self.retention {
181			Some(retention) => Ok(Some(retention)),
182			None => self.lateness_duration(),
183		}
184	}
185
186	pub fn check_retention(&self) -> Result<()> {
187		let (Some(retention), Some(lateness)) = (self.retention, self.lateness_duration()?) else {
188			return Ok(());
189		};
190		if retention < lateness {
191			return Err(CoreError::OperatorWithRetentionBelowLateness {
192				retention,
193				lateness,
194			}
195			.into());
196		}
197		Ok(())
198	}
199
200	pub fn reject_retention(&self) -> Result<()> {
201		match self.retention {
202			None => Ok(()),
203			Some(_) => Err(CoreError::OperatorWithRetentionNotSupported.into()),
204		}
205	}
206	pub fn reject_window(&self) -> Result<()> {
207		match &self.window {
208			None => Ok(()),
209			Some(kind) => Err(CoreError::OperatorWithWindowNotSupported {
210				kind: kind.name(),
211			}
212			.into()),
213		}
214	}
215
216	pub fn require_window(&self, supported: &'static str) -> Result<&WindowKind> {
217		let Some(kind) = &self.window else {
218			return Err(CoreError::OperatorWithWindowMissing.into());
219		};
220		if kind.name() != supported {
221			return Err(CoreError::OperatorWithWindowKindUnsupported {
222				kind: kind.name(),
223				supported: supported.to_string(),
224			}
225			.into());
226		}
227		Ok(kind)
228	}
229
230	pub fn lateness_duration(&self) -> Result<Option<Duration>> {
231		match self.lateness {
232			None => Ok(None),
233			Some(WithSpan::Duration(d)) => Ok(Some(d)),
234			Some(WithSpan::Count(count)) => Err(CoreError::OperatorWithCountSpan {
235				key: "lateness",
236				count,
237			}
238			.into()),
239		}
240	}
241
242	pub fn lateness_count(&self) -> Result<Option<u64>> {
243		match self.lateness {
244			None => Ok(None),
245			Some(WithSpan::Count(n)) => Ok(Some(n)),
246			Some(WithSpan::Duration(duration)) => Err(CoreError::OperatorWithDurationSpan {
247				key: "lateness",
248				duration,
249			}
250			.into()),
251		}
252	}
253
254	pub fn immutable_count(&self) -> Result<Option<u64>> {
255		match self.immutable {
256			None => Ok(None),
257			Some(WithSpan::Count(n)) => Ok(Some(n)),
258			Some(WithSpan::Duration(duration)) => Err(CoreError::OperatorWithDurationSpan {
259				key: "immutable",
260				duration,
261			}
262			.into()),
263		}
264	}
265}
266
267pub fn encode_apply_with(with: &ApplyWith) -> Result<Vec<u8>> {
268	let mut values = HashMap::new();
269	if let Some(window) = &with.window {
270		values.insert("window".to_string(), Value::Utf8(window.name().to_string()));
271		match window {
272			WindowKind::Tumbling {
273				size,
274			} => insert_window_size(&mut values, size),
275			WindowKind::Sliding {
276				size,
277				slide,
278			} => {
279				insert_window_size(&mut values, size);
280				values.insert("slide".to_string(), window_size_value(slide));
281			}
282			WindowKind::Rolling {
283				size,
284				lag,
285				pane,
286			} => {
287				insert_window_size(&mut values, size);
288				if let Some(lag) = lag {
289					values.insert("lag".to_string(), Value::Duration(*lag));
290				}
291				if let Some(pane) = pane {
292					values.insert("pane".to_string(), Value::Duration(*pane));
293				}
294			}
295			WindowKind::Session {
296				gap,
297			} => {
298				values.insert("gap".to_string(), Value::Duration(*gap));
299			}
300		}
301	}
302	if let Some(lateness) = with.lateness {
303		values.insert("lateness".to_string(), span_value(lateness));
304	}
305	if let Some(immutable) = with.immutable {
306		values.insert("immutable".to_string(), span_value(immutable));
307	}
308	if let Some(retention) = with.retention {
309		values.insert("retention".to_string(), Value::Duration(retention));
310	}
311	encode_params(&Params::Named(Arc::new(values)))
312		.map_err(|e| internal_error!("failed to encode apply with: {}", e))
313}
314
315pub fn decode_apply_with(bytes: &[u8]) -> Result<ApplyWith> {
316	let values = match decode_params(bytes).map_err(|e| internal_error!("failed to decode apply with: {}", e))? {
317		Params::None => return Ok(ApplyWith::default()),
318		Params::Named(values) => values,
319		Params::Positional(_) => return Err(internal_error!("apply with must be named params")),
320	};
321	let mut with = ApplyWith::default();
322	let mut window_name: Option<String> = None;
323	let mut duration: Option<Duration> = None;
324	let mut slots: Option<u64> = None;
325	let mut slide: Option<WindowSize> = None;
326	let mut gap: Option<Duration> = None;
327	let mut lag: Option<Duration> = None;
328	let mut pane: Option<Duration> = None;
329	for (key, value) in values.iter() {
330		match (key.as_str(), value) {
331			("window", Value::Utf8(name)) => window_name = Some(name.clone()),
332			("duration", Value::Duration(d)) => duration = Some(*d),
333			("slots", Value::Uint8(n)) => slots = Some(*n),
334			("slide", Value::Duration(d)) => slide = Some(WindowSize::Duration(*d)),
335			("slide", Value::Uint8(n)) => slide = Some(WindowSize::Count(*n)),
336			("gap", Value::Duration(d)) => gap = Some(*d),
337			("lag", Value::Duration(d)) => lag = Some(*d),
338			("pane", Value::Duration(d)) => pane = Some(*d),
339			("lateness", value) => with.lateness = Some(value_span(key, value)?),
340			("immutable", value) => with.immutable = Some(value_span(key, value)?),
341			("retention", Value::Duration(retention)) => with.retention = Some(*retention),
342			_ => return Err(internal_error!("unexpected apply with entry {}: {:?}", key, value)),
343		}
344	}
345	with.window = decode_window_kind(window_name, duration, slots, slide, gap, lag, pane)?;
346	Ok(with)
347}
348
349fn decode_window_kind(
350	window_name: Option<String>,
351	duration: Option<Duration>,
352	slots: Option<u64>,
353	slide: Option<WindowSize>,
354	gap: Option<Duration>,
355	lag: Option<Duration>,
356	pane: Option<Duration>,
357) -> Result<Option<WindowKind>> {
358	let Some(window_name) = window_name else {
359		if duration.is_some()
360			|| slots.is_some() || slide.is_some()
361			|| gap.is_some() || lag.is_some()
362			|| pane.is_some()
363		{
364			return Err(internal_error!("apply with has a window size key without 'window'"));
365		}
366		return Ok(None);
367	};
368	match window_name.as_str() {
369		"tumbling" => {
370			if slide.is_some() || gap.is_some() || lag.is_some() || pane.is_some() {
371				return Err(internal_error!("apply with has a key the tumbling window does not use"));
372			}
373			Ok(Some(WindowKind::Tumbling {
374				size: decode_window_size(duration, slots)?,
375			}))
376		}
377		"sliding" => {
378			let size = decode_window_size(duration, slots)?;
379			let Some(slide) = slide else {
380				return Err(internal_error!("apply with is missing 'slide' for a sliding window"));
381			};
382			if gap.is_some() || lag.is_some() || pane.is_some() {
383				return Err(internal_error!("apply with has a key the sliding window does not use"));
384			}
385			Ok(Some(WindowKind::Sliding {
386				size,
387				slide,
388			}))
389		}
390		"rolling" => {
391			let size = decode_window_size(duration, slots)?;
392			if slide.is_some() || gap.is_some() {
393				return Err(internal_error!("apply with has a key the rolling window does not use"));
394			}
395			Ok(Some(WindowKind::Rolling {
396				size,
397				lag,
398				pane,
399			}))
400		}
401		"session" => {
402			let Some(gap) = gap else {
403				return Err(internal_error!("apply with is missing 'gap' for a session window"));
404			};
405			if duration.is_some() || slots.is_some() || slide.is_some() || lag.is_some() || pane.is_some() {
406				return Err(internal_error!("apply with has a key the session window does not use"));
407			}
408			Ok(Some(WindowKind::Session {
409				gap,
410			}))
411		}
412		other => Err(internal_error!("apply with has an unknown window kind {}", other)),
413	}
414}
415
416fn decode_window_size(duration: Option<Duration>, slots: Option<u64>) -> Result<WindowSize> {
417	match (duration, slots) {
418		(Some(d), None) => Ok(WindowSize::Duration(d)),
419		(None, Some(n)) => Ok(WindowSize::Count(n)),
420		(None, None) => Err(internal_error!("apply with is missing 'duration' or 'slots' for its window")),
421		(Some(_), Some(_)) => Err(internal_error!("apply with has both 'duration' and 'slots'")),
422	}
423}
424
425fn insert_window_size(values: &mut HashMap<String, Value>, size: &WindowSize) {
426	match size {
427		WindowSize::Duration(d) => {
428			values.insert("duration".to_string(), Value::Duration(*d));
429		}
430		WindowSize::Count(n) => {
431			values.insert("slots".to_string(), Value::Uint8(*n));
432		}
433	}
434}
435
436fn window_size_value(size: &WindowSize) -> Value {
437	match size {
438		WindowSize::Duration(d) => Value::Duration(*d),
439		WindowSize::Count(n) => Value::Uint8(*n),
440	}
441}
442
443fn span_value(span: WithSpan) -> Value {
444	match span {
445		WithSpan::Duration(duration) => Value::Duration(duration),
446		WithSpan::Count(count) => Value::Uint8(count),
447	}
448}
449
450fn value_span(key: &str, value: &Value) -> Result<WithSpan> {
451	match value {
452		Value::Duration(duration) => Ok(WithSpan::Duration(*duration)),
453		Value::Uint8(count) => Ok(WithSpan::Count(*count)),
454		_ => Err(internal_error!("unexpected apply with entry {}: {:?}", key, value)),
455	}
456}
457
458fn duration_span(key: &'static str, span: Option<WithSpan>) -> Result<Option<Duration>> {
459	match span {
460		None => Ok(None),
461		Some(WithSpan::Duration(duration)) => Ok(Some(duration)),
462		Some(WithSpan::Count(count)) => Err(CoreError::OperatorWithCountSpan {
463			key,
464			count,
465		}
466		.into()),
467	}
468}
469
470#[cfg(test)]
471mod tests {
472	use std::{collections::HashMap, sync::Arc};
473
474	use reifydb_codec::value::encode_params;
475	use reifydb_value::{
476		params::Params,
477		value::{Value, duration::Duration},
478	};
479
480	use super::{ApplyWith, WindowKind, WindowSealing, WindowSize, WithSpan, decode_apply_with, encode_apply_with};
481
482	fn secs(n: i64) -> Duration {
483		Duration::from_seconds(n).unwrap()
484	}
485
486	fn with(lateness: Option<i64>, immutable: Option<i64>) -> ApplyWith {
487		ApplyWith {
488			window: None,
489			lateness: lateness.map(|n| WithSpan::Duration(secs(n))),
490			immutable: immutable.map(|n| WithSpan::Duration(secs(n))),
491			retention: None,
492		}
493	}
494
495	#[test]
496	fn declared_immutable_below_lateness_is_kept_verbatim() {
497		assert_eq!(
498			WindowSealing::from_operator_with(&with(Some(20), Some(15))).unwrap(),
499			WindowSealing {
500				lateness: Some(secs(20)),
501				immutable: Some(secs(15)),
502			}
503		);
504	}
505
506	#[test]
507	fn lateness_without_immutable_resolves_with_no_immutable() {
508		// Substituting the lateness would arm the sealing slots on a window that never asked to seal.
509		assert_eq!(
510			WindowSealing::from_operator_with(&with(Some(20), None)).unwrap(),
511			WindowSealing {
512				lateness: Some(secs(20)),
513				immutable: None,
514			}
515		);
516	}
517
518	#[test]
519	fn declared_immutable_equal_to_lateness_is_rejected() {
520		// The bound is strict; an immutable equal to the lateness would never seal before the window closes.
521		assert!(WindowSealing::from_operator_with(&with(Some(20), Some(20))).is_err());
522	}
523
524	#[test]
525	fn declared_immutable_above_lateness_is_rejected() {
526		assert!(WindowSealing::from_operator_with(&with(Some(20), Some(30))).is_err());
527	}
528
529	#[test]
530	fn immutable_without_lateness_is_accepted() {
531		// The ordering bound needs both knobs, so an immutable alone must reach the operator untouched.
532		assert_eq!(
533			WindowSealing::from_operator_with(&with(None, Some(15))).unwrap(),
534			WindowSealing {
535				lateness: None,
536				immutable: Some(secs(15)),
537			}
538		);
539	}
540
541	#[test]
542	fn neither_knob_declared_leaves_both_absent() {
543		// An undeclared knob must stay absent, never resolve to zero, which is itself a legal declared value.
544		assert_eq!(
545			WindowSealing::from_operator_with(&with(None, None)).unwrap(),
546			WindowSealing {
547				lateness: None,
548				immutable: None,
549			}
550		);
551	}
552
553	#[test]
554	fn validated_returns_both_when_declared() {
555		assert_eq!(
556			WindowSealing::from_operator_with(&with(Some(20), Some(15))).unwrap(),
557			WindowSealing {
558				lateness: Some(secs(20)),
559				immutable: Some(secs(15)),
560			}
561		);
562	}
563
564	#[test]
565	fn validated_returns_no_immutable_when_only_the_lateness_is_declared() {
566		// A window under the immutable floor declares a lateness alone and must still resolve.
567		assert_eq!(
568			WindowSealing::from_operator_with(&with(Some(20), None)).unwrap(),
569			WindowSealing {
570				lateness: Some(secs(20)),
571				immutable: None,
572			}
573		);
574	}
575
576	#[test]
577	fn validated_names_the_ordering_violation() {
578		let err = WindowSealing::from_operator_with(&with(Some(20), Some(20))).unwrap_err();
579		assert!(err.to_string().contains("must be strictly less than lateness"), "{err}");
580	}
581
582	#[test]
583	fn validated_accepts_an_immutable_without_a_lateness() {
584		// Validation covers the ordering rule only; neither knob is required, so this must not fail.
585		assert_eq!(
586			WindowSealing::from_operator_with(&with(None, Some(15))).unwrap(),
587			WindowSealing {
588				lateness: None,
589				immutable: Some(secs(15)),
590			}
591		);
592	}
593
594	#[test]
595	fn validated_accepts_a_window_that_declares_no_knob_at_all() {
596		assert_eq!(
597			WindowSealing::from_operator_with(&with(None, None)).unwrap(),
598			WindowSealing {
599				lateness: None,
600				immutable: None,
601			}
602		);
603	}
604
605	#[test]
606	fn a_count_span_is_rejected_for_a_time_sealed_operator() {
607		// A count read as a duration would seal after that many nanoseconds instead of that many rows.
608		let with = ApplyWith {
609			window: None,
610			lateness: Some(WithSpan::Count(150)),
611			immutable: None,
612			retention: None,
613		};
614		let err = WindowSealing::from_operator_with(&with).unwrap_err();
615		assert!(err.to_string().contains("lateness"), "{err}");
616	}
617
618	#[test]
619	fn apply_with_round_trips_through_the_create_buffer() {
620		// A span that changes unit on the way to the guest seals after rows instead of time, or the reverse.
621		for with in [
622			ApplyWith::default(),
623			with(Some(30), Some(10)),
624			ApplyWith {
625				window: None,
626				lateness: Some(WithSpan::Count(150)),
627				immutable: Some(WithSpan::Count(0)),
628				retention: None,
629			},
630		] {
631			assert_eq!(decode_apply_with(&encode_apply_with(&with).unwrap()).unwrap(), with);
632		}
633	}
634
635	#[test]
636	fn an_unknown_apply_with_key_fails_to_decode() {
637		// Dropping a key the guest does not know would run the operator without a setting the author declared.
638		let values = HashMap::from([("ttl".to_string(), Value::Duration(secs(1)))]);
639		let bytes = encode_params(&Params::Named(Arc::new(values))).unwrap();
640		assert!(decode_apply_with(&bytes).is_err());
641	}
642
643	#[test]
644	fn a_span_of_the_wrong_type_fails_to_decode() {
645		let values = HashMap::from([("lateness".to_string(), Value::Int8(5))]);
646		let bytes = encode_params(&Params::Named(Arc::new(values))).unwrap();
647		assert!(decode_apply_with(&bytes).is_err());
648	}
649
650	#[test]
651	fn positional_params_fail_to_decode() {
652		let bytes = encode_params(&Params::Positional(Arc::new(vec![Value::Duration(secs(1))]))).unwrap();
653		assert!(decode_apply_with(&bytes).is_err());
654	}
655
656	#[test]
657	fn every_window_kind_round_trips_through_the_create_buffer() {
658		// A kind that decodes into the wrong shape would run the guest with a size or slide in the wrong unit.
659		let lateness = Some(WithSpan::Duration(secs(30)));
660		let kinds = [
661			WindowKind::Tumbling {
662				size: WindowSize::Duration(secs(60)),
663			},
664			WindowKind::Tumbling {
665				size: WindowSize::Count(10),
666			},
667			WindowKind::Sliding {
668				size: WindowSize::Duration(secs(60)),
669				slide: WindowSize::Duration(secs(30)),
670			},
671			WindowKind::Sliding {
672				size: WindowSize::Count(10),
673				slide: WindowSize::Count(5),
674			},
675			WindowKind::Rolling {
676				size: WindowSize::Duration(secs(60)),
677				lag: Some(secs(5)),
678				pane: None,
679			},
680			WindowKind::Rolling {
681				size: WindowSize::Duration(secs(60)),
682				lag: None,
683				pane: None,
684			},
685			WindowKind::Session {
686				gap: secs(30),
687			},
688		];
689		for kind in kinds {
690			let with = ApplyWith {
691				window: Some(kind),
692				lateness,
693				immutable: None,
694				retention: None,
695			};
696			assert_eq!(decode_apply_with(&encode_apply_with(&with).unwrap()).unwrap(), with);
697		}
698	}
699
700	#[test]
701	fn a_size_key_without_a_window_fails_to_decode() {
702		// A size with no 'window' has nothing to build a WindowKind against.
703		let values = HashMap::from([("duration".to_string(), Value::Duration(secs(60)))]);
704		let bytes = encode_params(&Params::Named(Arc::new(values))).unwrap();
705		assert!(decode_apply_with(&bytes).is_err());
706	}
707
708	#[test]
709	fn window_duration_rejects_a_missing_window_a_count_size_and_a_session() {
710		assert!(ApplyWith::default().window_duration().is_err());
711
712		let count = ApplyWith {
713			window: Some(WindowKind::Tumbling {
714				size: WindowSize::Count(10),
715			}),
716			lateness: None,
717			immutable: None,
718			retention: None,
719		};
720		assert!(count.window_duration().is_err());
721
722		let session = ApplyWith {
723			window: Some(WindowKind::Session {
724				gap: secs(30),
725			}),
726			lateness: None,
727			immutable: None,
728			retention: None,
729		};
730		assert!(session.window_duration().is_err());
731	}
732
733	#[test]
734	fn window_slots_rejects_a_missing_window_and_a_duration_size() {
735		assert!(ApplyWith::default().window_slots().is_err());
736
737		let duration = ApplyWith {
738			window: Some(WindowKind::Tumbling {
739				size: WindowSize::Duration(secs(60)),
740			}),
741			lateness: None,
742			immutable: None,
743			retention: None,
744		};
745		assert!(duration.window_slots().is_err());
746	}
747
748	#[test]
749	fn window_slide_reads_only_a_sliding_window_and_only_in_its_unit() {
750		// a slide read from another kind or in the wrong unit would size every sliding window wrong with no
751		// error
752		let by_time = ApplyWith {
753			window: Some(WindowKind::Sliding {
754				size: WindowSize::Duration(secs(60)),
755				slide: WindowSize::Duration(secs(15)),
756			}),
757			lateness: None,
758			immutable: None,
759			retention: None,
760		};
761		let by_slots = ApplyWith {
762			window: Some(WindowKind::Sliding {
763				size: WindowSize::Count(10),
764				slide: WindowSize::Count(4),
765			}),
766			lateness: None,
767			immutable: None,
768			retention: None,
769		};
770		let tumbling = ApplyWith {
771			window: Some(WindowKind::Tumbling {
772				size: WindowSize::Duration(secs(60)),
773			}),
774			lateness: None,
775			immutable: None,
776			retention: None,
777		};
778
779		assert_eq!(by_time.window_slide_duration().unwrap(), Some(secs(15)));
780		assert_eq!(by_slots.window_slide_slots().unwrap(), Some(4));
781		assert!(by_time.window_slide_slots().is_err());
782		assert!(by_slots.window_slide_duration().is_err());
783		assert_eq!(tumbling.window_slide_duration().unwrap(), None);
784		assert_eq!(tumbling.window_slide_slots().unwrap(), None);
785	}
786
787	#[test]
788	fn window_session_gap_reads_only_a_session_window() {
789		// A gap read from another kind would give a tumbling or sliding window a session's sealing with no
790		// error.
791		let session = ApplyWith {
792			window: Some(WindowKind::Session {
793				gap: secs(30),
794			}),
795			lateness: None,
796			immutable: None,
797			retention: None,
798		};
799		let others = [
800			WindowKind::Tumbling {
801				size: WindowSize::Duration(secs(30)),
802			},
803			WindowKind::Sliding {
804				size: WindowSize::Duration(secs(30)),
805				slide: WindowSize::Duration(secs(10)),
806			},
807			WindowKind::Rolling {
808				size: WindowSize::Duration(secs(30)),
809				lag: None,
810				pane: None,
811			},
812		];
813
814		assert_eq!(session.window_session_gap(), Some(secs(30)));
815		assert_eq!(ApplyWith::default().window_session_gap(), None);
816		for kind in others {
817			let name = kind.name();
818			let with = ApplyWith {
819				window: Some(kind),
820				lateness: None,
821				immutable: None,
822				retention: None,
823			};
824			assert_eq!(with.window_session_gap(), None, "a {name} window has no session gap");
825		}
826	}
827
828	#[test]
829	fn check_session_window_refuses_a_zero_gap_and_a_positive_lateness_only_on_a_session() {
830		// A zero gap spans no row and a lateness misfiles late rows; any other kind must keep its lateness.
831		let session = |gap: Duration, lateness: Option<WithSpan>| ApplyWith {
832			window: Some(WindowKind::Session {
833				gap,
834			}),
835			lateness,
836			immutable: None,
837			retention: None,
838		};
839		let code = |with: ApplyWith| with.check_session_window().unwrap_err().0.code;
840
841		assert!(session(secs(30), None).check_session_window().is_ok());
842		assert!(session(secs(30), Some(WithSpan::Duration(secs(0)))).check_session_window().is_ok());
843		assert_eq!(code(session(secs(30), Some(WithSpan::Duration(secs(1))))), "FLOW_078");
844		assert_eq!(code(session(secs(0), None)), "FLOW_079");
845		assert_eq!(
846			code(session(secs(0), Some(WithSpan::Duration(secs(1))))),
847			"FLOW_079",
848			"the gap is checked first"
849		);
850		assert_eq!(
851			code(session(secs(30), Some(WithSpan::Count(5)))),
852			"FLOW_063",
853			"a session seals by time, so a count lateness is the wrong unit"
854		);
855
856		let tumbling = ApplyWith {
857			window: Some(WindowKind::Tumbling {
858				size: WindowSize::Duration(secs(60)),
859			}),
860			lateness: Some(WithSpan::Duration(secs(1))),
861			immutable: None,
862			retention: None,
863		};
864		assert!(tumbling.check_session_window().is_ok());
865		assert!(ApplyWith::default().check_session_window().is_ok());
866	}
867
868	#[test]
869	fn reject_window_refuses_any_window() {
870		assert!(ApplyWith::default().reject_window().is_ok());
871
872		let windowed = ApplyWith {
873			window: Some(WindowKind::Tumbling {
874				size: WindowSize::Duration(secs(60)),
875			}),
876			lateness: None,
877			immutable: None,
878			retention: None,
879		};
880		assert!(windowed.reject_window().is_err());
881	}
882
883	#[test]
884	fn require_window_refuses_a_missing_window_and_another_kind() {
885		assert!(ApplyWith::default().require_window("tumbling").is_err());
886
887		let rolling = ApplyWith {
888			window: Some(WindowKind::Rolling {
889				size: WindowSize::Duration(secs(60)),
890				lag: None,
891				pane: None,
892			}),
893			lateness: None,
894			immutable: None,
895			retention: None,
896		};
897		assert!(rolling.require_window("tumbling").is_err());
898		assert!(rolling.require_window("rolling").is_ok());
899	}
900
901	#[test]
902	fn lateness_readers_refuse_the_other_unit() {
903		let none = ApplyWith::default();
904		assert_eq!(none.lateness_duration().unwrap(), None);
905		assert_eq!(none.lateness_count().unwrap(), None);
906
907		let duration = ApplyWith {
908			window: None,
909			lateness: Some(WithSpan::Duration(secs(30))),
910			immutable: None,
911			retention: None,
912		};
913		assert_eq!(duration.lateness_duration().unwrap(), Some(secs(30)));
914		assert!(duration.lateness_count().is_err());
915
916		let count = ApplyWith {
917			window: None,
918			lateness: Some(WithSpan::Count(5)),
919			immutable: None,
920			retention: None,
921		};
922		assert_eq!(count.lateness_count().unwrap(), Some(5));
923		assert!(count.lateness_duration().is_err());
924	}
925
926	#[test]
927	fn a_rolling_pane_round_trips_through_the_create_buffer() {
928		// A pane lost or moved between the reader and the guest would run the window at the wrong resolution.
929		let with = ApplyWith {
930			window: Some(WindowKind::Rolling {
931				size: WindowSize::Duration(secs(3600)),
932				lag: Some(secs(5)),
933				pane: Some(secs(1)),
934			}),
935			lateness: None,
936			immutable: None,
937			retention: None,
938		};
939		assert_eq!(decode_apply_with(&encode_apply_with(&with).unwrap()).unwrap(), with);
940	}
941
942	#[test]
943	fn a_pane_on_a_window_that_is_not_rolling_fails_to_decode() {
944		// A pane the kind never reads is a setting the author believes is in force.
945		for (window, extra) in [
946			("tumbling", ("duration", Value::Duration(secs(60)))),
947			("sliding", ("duration", Value::Duration(secs(60)))),
948			("session", ("gap", Value::Duration(secs(30)))),
949		] {
950			let mut values = HashMap::from([
951				("window".to_string(), Value::Utf8(window.to_string())),
952				("pane".to_string(), Value::Duration(secs(1))),
953				(extra.0.to_string(), extra.1),
954			]);
955			if window == "sliding" {
956				values.insert("slide".to_string(), Value::Duration(secs(30)));
957			}
958			let bytes = encode_params(&Params::Named(Arc::new(values))).unwrap();
959			assert!(decode_apply_with(&bytes).is_err(), "pane must be rejected on {window}");
960		}
961	}
962
963	#[test]
964	fn a_pane_without_a_window_fails_to_decode() {
965		let values = HashMap::from([("pane".to_string(), Value::Duration(secs(1)))]);
966		let bytes = encode_params(&Params::Named(Arc::new(values))).unwrap();
967		assert!(decode_apply_with(&bytes).is_err());
968	}
969
970	#[test]
971	fn immutable_count_refuses_a_duration_and_reads_a_count() {
972		// A duration read as a count would seal after that many nanoseconds' worth of rows.
973		let none = ApplyWith::default();
974		assert_eq!(none.immutable_count().unwrap(), None);
975
976		let count = ApplyWith {
977			window: None,
978			lateness: None,
979			immutable: Some(WithSpan::Count(4)),
980			retention: None,
981		};
982		assert_eq!(count.immutable_count().unwrap(), Some(4));
983
984		let duration = ApplyWith {
985			window: None,
986			lateness: None,
987			immutable: Some(WithSpan::Duration(secs(4))),
988			retention: None,
989		};
990		assert!(duration.immutable_count().is_err());
991	}
992
993	#[test]
994	fn retention_round_trips_through_the_create_buffer() {
995		// A retention dropped on the way to the guest would run the view with a bound it did not declare.
996		let declared = ApplyWith {
997			retention: Some(secs(3600)),
998			..with(Some(20), None)
999		};
1000		let bytes = encode_apply_with(&declared).unwrap();
1001		assert_eq!(decode_apply_with(&bytes).unwrap(), declared);
1002	}
1003
1004	#[test]
1005	fn effective_retention_is_the_retention_or_else_the_lateness() {
1006		// A view that declares lateness alone must keep the bound it had before retention existed.
1007		assert_eq!(with(Some(20), None).effective_retention().unwrap(), Some(secs(20)));
1008		assert_eq!(with(None, None).effective_retention().unwrap(), None);
1009		let declared = ApplyWith {
1010			retention: Some(secs(60)),
1011			..with(Some(20), None)
1012		};
1013		assert_eq!(declared.effective_retention().unwrap(), Some(secs(60)));
1014	}
1015
1016	#[test]
1017	fn check_retention_refuses_below_the_lateness_and_accepts_equal() {
1018		// A retention under the lateness frees a group while a timer inside the hold can still fire for it.
1019		let below = ApplyWith {
1020			retention: Some(secs(19)),
1021			..with(Some(20), None)
1022		};
1023		let err = below.check_retention().unwrap_err();
1024		assert!(err.to_string().contains("must not be below lateness"), "{err}");
1025
1026		let equal = ApplyWith {
1027			retention: Some(secs(20)),
1028			..with(Some(20), None)
1029		};
1030		assert!(equal.check_retention().is_ok());
1031		assert!(with(Some(20), None).check_retention().is_ok());
1032	}
1033
1034	#[test]
1035	fn reject_retention_refuses_only_a_declared_retention() {
1036		// A retention silently dropped on a class that never reclaims by it is a bound the author believes
1037		// holds.
1038		assert!(with(Some(20), None).reject_retention().is_ok());
1039		let declared = ApplyWith {
1040			retention: Some(secs(60)),
1041			..with(None, None)
1042		};
1043		let err = declared.reject_retention().unwrap_err();
1044		assert!(err.to_string().contains("takes no 'retention'"), "{err}");
1045	}
1046}