1use std::collections::{HashMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum WindowKind {
10 Tumbling,
11 Sliding,
12 Session,
13 Count {
15 size: u64,
16 slide: u64,
17 },
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum WindowAggKind {
23 Count,
24 Sum,
25 Min,
26 Max,
27 Avg,
28 Stddev,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum AggFilterCompareOp {
35 Eq,
36 NotEq,
37 Lt,
38 LtEq,
39 Gt,
40 GtEq,
41}
42
43#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
45#[serde(transparent)]
46pub struct FloatLiteral(pub f64);
47
48impl PartialEq for FloatLiteral {
49 fn eq(&self, other: &Self) -> bool {
50 self.0.to_bits() == other.0.to_bits()
51 }
52}
53impl Eq for FloatLiteral {}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub enum AggFilterValue {
58 Int(i64),
59 Float(FloatLiteral),
60 Utf8(String),
61 Bool(bool),
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub enum WindowAggFilter {
74 Compare {
76 column: String,
77 op: AggFilterCompareOp,
78 value: AggFilterValue,
79 },
80 IsNull {
81 column: String,
82 },
83 IsNotNull {
84 column: String,
85 },
86 And(Box<WindowAggFilter>, Box<WindowAggFilter>),
87 Or(Box<WindowAggFilter>, Box<WindowAggFilter>),
88 Not(Box<WindowAggFilter>),
89}
90
91impl WindowAggFilter {
92 pub fn columns(&self) -> Vec<&str> {
94 match self {
95 WindowAggFilter::Compare { column, .. }
96 | WindowAggFilter::IsNull { column }
97 | WindowAggFilter::IsNotNull { column } => vec![column.as_str()],
98 WindowAggFilter::And(a, b) | WindowAggFilter::Or(a, b) => {
99 let mut cols = a.columns();
100 cols.extend(b.columns());
101 cols
102 }
103 WindowAggFilter::Not(inner) => inner.columns(),
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct WindowAgg {
111 pub kind: WindowAggKind,
112 pub input_column: String,
113 pub output_column: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub filter: Option<WindowAggFilter>,
118}
119
120impl WindowAgg {
121 pub fn count(output_column: impl Into<String>) -> Self {
122 Self {
123 kind: WindowAggKind::Count,
124 input_column: String::new(),
125 output_column: output_column.into(),
126 filter: None,
127 }
128 }
129
130 #[must_use]
132 pub fn with_filter(mut self, filter: WindowAggFilter) -> Self {
133 self.filter = Some(filter);
134 self
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct WindowExecutionSpec {
141 pub key_column: String,
142 #[serde(default = "default_key_type")]
145 pub key_column_type: String,
146 pub event_time_column: String,
147 pub watermark_lag_ms: u64,
148 pub window_kind: WindowKind,
149 pub window_size_ms: u64,
150 pub slide_ms: Option<u64>,
152 pub session_gap_ms: Option<u64>,
154 pub agg_exprs: Vec<WindowAgg>,
155 pub state_ttl_ms: Option<u64>,
156 #[serde(default)]
162 pub allowed_lateness_ms: Option<u64>,
163 #[serde(default)]
166 pub source_watermark_lags: HashMap<String, u64>,
167 #[serde(default)]
169 pub source_id_column: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub window_timezone: Option<String>,
179}
180
181fn default_key_type() -> String {
182 String::from("utf8")
183}
184
185impl WindowExecutionSpec {
186 pub fn default_count_agg() -> Vec<WindowAgg> {
187 vec![WindowAgg::count("count")]
188 }
189
190 pub fn tumbling(
191 key_column: impl Into<String>,
192 event_time_column: impl Into<String>,
193 window_size_ms: u64,
194 ) -> Self {
195 Self {
196 key_column: key_column.into(),
197 key_column_type: default_key_type(),
198 event_time_column: event_time_column.into(),
199 watermark_lag_ms: 0,
200 window_kind: WindowKind::Tumbling,
201 window_size_ms,
202 slide_ms: None,
203 session_gap_ms: None,
204 agg_exprs: Self::default_count_agg(),
205 state_ttl_ms: None,
206 allowed_lateness_ms: None,
207 source_watermark_lags: HashMap::new(),
208 source_id_column: None,
209 window_timezone: None,
210 }
211 }
212}
213
214pub const WINDOW_EXECUTION_SPEC_PREFIX: &str = "stream:spec:v1:";
216
217pub fn encode_window_execution_spec(spec: &WindowExecutionSpec) -> Result<String, PlanError> {
222 let mut normalized = spec.clone();
223 if normalized.agg_exprs.is_empty() {
224 normalized.agg_exprs = WindowExecutionSpec::default_count_agg();
225 }
226 validate_window_execution_spec(&normalized)?;
227 let json =
228 serde_json::to_string(&normalized).map_err(|error| PlanError::Encode(error.to_string()))?;
229 Ok(format!("{WINDOW_EXECUTION_SPEC_PREFIX}{json}"))
230}
231
232pub fn decode_window_execution_spec(encoded: &str) -> Result<WindowExecutionSpec, PlanError> {
235 if let Some(json) = encoded.strip_prefix(WINDOW_EXECUTION_SPEC_PREFIX) {
236 let spec: WindowExecutionSpec =
237 serde_json::from_str(json).map_err(|error| PlanError::Parse(error.to_string()))?;
238 validate_window_execution_spec(&spec)?;
239 return Ok(spec);
240 }
241
242 let parsed = parse_stream_fragment(encoded)?;
243 let (slide_ms, session_gap_ms) = match parsed.window_kind {
244 WindowKind::Tumbling => (None, None),
245 WindowKind::Sliding => (parsed.slide_ms, None),
246 WindowKind::Session => (None, parsed.session_gap_ms),
247 WindowKind::Count { .. } => (None, None),
248 };
249 let spec = WindowExecutionSpec {
250 key_column: parsed.key_col,
251 key_column_type: String::from("utf8"),
252 event_time_column: parsed.time_col,
253 watermark_lag_ms: parsed.lag_ms,
254 window_kind: parsed.window_kind,
255 window_size_ms: parsed.window_ms,
256 slide_ms,
257 session_gap_ms,
258 agg_exprs: vec![parsed.agg],
259 state_ttl_ms: parsed.ttl_ms,
260 allowed_lateness_ms: None,
261 source_watermark_lags: parsed.source_watermark_lags,
262 source_id_column: parsed.source_id_column,
263 window_timezone: None,
264 };
265 validate_window_execution_spec(&spec)?;
266 Ok(spec)
267}
268
269pub fn validate_window_execution_spec(spec: &WindowExecutionSpec) -> Result<(), PlanError> {
271 if spec.key_column.trim().is_empty() {
272 return Err(PlanError::Validation(String::from(
273 "window key_column must not be empty",
274 )));
275 }
276 if spec.event_time_column.trim().is_empty() {
277 return Err(PlanError::Validation(String::from(
278 "window event_time_column must not be empty",
279 )));
280 }
281 if spec.window_size_ms == 0 && spec.window_kind != WindowKind::Session {
285 return Err(PlanError::Validation(String::from(
286 "window_size_ms must be greater than zero",
287 )));
288 }
289 if spec.window_kind == WindowKind::Sliding {
290 match spec.slide_ms {
291 None => {
292 return Err(PlanError::Validation(String::from(
293 "sliding window requires explicit slide_ms",
294 )));
295 }
296 Some(0) => {
297 return Err(PlanError::Validation(String::from(
298 "sliding window slide_ms must be greater than zero",
299 )));
300 }
301 Some(_) => {}
302 }
303 }
304 if spec.window_kind == WindowKind::Session {
305 match spec.session_gap_ms {
306 None => {
307 return Err(PlanError::Validation(String::from(
308 "session window requires explicit session_gap_ms",
309 )));
310 }
311 Some(0) => {
312 return Err(PlanError::Validation(String::from(
313 "session window session_gap_ms must be greater than zero",
314 )));
315 }
316 Some(_) => {}
317 }
318 }
319 if let WindowKind::Count { size, slide } = spec.window_kind {
320 if size == 0 {
321 return Err(PlanError::Validation(String::from(
322 "count window size must be greater than zero",
323 )));
324 }
325 if slide == 0 {
326 return Err(PlanError::Validation(String::from(
327 "count window slide must be greater than zero",
328 )));
329 }
330 if slide > size {
331 return Err(PlanError::Validation(String::from(
332 "count window slide must be ≤ size",
333 )));
334 }
335 }
336 if spec.state_ttl_ms == Some(0) {
337 return Err(PlanError::Validation(String::from(
338 "window state_ttl_ms must be greater than zero",
339 )));
340 }
341 if let Some(0) = spec.allowed_lateness_ms {
346 return Err(PlanError::Validation(String::from(
347 "window allowed_lateness_ms must be greater than zero (use None to disable)",
348 )));
349 }
350 if let Some(lat) = spec.allowed_lateness_ms
351 && lat > u64::MAX / 2
352 {
353 return Err(PlanError::Validation(String::from(
354 "window allowed_lateness_ms is implausibly large",
355 )));
356 }
357 if spec.agg_exprs.is_empty() {
358 return Err(PlanError::Validation(String::from(
359 "window execution requires at least one aggregate",
360 )));
361 }
362
363 let mut output_columns = HashSet::with_capacity(spec.agg_exprs.len());
364 for aggregate in &spec.agg_exprs {
365 if aggregate.output_column.trim().is_empty() {
366 return Err(PlanError::Validation(String::from(
367 "window aggregate output_column must not be empty",
368 )));
369 }
370 if aggregate.kind != WindowAggKind::Count && aggregate.input_column.trim().is_empty() {
371 return Err(PlanError::Validation(format!(
372 "{:?} window aggregate requires a non-empty input_column",
373 aggregate.kind
374 )));
375 }
376 if !output_columns.insert(aggregate.output_column.as_str()) {
377 return Err(PlanError::Validation(format!(
378 "duplicate window aggregate output_column '{}'",
379 aggregate.output_column
380 )));
381 }
382 if let Some(filter) = &aggregate.filter
383 && filter.columns().iter().any(|c| c.trim().is_empty())
384 {
385 return Err(PlanError::Validation(format!(
386 "window aggregate '{}' filter references an empty column name",
387 aggregate.output_column
388 )));
389 }
390 }
391
392 if let Some(source_id_column) = &spec.source_id_column
393 && source_id_column.trim().is_empty()
394 {
395 return Err(PlanError::Validation(String::from(
396 "source_id_column must not be empty when configured",
397 )));
398 }
399 if !spec.source_watermark_lags.is_empty() && spec.source_id_column.is_none() {
400 return Err(PlanError::Validation(String::from(
401 "source_id_column is required when source_watermark_lags are configured",
402 )));
403 }
404 if spec
405 .source_watermark_lags
406 .keys()
407 .any(|source_id| source_id.trim().is_empty())
408 {
409 return Err(PlanError::Validation(String::from(
410 "source_watermark_lags contains an empty source id",
411 )));
412 }
413 Ok(())
414}
415
416fn escape_compact_value(s: &str) -> String {
419 s.replace('\\', "\\\\").replace(':', "\\:")
420}
421
422fn unescape_compact_value(s: &str) -> String {
424 let mut result = String::with_capacity(s.len());
425 let mut chars = s.chars().peekable();
426 while let Some(ch) = chars.next() {
427 if ch == '\\'
428 && let Some(&next) = chars.peek()
429 && (next == ':' || next == '\\')
430 {
431 chars.next();
432 result.push(next);
433 continue;
434 }
435 result.push(ch);
436 }
437 result
438}
439
440pub fn encode_stream_fragment(spec: &WindowExecutionSpec) -> Result<String, PlanError> {
447 if spec.agg_exprs.len() > 1 || spec.agg_exprs.iter().any(|a| a.filter.is_some()) {
450 return encode_window_execution_spec(spec);
451 }
452 let agg = if spec.agg_exprs.is_empty() {
453 "agg=count".to_string()
454 } else {
455 spec.agg_exprs.first().map(encode_agg).unwrap_or_default()
456 };
457
458 let prefix = match spec.window_kind {
459 WindowKind::Tumbling => "stream:tw",
460 WindowKind::Sliding => "stream:sw",
461 WindowKind::Session => "stream:ses",
462 WindowKind::Count { .. } => "stream:cw",
463 };
464
465 let extra = match spec.window_kind {
466 WindowKind::Tumbling => String::new(),
467 WindowKind::Sliding => format!(":slide={}", spec.slide_ms.unwrap_or(spec.window_size_ms)),
468 WindowKind::Session => format!(
469 ":gap={}",
470 spec.session_gap_ms.unwrap_or(spec.window_size_ms)
471 ),
472 WindowKind::Count { size, slide } => format!(":csize={size}:cslide={slide}"),
473 };
474
475 let ttl = spec
476 .state_ttl_ms
477 .map(|ms| format!(":ttl={ms}"))
478 .unwrap_or_default();
479
480 let srcid = spec
484 .source_id_column
485 .as_deref()
486 .map(|c| format!(":srcid={}", escape_compact_value(c)))
487 .unwrap_or_default();
488
489 let srcs = if spec.source_watermark_lags.is_empty() {
490 String::new()
491 } else {
492 let mut pairs: Vec<_> = spec.source_watermark_lags.iter().collect();
494 pairs.sort_by_key(|(k, _)| k.as_str());
495 let encoded: Vec<String> = pairs
496 .iter()
497 .map(|(id, lag)| format!("{}:{lag}", escape_compact_value(id)))
498 .collect();
499 format!(":srcs={}", encoded.join(","))
500 };
501
502 Ok(format!(
503 "{prefix}:key={}:time={}:win={}:lag={}:{agg}{extra}{ttl}{srcid}{srcs}",
504 escape_compact_value(&spec.key_column),
505 escape_compact_value(&spec.event_time_column),
506 spec.window_size_ms,
507 spec.watermark_lag_ms,
508 ))
509}
510
511fn encode_agg(agg: &WindowAgg) -> String {
512 match agg.kind {
513 WindowAggKind::Count => "agg=count".to_string(),
514 WindowAggKind::Sum => format!("agg=sum:col={}", agg.input_column),
515 WindowAggKind::Min => format!("agg=min:col={}", agg.input_column),
516 WindowAggKind::Max => format!("agg=max:col={}", agg.input_column),
517 WindowAggKind::Avg => format!("agg=avg:col={}", agg.input_column),
518 WindowAggKind::Stddev => format!("agg=stddev:col={}", agg.input_column),
519 }
520}
521
522#[derive(Debug, Clone, PartialEq, Eq)]
524pub struct ParsedStreamFragment {
525 pub window_kind: WindowKind,
526 pub key_col: String,
527 pub time_col: String,
528 pub window_ms: u64,
529 pub lag_ms: u64,
530 pub slide_ms: Option<u64>,
531 pub session_gap_ms: Option<u64>,
532 pub ttl_ms: Option<u64>,
533 pub agg: WindowAgg,
534 pub source_id_column: Option<String>,
536 pub source_watermark_lags: HashMap<String, u64>,
538}
539
540use crate::PlanError;
541
542pub fn parse_stream_fragment(fragment: &str) -> Result<ParsedStreamFragment, PlanError> {
544 let (window_kind_tag, payload) = if let Some(p) = fragment.strip_prefix("stream:tw:") {
545 ("tw", p)
546 } else if let Some(p) = fragment.strip_prefix("stream:sw:") {
547 ("sw", p)
548 } else if let Some(p) = fragment.strip_prefix("stream:ses:") {
549 ("ses", p)
550 } else if let Some(p) = fragment.strip_prefix("stream:cw:") {
551 ("cw", p)
552 } else {
553 return Err(PlanError::Parse(format!(
554 "streaming fragment must start with stream:tw:, stream:sw:, stream:ses:, or stream:cw:; got: {fragment}"
555 )));
556 };
557
558 let mut key_col = None;
559 let mut time_col = None;
560 let mut window_ms = None;
561 let mut lag_ms = None;
562 let mut slide_ms = None;
563 let mut session_gap_ms = None;
564 let mut ttl_ms = None;
565 let mut agg_kind: Option<String> = None;
566 let mut agg_col: Option<String> = None;
567 let mut source_id_column: Option<String> = None;
568 let mut source_watermark_lags: HashMap<String, u64> = HashMap::new();
569 let mut count_size: Option<u64> = None;
570 let mut count_slide: Option<u64> = None;
571
572 for part in split_stream_fields(payload) {
573 let part = part.trim();
574 if part.is_empty() {
575 continue;
576 }
577 let (k, v) = part.split_once('=').ok_or_else(|| {
578 PlanError::Parse(format!(
579 "streaming fragment field must be k=v; got '{part}'"
580 ))
581 })?;
582 match k.trim() {
583 "key" => key_col = Some(unescape_compact_value(v.trim())),
584 "time" => time_col = Some(unescape_compact_value(v.trim())),
585 "win" => {
586 window_ms = Some(
587 v.trim()
588 .parse::<u64>()
589 .map_err(|e| PlanError::Parse(format!("invalid win value '{v}': {e}")))?,
590 );
591 }
592 "lag" => {
593 lag_ms = Some(
594 v.trim()
595 .parse::<u64>()
596 .map_err(|e| PlanError::Parse(format!("invalid lag value '{v}': {e}")))?,
597 );
598 }
599 "slide" => {
600 slide_ms =
601 Some(v.trim().parse::<u64>().map_err(|e| {
602 PlanError::Parse(format!("invalid slide value '{v}': {e}"))
603 })?);
604 }
605 "gap" => {
606 session_gap_ms = Some(
607 v.trim()
608 .parse::<u64>()
609 .map_err(|e| PlanError::Parse(format!("invalid gap value '{v}': {e}")))?,
610 );
611 }
612 "ttl" => {
613 ttl_ms = Some(
614 v.trim()
615 .parse::<u64>()
616 .map_err(|e| PlanError::Parse(format!("invalid ttl value '{v}': {e}")))?,
617 );
618 }
619 "csize" => {
620 count_size =
621 Some(v.trim().parse::<u64>().map_err(|e| {
622 PlanError::Parse(format!("invalid csize value '{v}': {e}"))
623 })?);
624 }
625 "cslide" => {
626 count_slide =
627 Some(v.trim().parse::<u64>().map_err(|e| {
628 PlanError::Parse(format!("invalid cslide value '{v}': {e}"))
629 })?);
630 }
631 "agg" => agg_kind = Some(v.trim().to_owned()),
632 "col" => agg_col = Some(v.trim().to_owned()),
633 "srcid" => source_id_column = Some(unescape_compact_value(v.trim())),
634 "srcs" => {
635 for pair in v.split(',') {
637 let pair = pair.trim();
638 if pair.is_empty() {
639 continue;
640 }
641 let split_idx = pair
643 .char_indices()
644 .find(|(idx, ch)| *ch == ':' && !is_escaped_colon(pair, *idx))
645 .map(|(idx, _)| idx);
646 let split_idx = split_idx.ok_or_else(|| {
647 PlanError::Parse(format!("srcs entry must be id:lag_ms; got '{pair}'"))
648 })?;
649 let id = unescape_compact_value(&pair[..split_idx]);
650 let lag_str = &pair[split_idx + ':'.len_utf8()..];
651 let lag: u64 = lag_str.trim().parse().map_err(|e| {
652 PlanError::Parse(format!("invalid lag in srcs entry '{pair}': {e}"))
653 })?;
654 source_watermark_lags.insert(id.trim().to_owned(), lag);
655 }
656 }
657 _ => {}
658 }
659 }
660
661 let agg = match agg_kind.as_deref() {
662 None | Some("count") => WindowAgg::count("count"),
663 Some("sum") => WindowAgg {
664 filter: None,
665 kind: WindowAggKind::Sum,
666 input_column: agg_col.clone().ok_or_else(|| {
667 PlanError::Parse(String::from(
668 "stream fragment with agg=sum requires col=<column>",
669 ))
670 })?,
671 output_column: format!("sum_{}", agg_col.as_deref().unwrap_or("val")),
672 },
673 Some("min") => WindowAgg {
674 filter: None,
675 kind: WindowAggKind::Min,
676 input_column: agg_col.clone().ok_or_else(|| {
677 PlanError::Parse(String::from(
678 "stream fragment with agg=min requires col=<column>",
679 ))
680 })?,
681 output_column: format!("min_{}", agg_col.as_deref().unwrap_or("val")),
682 },
683 Some("max") => WindowAgg {
684 filter: None,
685 kind: WindowAggKind::Max,
686 input_column: agg_col.clone().ok_or_else(|| {
687 PlanError::Parse(String::from(
688 "stream fragment with agg=max requires col=<column>",
689 ))
690 })?,
691 output_column: format!("max_{}", agg_col.as_deref().unwrap_or("val")),
692 },
693 Some("avg") => WindowAgg {
694 filter: None,
695 kind: WindowAggKind::Avg,
696 input_column: agg_col.clone().ok_or_else(|| {
697 PlanError::Parse(String::from(
698 "stream fragment with agg=avg requires col=<column>",
699 ))
700 })?,
701 output_column: format!("avg_{}", agg_col.as_deref().unwrap_or("val")),
702 },
703 Some("stddev") => WindowAgg {
704 filter: None,
705 kind: WindowAggKind::Stddev,
706 input_column: agg_col.clone().ok_or_else(|| {
707 PlanError::Parse(String::from(
708 "stream fragment with agg=stddev requires col=<column>",
709 ))
710 })?,
711 output_column: format!("stddev_{}", agg_col.as_deref().unwrap_or("val")),
712 },
713 Some(other) => {
714 return Err(PlanError::Parse(format!(
715 "unknown streaming aggregate '{other}', expected count|sum|min|max|avg|stddev"
716 )));
717 }
718 };
719
720 let window_kind = match window_kind_tag {
721 "tw" => WindowKind::Tumbling,
722 "sw" => WindowKind::Sliding,
723 "ses" => WindowKind::Session,
724 "cw" => WindowKind::Count {
725 size: count_size.ok_or_else(|| {
726 PlanError::Parse(String::from("count-window fragment missing csize=<n>"))
727 })?,
728 slide: count_slide.ok_or_else(|| {
729 PlanError::Parse(String::from("count-window fragment missing cslide=<n>"))
730 })?,
731 },
732 _ => {
733 return Err(PlanError::Parse(format!(
734 "unknown window kind tag '{window_kind_tag}'"
735 )));
736 }
737 };
738
739 Ok(ParsedStreamFragment {
740 window_kind,
741 key_col: key_col
742 .ok_or_else(|| PlanError::Parse(String::from("stream fragment missing key=<col>")))?,
743 time_col: time_col
744 .ok_or_else(|| PlanError::Parse(String::from("stream fragment missing time=<col>")))?,
745 window_ms: {
746 let ms = window_ms.ok_or_else(|| {
747 PlanError::Parse(String::from("stream fragment missing win=<ms>"))
748 })?;
749 if ms == 0 {
750 return Err(PlanError::Parse(String::from(
751 "stream fragment window size must be > 0",
752 )));
753 }
754 ms
755 },
756 lag_ms: lag_ms.unwrap_or(0),
757 slide_ms,
758 session_gap_ms,
759 ttl_ms,
760 agg,
761 source_id_column,
762 source_watermark_lags,
763 })
764}
765
766const STREAM_FIELD_PREFIXES: &[&str] = &[
767 "key=", "time=", "win=", "lag=", "slide=", "gap=", "ttl=", "agg=", "col=", "srcid=", "srcs=",
768 "csize=", "cslide=",
769];
770
771fn is_escaped_colon(payload: &str, idx: usize) -> bool {
774 let mut backslash_count = 0usize;
775 let mut i = idx;
776 while i > 0 {
777 i -= 1;
778 if payload.as_bytes().get(i).is_some_and(|&b| b == b'\\') {
779 backslash_count += 1;
780 } else {
781 break;
782 }
783 }
784 backslash_count % 2 == 1
785}
786
787fn split_stream_fields(payload: &str) -> Vec<&str> {
788 let mut fields = Vec::new();
789 let mut field_start = 0usize;
790
791 for (idx, ch) in payload.char_indices() {
792 if ch != ':' || idx == field_start {
793 continue;
794 }
795 if is_escaped_colon(payload, idx) {
797 continue;
798 }
799 let after_colon = &payload[idx + ch.len_utf8()..];
800 if STREAM_FIELD_PREFIXES
801 .iter()
802 .any(|prefix| after_colon.starts_with(prefix))
803 {
804 fields.push(&payload[field_start..idx]);
805 field_start = idx + ch.len_utf8();
806 }
807 }
808
809 fields.push(&payload[field_start..]);
810 fields
811}
812
813#[cfg(test)]
814mod tests {
815
816 #[test]
817 fn session_window_validates_with_zero_window_size() {
818 use std::collections::HashMap;
819 let make = |gap: Option<u64>| super::WindowExecutionSpec {
823 key_column: "user_id".into(),
824 key_column_type: super::default_key_type(),
825 event_time_column: "ts".into(),
826 watermark_lag_ms: 0,
827 window_kind: super::WindowKind::Session,
828 window_size_ms: 0,
829 slide_ms: None,
830 session_gap_ms: gap,
831 agg_exprs: vec![super::WindowAgg::count("count")],
832 state_ttl_ms: None,
833 allowed_lateness_ms: None,
834 source_watermark_lags: HashMap::new(),
835 source_id_column: None,
836 window_timezone: None,
837 };
838 super::validate_window_execution_spec(&make(Some(10_000)))
839 .expect("session window with window_size_ms == 0 must validate");
840 assert!(super::validate_window_execution_spec(&make(Some(0))).is_err());
842 }
843
844 #[test]
845 fn filtered_agg_fragment_round_trips_via_json() {
846 let mut spec = WindowExecutionSpec::tumbling("k", "ts", 60_000);
847 spec.agg_exprs = vec![WindowAgg {
848 kind: WindowAggKind::Sum,
849 input_column: "size".into(),
850 output_column: "edit_bytes".into(),
851 filter: Some(WindowAggFilter::And(
852 Box::new(WindowAggFilter::Compare {
853 column: "kind".into(),
854 op: AggFilterCompareOp::Eq,
855 value: AggFilterValue::Utf8("edit".into()),
856 }),
857 Box::new(WindowAggFilter::IsNotNull {
858 column: "size".into(),
859 }),
860 )),
861 }];
862 let encoded = encode_stream_fragment(&spec).unwrap();
863 assert!(
864 encoded.starts_with(WINDOW_EXECUTION_SPEC_PREFIX),
865 "filtered aggregates must take the lossless JSON fragment format, got: {encoded}"
866 );
867 let decoded = decode_window_execution_spec(&encoded).unwrap();
868 assert_eq!(decoded, spec, "filter survives the fragment round trip");
869 }
870
871 #[test]
872 fn unfiltered_agg_json_omits_filter_field_for_wire_compat() {
873 let spec = WindowExecutionSpec::tumbling("k", "ts", 60_000);
874 let json = serde_json::to_string(&spec).unwrap();
875 assert!(
876 !json.contains("\"filter\""),
877 "unfiltered aggregates must serialize byte-identically to the pre-filter format"
878 );
879 }
880 use super::*;
881
882 #[test]
883 fn roundtrip_tumbling_fragment() {
884 let spec = WindowExecutionSpec {
885 key_column: "user_id".into(),
886 key_column_type: default_key_type(),
887 event_time_column: "ts".into(),
888 watermark_lag_ms: 1000,
889 window_kind: WindowKind::Tumbling,
890 window_size_ms: 60_000,
891 slide_ms: None,
892 session_gap_ms: None,
893 agg_exprs: vec![WindowAgg::count("count")],
894 state_ttl_ms: Some(30_000),
895 allowed_lateness_ms: None,
896 source_watermark_lags: HashMap::new(),
897 source_id_column: None,
898 window_timezone: None,
899 };
900 let frag = encode_stream_fragment(&spec).unwrap();
901 let parsed = parse_stream_fragment(&frag).expect("parse");
902 assert_eq!(parsed.window_kind, WindowKind::Tumbling);
903 assert_eq!(parsed.window_ms, 60_000);
904 assert_eq!(parsed.lag_ms, 1000);
905 assert_eq!(parsed.ttl_ms, Some(30_000));
906 }
907
908 #[test]
909 fn lossless_window_spec_roundtrip_preserves_all_aggregates() {
910 let mut source_watermark_lags = HashMap::new();
911 source_watermark_lags.insert(String::from("orders"), 1_000);
912 source_watermark_lags.insert(String::from("payments"), 2_000);
913 let spec = WindowExecutionSpec {
914 key_column: String::from("customer_id"),
915 key_column_type: default_key_type(),
916 event_time_column: String::from("event_ts"),
917 watermark_lag_ms: 250,
918 window_kind: WindowKind::Sliding,
919 window_size_ms: 60_000,
920 slide_ms: Some(5_000),
921 session_gap_ms: None,
922 agg_exprs: vec![
923 WindowAgg::count("event_count"),
924 WindowAgg {
925 filter: None,
926 kind: WindowAggKind::Sum,
927 input_column: String::from("amount"),
928 output_column: String::from("gross_amount"),
929 },
930 ],
931 state_ttl_ms: Some(600_000),
932 allowed_lateness_ms: None,
933 source_watermark_lags,
934 source_id_column: Some(String::from("source")),
935 window_timezone: None,
936 };
937
938 let encoded = encode_window_execution_spec(&spec).unwrap();
939 assert!(encoded.starts_with(WINDOW_EXECUTION_SPEC_PREFIX));
940 assert_eq!(decode_window_execution_spec(&encoded).unwrap(), spec);
941 }
942
943 #[test]
944 fn lossless_window_spec_normalizes_empty_aggregate_to_count() {
945 let mut spec = WindowExecutionSpec::tumbling("key", "ts", 1_000);
946 spec.agg_exprs.clear();
947
948 let decoded =
949 decode_window_execution_spec(&encode_window_execution_spec(&spec).unwrap()).unwrap();
950
951 assert_eq!(decoded.agg_exprs, WindowExecutionSpec::default_count_agg());
952 }
953
954 #[test]
955 fn window_spec_validation_rejects_invalid_execution_contracts() {
956 let mut spec = WindowExecutionSpec::tumbling("", "ts", 1_000);
957 assert!(validate_window_execution_spec(&spec).is_err());
958
959 spec.key_column = String::from("key");
960 spec.window_size_ms = 0;
961 assert!(validate_window_execution_spec(&spec).is_err());
962
963 spec.window_size_ms = 1_000;
964 spec.source_watermark_lags
965 .insert(String::from("orders"), 100);
966 assert!(validate_window_execution_spec(&spec).is_err());
967
968 spec.source_id_column = Some(String::from("source"));
969 spec.agg_exprs.push(WindowAgg::count("count"));
970 assert!(validate_window_execution_spec(&spec).is_err());
971 }
972
973 #[test]
974 fn parse_sliding_fragment() {
975 let frag = "stream:sw:key=key:time=ts:win=10000:lag=0:slide=5000:agg=count";
976 let p = parse_stream_fragment(frag).expect("parse");
977 assert_eq!(p.window_kind, WindowKind::Sliding);
978 assert_eq!(p.slide_ms, Some(5000));
979 }
980
981 #[test]
982 fn roundtrip_multi_source_watermark_fragment() {
983 let mut source_watermark_lags = HashMap::new();
984 source_watermark_lags.insert("orders".to_string(), 1_000);
985 source_watermark_lags.insert("payments".to_string(), 2_500);
986 let spec = WindowExecutionSpec {
987 key_column: "customer_id".into(),
988 key_column_type: default_key_type(),
989 event_time_column: "event_ts".into(),
990 watermark_lag_ms: 100,
991 window_kind: WindowKind::Tumbling,
992 window_size_ms: 60_000,
993 slide_ms: None,
994 session_gap_ms: None,
995 agg_exprs: vec![WindowAgg::count("count")],
996 state_ttl_ms: Some(600_000),
997 allowed_lateness_ms: None,
998 source_watermark_lags,
999 source_id_column: Some("source_id".into()),
1000 window_timezone: None,
1001 };
1002
1003 let fragment = encode_stream_fragment(&spec).unwrap();
1004 assert!(
1005 fragment.contains("srcs=orders:1000,payments:2500"),
1006 "multi-source encoding should remain deterministic: {fragment}"
1007 );
1008
1009 let parsed = parse_stream_fragment(&fragment).expect("parse multi-source fragment");
1010 assert_eq!(parsed.source_id_column.as_deref(), Some("source_id"));
1011 assert_eq!(parsed.source_watermark_lags.get("orders"), Some(&1_000));
1012 assert_eq!(parsed.source_watermark_lags.get("payments"), Some(&2_500));
1013 assert_eq!(parsed.source_watermark_lags.len(), 2);
1014 }
1015
1016 #[test]
1017 fn parse_multi_source_watermark_with_colon_values() {
1018 let fragment =
1019 "stream:tw:key=k:time=ts:win=1000:lag=0:agg=count:srcid=source:srcs=a:10,b:20";
1020 let parsed = parse_stream_fragment(fragment).expect("parse");
1021 assert_eq!(parsed.source_watermark_lags.get("a"), Some(&10));
1022 assert_eq!(parsed.source_watermark_lags.get("b"), Some(&20));
1023 }
1024
1025 #[test]
1026 fn parse_invalid_multi_source_lag_errors() {
1027 let fragment = "stream:tw:key=k:time=ts:win=1000:lag=0:agg=count:srcs=a:not-a-number";
1028 let err = parse_stream_fragment(fragment).expect_err("invalid lag should fail");
1029 assert!(
1030 err.to_string().contains("invalid lag in srcs entry"),
1031 "unexpected error: {err}"
1032 );
1033 }
1034
1035 #[test]
1036 fn roundtrip_fragment_with_colon_in_column_name() {
1037 let spec = WindowExecutionSpec {
1038 key_column: "ns:user_id".into(),
1039 key_column_type: default_key_type(),
1040 event_time_column: "ts:ms".into(),
1041 watermark_lag_ms: 100,
1042 window_kind: WindowKind::Tumbling,
1043 window_size_ms: 5_000,
1044 slide_ms: None,
1045 session_gap_ms: None,
1046 agg_exprs: vec![WindowAgg::count("count")],
1047 state_ttl_ms: None,
1048 allowed_lateness_ms: None,
1049 source_watermark_lags: HashMap::new(),
1050 source_id_column: None,
1051 window_timezone: None,
1052 };
1053 let frag = encode_stream_fragment(&spec).unwrap();
1054 let parsed = parse_stream_fragment(&frag).expect("parse escaped fragment");
1055 assert_eq!(parsed.key_col, "ns:user_id");
1056 assert_eq!(parsed.time_col, "ts:ms");
1057 }
1058
1059 #[test]
1060 fn roundtrip_fragment_with_backslash_in_column_name() {
1061 let spec = WindowExecutionSpec {
1062 key_column: "path\\to".into(),
1063 key_column_type: default_key_type(),
1064 event_time_column: "ts".into(),
1065 watermark_lag_ms: 0,
1066 window_kind: WindowKind::Tumbling,
1067 window_size_ms: 1_000,
1068 slide_ms: None,
1069 session_gap_ms: None,
1070 agg_exprs: vec![WindowAgg::count("count")],
1071 state_ttl_ms: None,
1072 allowed_lateness_ms: None,
1073 source_watermark_lags: HashMap::new(),
1074 source_id_column: None,
1075 window_timezone: None,
1076 };
1077 let frag = encode_stream_fragment(&spec).unwrap();
1078 let parsed = parse_stream_fragment(&frag).expect("parse escaped backslash");
1079 assert_eq!(parsed.key_col, "path\\to");
1080 }
1081
1082 #[test]
1083 fn roundtrip_multi_source_with_colon_in_source_id() {
1084 let mut source_watermark_lags = HashMap::new();
1085 source_watermark_lags.insert("ns:orders".to_string(), 1_000);
1086 let spec = WindowExecutionSpec {
1087 key_column: "customer_id".into(),
1088 key_column_type: default_key_type(),
1089 event_time_column: "event_ts".into(),
1090 watermark_lag_ms: 100,
1091 window_kind: WindowKind::Tumbling,
1092 window_size_ms: 60_000,
1093 slide_ms: None,
1094 session_gap_ms: None,
1095 agg_exprs: vec![WindowAgg::count("count")],
1096 state_ttl_ms: None,
1097 allowed_lateness_ms: None,
1098 source_watermark_lags,
1099 source_id_column: Some("src:col".into()),
1100 window_timezone: None,
1101 };
1102 let frag = encode_stream_fragment(&spec).unwrap();
1103 let parsed = parse_stream_fragment(&frag).expect("parse escaped multi-source");
1104 assert_eq!(parsed.source_id_column.as_deref(), Some("src:col"));
1105 assert_eq!(parsed.source_watermark_lags.get("ns:orders"), Some(&1_000));
1106 }
1107
1108 mod adversarial_validation {
1116 use super::*;
1117 use proptest::prelude::*;
1118
1119 fn arb_window_kind() -> impl Strategy<Value = WindowKind> {
1120 prop_oneof![
1121 Just(WindowKind::Tumbling),
1122 Just(WindowKind::Sliding),
1123 Just(WindowKind::Session),
1124 ]
1125 }
1126
1127 fn arb_agg_kind() -> impl Strategy<Value = WindowAggKind> {
1128 prop_oneof![
1129 Just(WindowAggKind::Count),
1130 Just(WindowAggKind::Sum),
1131 Just(WindowAggKind::Min),
1132 Just(WindowAggKind::Max),
1133 Just(WindowAggKind::Avg),
1134 Just(WindowAggKind::Stddev),
1135 ]
1136 }
1137
1138 fn arb_agg() -> impl Strategy<Value = WindowAgg> {
1139 (arb_agg_kind(), "[a-zA-Z0-9_ ]{0,8}", "[a-zA-Z0-9_ ]{0,8}").prop_map(
1140 |(kind, input_column, output_column)| WindowAgg {
1141 filter: None,
1142 kind,
1143 input_column,
1144 output_column,
1145 },
1146 )
1147 }
1148
1149 fn arb_spec() -> impl Strategy<Value = WindowExecutionSpec> {
1150 (
1151 "[a-zA-Z0-9_ ]{0,8}",
1152 "[a-zA-Z0-9_ ]{0,8}",
1153 any::<u64>(),
1154 arb_window_kind(),
1155 any::<u64>(),
1156 proptest::option::of(any::<u64>()),
1157 proptest::option::of(any::<u64>()),
1158 proptest::collection::vec(arb_agg(), 0..4),
1159 proptest::option::of(any::<u64>()),
1160 )
1161 .prop_map(
1162 |(
1163 key_column,
1164 event_time_column,
1165 watermark_lag_ms,
1166 window_kind,
1167 window_size_ms,
1168 slide_ms,
1169 session_gap_ms,
1170 agg_exprs,
1171 state_ttl_ms,
1172 )| WindowExecutionSpec {
1173 key_column,
1174 key_column_type: default_key_type(),
1175 event_time_column,
1176 watermark_lag_ms,
1177 window_kind,
1178 window_size_ms,
1179 slide_ms,
1180 session_gap_ms,
1181 agg_exprs,
1182 state_ttl_ms,
1183 allowed_lateness_ms: None,
1184 source_watermark_lags: HashMap::new(),
1185 source_id_column: None,
1186 window_timezone: None,
1187 },
1188 )
1189 }
1190
1191 proptest! {
1192 #[test]
1196 fn validate_window_execution_spec_never_panics(spec in arb_spec()) {
1197 let _ = validate_window_execution_spec(&spec);
1198 }
1199
1200 #[test]
1204 fn validated_spec_satisfies_invariants(spec in arb_spec()) {
1205 if validate_window_execution_spec(&spec).is_ok() {
1206 prop_assert!(!spec.key_column.trim().is_empty());
1207 prop_assert!(!spec.event_time_column.trim().is_empty());
1208 prop_assert!(spec.window_size_ms > 0);
1209 prop_assert!(!spec.agg_exprs.is_empty());
1210 if spec.window_kind == WindowKind::Sliding {
1211 prop_assert!(matches!(spec.slide_ms, Some(s) if s > 0));
1212 }
1213 if spec.window_kind == WindowKind::Session {
1214 prop_assert!(matches!(spec.session_gap_ms, Some(g) if g > 0));
1215 }
1216 prop_assert_ne!(spec.state_ttl_ms, Some(0));
1217 let mut seen = HashSet::with_capacity(spec.agg_exprs.len());
1218 for agg in &spec.agg_exprs {
1219 prop_assert!(!agg.output_column.trim().is_empty());
1220 prop_assert!(seen.insert(agg.output_column.clone()));
1221 }
1222 }
1223 }
1224 }
1225 }
1226}
1227
1228#[cfg(test)]
1229mod allowed_lateness_tests {
1230 use super::*;
1231
1232 #[test]
1236 fn allowed_lateness_defaults_to_none_and_validates_positive_value() {
1237 let spec = WindowExecutionSpec::tumbling("k", "ts", 1_000);
1238 assert_eq!(spec.allowed_lateness_ms, None);
1239 validate_window_execution_spec(&spec).expect("default spec is valid");
1240
1241 let mut with_lat = spec.clone();
1242 with_lat.allowed_lateness_ms = Some(2_000);
1243 validate_window_execution_spec(&with_lat).expect("positive allowed_lateness_ms is valid");
1244 }
1245
1246 #[test]
1250 fn allowed_lateness_zero_is_rejected() {
1251 let mut spec = WindowExecutionSpec::tumbling("k", "ts", 1_000);
1252 spec.allowed_lateness_ms = Some(0);
1253 let err = validate_window_execution_spec(&spec).unwrap_err();
1254 assert!(format!("{err}").contains("allowed_lateness_ms"));
1255 }
1256}