Skip to main content

krishiv_plan/
window.rs

1//! Streaming window plan configuration and fragment encoding (unified execution).
2
3use std::collections::{HashMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7/// Window operator kind for streaming execution.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum WindowKind {
10    Tumbling,
11    Sliding,
12    Session,
13    /// E3.1 — count-based window: closes every `size` rows, slides every `slide` rows.
14    Count {
15        size: u64,
16        slide: u64,
17    },
18}
19
20/// Aggregate function in a streaming window plan.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum WindowAggKind {
23    Count,
24    Sum,
25    Min,
26    Max,
27    Avg,
28    /// Sample standard deviation (Bessel-corrected, denominator `n-1`).
29    Stddev,
30}
31
32/// Comparison operator inside a [`WindowAggFilter`].
33#[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/// A float literal with bitwise equality so filter ASTs stay `Eq`-comparable.
44#[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/// Literal value a filter compares a column against.
56#[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/// Typed per-aggregate row predicate for streaming windows.
65///
66/// This is the engine-internal lowering target for SQL
67/// `AGG(x) FILTER (WHERE …)` and the `AGG(CASE WHEN … THEN x END)` idiom: a
68/// small, serializable predicate AST the dataflow operators can evaluate with
69/// plain Arrow compute kernels (the dataflow crate deliberately has no SQL or
70/// DataFusion dependency). Rows failing the predicate (or where it evaluates
71/// to NULL) do not feed the aggregate.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub enum WindowAggFilter {
74    /// `column <op> literal`. NULL column values compare as "no match".
75    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    /// Every column name the predicate references (for validation).
93    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/// One aggregate in a windowed stream plan.
109#[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    /// Optional row predicate (`FILTER (WHERE …)` / `CASE WHEN` lowering);
115    /// rows failing it do not feed this aggregate.
116    #[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    /// Attach a row predicate to this aggregate.
131    #[must_use]
132    pub fn with_filter(mut self, filter: WindowAggFilter) -> Self {
133        self.filter = Some(filter);
134        self
135    }
136}
137
138/// Full specification for a keyed, windowed streaming operator.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct WindowExecutionSpec {
141    pub key_column: String,
142    /// Arrow type of the key column as a simple string tag: `"int32"`,
143    /// `"int64"`, `"float64"`, `"utf8"`, or `"bool"`.  Defaults to `"utf8"`.
144    #[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    /// Slide step for sliding windows.
151    pub slide_ms: Option<u64>,
152    /// Session gap for session windows.
153    pub session_gap_ms: Option<u64>,
154    pub agg_exprs: Vec<WindowAgg>,
155    pub state_ttl_ms: Option<u64>,
156    /// ST11: events arriving within `[watermark, watermark + allowed_lateness_ms)`
157    /// are kept for late-firing instead of being dropped. Defaults to
158    /// `None` (no lateness — events past the watermark are dropped).
159    /// When `Some(0)` the behaviour is identical to `None` (drop on
160    /// arrival past the watermark).
161    #[serde(default)]
162    pub allowed_lateness_ms: Option<u64>,
163    /// Per-source fixed-lag watermark (ms). When non-empty, effective watermark is the
164    /// minimum across all configured sources (R5.2 multi-source watermark reconciliation).
165    #[serde(default)]
166    pub source_watermark_lags: HashMap<String, u64>,
167    /// Column identifying the input source for multi-source watermark propagation.
168    #[serde(default)]
169    pub source_id_column: Option<String>,
170    /// Optional timezone for SQL civil-time window bucketing (e.g. "America/New_York").
171    ///
172    /// This is only used for SQL window TVFs (`TUMBLE`, `HOP`, `SESSION`) when the
173    /// user specifies `WITH TIMEZONE`. It affects how event timestamps are bucketed
174    /// into civil-time windows (e.g., daily windows in a specific timezone).
175    /// Watermark comparison and checkpoint ordering are always UTC and are NOT
176    /// affected by this field.
177    #[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
214/// Versioned prefix for lossless JSON-encoded [`WindowExecutionSpec`] values.
215pub const WINDOW_EXECUTION_SPEC_PREFIX: &str = "stream:spec:v1:";
216
217/// Validate and losslessly encode a window execution specification.
218///
219/// An empty aggregate list retains the historical default-count behavior and
220/// is normalized to one `count` aggregate in the encoded representation.
221pub 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
232/// Decode a lossless window specification, accepting legacy compact fragments
233/// for backward compatibility.
234pub 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
269/// Validate invariants required by all continuous window executors.
270pub 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    // Session windows have no fixed size — their extent is driven by
282    // `session_gap_ms` (validated below), so `window_size_ms == 0` is expected
283    // for them and must not be rejected here.
284    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    // ST11: `allowed_lateness_ms = Some(0)` is equivalent to `None` and
342    // is rejected here so the spec round-trips cleanly. `Some(n)` for
343    // `n > 0` is accepted; values larger than `u64::MAX / 2` are
344    // rejected to keep `watermark + allowed_lateness` additions safe.
345    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
416/// Escape `:` and `\` in compact-fragment values so that the colon separator
417/// cannot be confused with literal characters inside column names or source ids.
418fn escape_compact_value(s: &str) -> String {
419    s.replace('\\', "\\\\").replace(':', "\\:")
420}
421
422/// Reverse [`escape_compact_value`].
423fn 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
440/// Encode a window spec as an executor plan fragment description.
441///
442/// Single-aggregate specs use the compact text format for backward
443/// compatibility.  Multi-aggregate specs delegate to the lossless JSON
444/// format because the compact format's `:` field delimiter conflicts with
445/// agg parameter syntax (`agg=sum:col=amount`).
446pub fn encode_stream_fragment(spec: &WindowExecutionSpec) -> Result<String, PlanError> {
447    // Filtered aggregates also need the lossless JSON format: the compact
448    // text format has no filter syntax.
449    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    // Encode multi-source watermark fields: srcid=<col> and srcs=id1:lag1,id2:lag2.
481    // These are omitted when not configured so the fragment stays compact for the
482    // common single-source case.
483    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        // Sort by key for deterministic encoding (HashMap iteration order is unspecified).
493        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/// Parsed streaming window fragment (all window kinds).
523#[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    /// Source-id column name for multi-source watermark tracking.
535    pub source_id_column: Option<String>,
536    /// Per-source fixed watermark lags: source_id → lag_ms.
537    pub source_watermark_lags: HashMap<String, u64>,
538}
539
540use crate::PlanError;
541
542/// Parse `stream:tw|sw|ses:...` fragment strings.
543pub 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                // Format: id1:lag1,id2:lag2  (ids may contain escaped colons)
636                for pair in v.split(',') {
637                    let pair = pair.trim();
638                    if pair.is_empty() {
639                        continue;
640                    }
641                    // Split on the first non-escaped colon.
642                    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
771/// Return `true` if the colon at byte position `idx` is escaped by an odd
772/// number of consecutive backslashes immediately preceding it.
773fn 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        // Skip escaped colons so values like `key=col\:name` are not split.
796        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        // Regression: session windows have no fixed size (extent is driven by
820        // session_gap_ms), so window_size_ms == 0 must pass validation. It was
821        // previously rejected, making SDF.session_window().collect() unusable.
822        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        // A session window still requires a positive gap.
841        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    // ── Fuzz-style adversarial validation (Phase 5: no fuzz coverage for
1109    // validate_window_execution_spec) ──────────────────────────────────────
1110    //
1111    // `cargo-fuzz` requires a nightly toolchain and sanitizer support that
1112    // this workspace does not provision; `proptest` gives equivalent
1113    // adversarial-input coverage (arbitrary/edge-case generation, shrinking
1114    // on failure) entirely on stable, so it is the practical choice here.
1115    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            /// Adversarial inputs (empty/whitespace names, zero/huge durations,
1193            /// missing slide/gap, duplicate output columns, …) must always be
1194            /// rejected or accepted cleanly — never panic.
1195            #[test]
1196            fn validate_window_execution_spec_never_panics(spec in arb_spec()) {
1197                let _ = validate_window_execution_spec(&spec);
1198            }
1199
1200            /// A spec that validates Ok must satisfy the invariants the
1201            /// validator is supposed to enforce, regardless of how the
1202            /// arbitrary input was shaped.
1203            #[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    /// ST11: `allowed_lateness_ms` defaults to `None` (no lateness) on
1233    /// the `tumbling` constructor and is accepted as a positive value
1234    /// by the validator.
1235    #[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    /// ST11: `Some(0)` is rejected by the validator so a round-trip
1247    /// through `encode_window_execution_spec` cannot store a zero
1248    /// lateness that would be indistinguishable from `None` on read.
1249    #[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}