Skip to main content

risingwave_stream/executor/match_recognize/
proto.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Decode the structured row-pattern proto into the executor-side [`Pattern`]. The frontend lowers
16//! the SQL `PATTERN` clause directly into this proto tree, so there is no textual round-trip.
17
18use risingwave_pb::stream_plan::match_recognize_pattern_node::Node;
19use risingwave_pb::stream_plan::match_recognize_quantifier::Kind;
20use risingwave_pb::stream_plan::{MatchRecognizePatternNode, MatchRecognizeQuantifier};
21
22use super::nfa::{Pattern, Quantifier};
23
24/// Decode-side re-statement of the binder's `MAX_PERMUTE_VARS`: `PERMUTE` expands to `n!`
25/// orderings, so a skewed or corrupt plan carrying an oversized one would allocate factorially
26/// *before* any row is processed. Every other malformed input to this decoder fails with a
27/// descriptive error; the sizes must too.
28const MAX_PERMUTE_VARS: usize = 6;
29
30/// Decode-side re-statement of the binder's `MAX_QUANTIFIER_BOUND`: a range bound is a state
31/// multiplier for the compiled NFA (`a{4_000_000_000}` would try to build billions of states).
32const MAX_QUANTIFIER_BOUND: u32 = 1000;
33
34/// Decode-side re-statement of the binder's `MAX_PATTERN_NFA_STATES`.
35///
36/// The two caps above bound each construct individually, which is not enough: nesting multiplies.
37/// `(a{1000}){1000}` satisfies both and still expands to ~10^6 states, allocated by `Nfa::compile`
38/// on the compute node while the actor is built. Only reachable from a corrupt or skewed plan —
39/// which is precisely what this layer exists to survive.
40const MAX_PATTERN_NFA_STATES: u64 = 100_000;
41
42/// Estimated compiled-NFA state count, mirroring the binder's `estimate_nfa_states` over the
43/// decoded pattern. Saturating throughout: the point is to reject the absurd, and a saturated
44/// total is still over the cap. `PERMUTE` is counted in its expanded form (`n!` orderings), the
45/// same shape the binder assumes.
46fn estimate_nfa_states(pattern: &Pattern) -> u64 {
47    match pattern {
48        Pattern::Var(_) => 2,
49        Pattern::Permute(vars) => {
50            let n = vars.len() as u64;
51            let orderings = (1..=n)
52                .try_fold(1u64, |acc, i| acc.checked_mul(i))
53                .unwrap_or(u64::MAX);
54            orderings
55                .saturating_mul(n.saturating_mul(2))
56                .saturating_add(2)
57        }
58        Pattern::Concat(ps) => ps
59            .iter()
60            .map(estimate_nfa_states)
61            .fold(0u64, u64::saturating_add)
62            .max(1),
63        Pattern::Alt(ps) => ps
64            .iter()
65            .map(estimate_nfa_states)
66            .fold(2u64, u64::saturating_add),
67        Pattern::Quantified(inner, q, _) => {
68            let inner = estimate_nfa_states(inner);
69            match q {
70                Quantifier::Star | Quantifier::Question => inner.saturating_add(2),
71                Quantifier::Plus => inner.saturating_mul(2).saturating_add(2),
72                // `min` mandatory copies, then either an unbounded `*` tail or `max - min`
73                // optional copies.
74                Quantifier::Range { min, max } => {
75                    let mandatory = inner.saturating_mul(u64::from(*min));
76                    match max {
77                        None => mandatory.saturating_add(inner).saturating_add(2),
78                        Some(max) => {
79                            // Each optional copy is expanded as `Question` by `build_range`, and
80                            // `Question` allocates its own start/accept pair on top of the inner
81                            // fragment — so it costs `inner + 2`, not `inner`. Undercounting by 2
82                            // per copy let `(a{0,1000}){0,50}` estimate exactly at the cap while
83                            // really compiling to a little over twice it.
84                            let optional = inner
85                                .saturating_add(2)
86                                .saturating_mul(u64::from(max.saturating_sub(*min)));
87                            mandatory.saturating_add(optional).max(1)
88                        }
89                    }
90                }
91            }
92        }
93    }
94}
95
96/// Build a [`Pattern`] from its protobuf representation, rejecting one whose compiled size would be
97/// absurd. The size check is on the whole decoded pattern, since nesting is what defeats the
98/// per-construct caps.
99pub fn pattern_from_protobuf(pb: &MatchRecognizePatternNode) -> Result<Pattern, String> {
100    let pattern = decode_pattern(pb)?;
101    let states = estimate_nfa_states(&pattern);
102    if states > MAX_PATTERN_NFA_STATES {
103        return Err(format!(
104            "the pattern expands to an estimated {states} NFA states, above the supported \
105             maximum of {MAX_PATTERN_NFA_STATES}"
106        ));
107    }
108    Ok(pattern)
109}
110
111fn decode_pattern(pb: &MatchRecognizePatternNode) -> Result<Pattern, String> {
112    let node = pb
113        .node
114        .as_ref()
115        .ok_or_else(|| "empty MATCH_RECOGNIZE pattern node".to_owned())?;
116    Ok(match node {
117        Node::Var(v) => Pattern::Var(v.clone()),
118        Node::Concat(seq) => Pattern::Concat(patterns_from_protobuf(&seq.patterns)?),
119        Node::Alternation(seq) => Pattern::Alt(patterns_from_protobuf(&seq.patterns)?),
120        Node::Permute(p) => {
121            if p.vars.len() > MAX_PERMUTE_VARS {
122                return Err(format!(
123                    "PERMUTE over {} variables exceeds the supported maximum of {}",
124                    p.vars.len(),
125                    MAX_PERMUTE_VARS
126                ));
127            }
128            Pattern::Permute(p.vars.clone())
129        }
130        Node::Quantified(q) => {
131            let inner = q
132                .inner
133                .as_ref()
134                .ok_or_else(|| "quantified pattern missing inner".to_owned())?;
135            let quantifier = quantifier_from_protobuf(
136                q.quantifier
137                    .as_ref()
138                    .ok_or_else(|| "quantified pattern missing quantifier".to_owned())?,
139            )?;
140            Pattern::Quantified(Box::new(decode_pattern(inner)?), quantifier, q.reluctant)
141        }
142    })
143}
144
145fn patterns_from_protobuf(patterns: &[MatchRecognizePatternNode]) -> Result<Vec<Pattern>, String> {
146    patterns.iter().map(decode_pattern).collect()
147}
148
149fn quantifier_from_protobuf(q: &MatchRecognizeQuantifier) -> Result<Quantifier, String> {
150    Ok(match q.kind() {
151        Kind::Star => Quantifier::Star,
152        Kind::Plus => Quantifier::Plus,
153        Kind::Question => Quantifier::Question,
154        Kind::Range => {
155            if q.min > MAX_QUANTIFIER_BOUND || q.max.is_some_and(|m| m > MAX_QUANTIFIER_BOUND) {
156                return Err(format!(
157                    "quantifier bound {{{},{:?}}} exceeds the supported maximum of {}",
158                    q.min, q.max, MAX_QUANTIFIER_BOUND
159                ));
160            }
161            // The binder rejects inverted bounds; from a corrupt or skewed plan they would
162            // silently compile to `{min}` semantics (the `min..max` expansion is just empty).
163            if q.max.is_some_and(|m| m < q.min) {
164                return Err(format!(
165                    "quantifier bound {{{},{:?}}} has max < min",
166                    q.min, q.max
167                ));
168            }
169            Quantifier::Range {
170                min: q.min,
171                max: q.max,
172            }
173        }
174        Kind::Unspecified => {
175            return Err("unspecified MATCH_RECOGNIZE quantifier kind".to_owned());
176        }
177    })
178}
179
180#[cfg(test)]
181mod tests {
182
183    /// The whole-pattern state cap must be re-checked here, not only in the binder. Each nested
184    /// quantifier below is inside its per-construct bound, so `MAX_QUANTIFIER_BOUND` passes it —
185    /// yet the product expands to ~10^6 states, which `Nfa::compile` would allocate on the compute
186    /// node while the actor is being built.
187    #[test]
188    fn an_oversized_whole_pattern_is_rejected_even_when_every_construct_is_in_bounds() {
189        let inner = quantified(var("a"), Kind::Range, 1000, Some(1000), false);
190        let nested = quantified(inner, Kind::Range, 1000, Some(1000), false);
191        let err = pattern_from_protobuf(&nested).expect_err("must not decode");
192        assert!(
193            err.contains("states"),
194            "the error should name the state expansion, got: {err}"
195        );
196    }
197
198    /// A realistic pattern is nowhere near the cap and must still decode.
199    /// A bounded range with `max > min` must count each OPTIONAL copy at the price the compiler
200    /// actually pays. `build_range` expands `max - min` of them as `Question`, and `Question`
201    /// allocates its own start/accept pair on top of the inner fragment — so an optional copy costs
202    /// `inner + 2`, not `inner`.
203    ///
204    /// The existing oversize test uses `{1000,1000}`, where `max == min` makes the optional term
205    /// zero, so it cannot see this. `(a{0,1000}){0,50}` is the smallest shape that can: it estimates
206    /// exactly at the cap while really compiling to a little over twice it.
207    #[test]
208    fn a_bounded_range_counts_the_state_pair_each_optional_copy_allocates() {
209        let inner = quantified(var("a"), Kind::Range, 0, Some(1000), false);
210        let nested = quantified(inner, Kind::Range, 0, Some(50), false);
211        assert!(
212            pattern_from_protobuf(&nested).is_err(),
213            "must be rejected: this compiles to ~200k states, twice the cap"
214        );
215    }
216
217    #[test]
218    fn an_ordinary_pattern_is_well_under_the_state_cap() {
219        let pat = concat(vec![
220            var("d"),
221            quantified(var("b"), Kind::Star, 0, None, false),
222            var("w"),
223        ]);
224        assert!(pattern_from_protobuf(&pat).is_ok());
225    }
226    use risingwave_pb::stream_plan::match_recognize_pattern_node::Node;
227    use risingwave_pb::stream_plan::{
228        MatchRecognizePatternNode, MatchRecognizePatternSeq, MatchRecognizePermutePattern,
229        MatchRecognizeQuantifiedPattern, MatchRecognizeQuantifier,
230    };
231
232    use super::*;
233
234    fn var(name: &str) -> MatchRecognizePatternNode {
235        MatchRecognizePatternNode {
236            node: Some(Node::Var(name.to_owned())),
237        }
238    }
239
240    fn quantifier(kind: Kind, min: u32, max: Option<u32>) -> MatchRecognizeQuantifier {
241        MatchRecognizeQuantifier {
242            kind: kind as i32,
243            min,
244            max,
245        }
246    }
247
248    fn quantified(
249        inner: MatchRecognizePatternNode,
250        kind: Kind,
251        min: u32,
252        max: Option<u32>,
253        reluctant: bool,
254    ) -> MatchRecognizePatternNode {
255        MatchRecognizePatternNode {
256            node: Some(Node::Quantified(Box::new(
257                MatchRecognizeQuantifiedPattern {
258                    inner: Some(Box::new(inner)),
259                    quantifier: Some(quantifier(kind, min, max)),
260                    reluctant,
261                },
262            ))),
263        }
264    }
265
266    fn concat(patterns: Vec<MatchRecognizePatternNode>) -> MatchRecognizePatternNode {
267        MatchRecognizePatternNode {
268            node: Some(Node::Concat(MatchRecognizePatternSeq { patterns })),
269        }
270    }
271
272    fn alt(patterns: Vec<MatchRecognizePatternNode>) -> MatchRecognizePatternNode {
273        MatchRecognizePatternNode {
274            node: Some(Node::Alternation(MatchRecognizePatternSeq { patterns })),
275        }
276    }
277
278    #[test]
279    fn decode_concat() {
280        assert_eq!(
281            pattern_from_protobuf(&concat(vec![var("a"), var("b"), var("c")])).unwrap(),
282            Pattern::Concat(vec![
283                Pattern::Var("a".to_owned()),
284                Pattern::Var("b".to_owned()),
285                Pattern::Var("c".to_owned()),
286            ])
287        );
288    }
289
290    #[test]
291    fn decode_quantifiers() {
292        assert_eq!(
293            pattern_from_protobuf(&concat(vec![
294                var("a"),
295                quantified(var("b"), Kind::Plus, 0, None, false),
296                quantified(var("c"), Kind::Question, 0, None, false),
297            ]))
298            .unwrap(),
299            Pattern::Concat(vec![
300                Pattern::Var("a".to_owned()),
301                Pattern::Quantified(
302                    Box::new(Pattern::Var("b".to_owned())),
303                    Quantifier::Plus,
304                    false
305                ),
306                Pattern::Quantified(
307                    Box::new(Pattern::Var("c".to_owned())),
308                    Quantifier::Question,
309                    false
310                ),
311            ])
312        );
313        assert_eq!(
314            pattern_from_protobuf(&quantified(var("a"), Kind::Star, 0, None, true)).unwrap(),
315            Pattern::Quantified(
316                Box::new(Pattern::Var("a".to_owned())),
317                Quantifier::Star,
318                true
319            )
320        );
321    }
322
323    #[test]
324    fn decode_alternation_and_range() {
325        assert_eq!(
326            pattern_from_protobuf(&concat(vec![
327                alt(vec![var("a"), var("b")]),
328                quantified(var("c"), Kind::Range, 1, Some(3), false),
329            ]))
330            .unwrap(),
331            Pattern::Concat(vec![
332                Pattern::Alt(vec![
333                    Pattern::Var("a".to_owned()),
334                    Pattern::Var("b".to_owned())
335                ]),
336                Pattern::Quantified(
337                    Box::new(Pattern::Var("c".to_owned())),
338                    Quantifier::Range {
339                        min: 1,
340                        max: Some(3)
341                    },
342                    false
343                ),
344            ])
345        );
346    }
347
348    #[test]
349    fn decode_permute() {
350        assert_eq!(
351            pattern_from_protobuf(&MatchRecognizePatternNode {
352                node: Some(Node::Permute(MatchRecognizePermutePattern {
353                    vars: vec!["a".to_owned(), "b".to_owned(), "c".to_owned()],
354                })),
355            })
356            .unwrap(),
357            Pattern::Permute(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()])
358        );
359    }
360
361    #[test]
362    fn rejects_empty_node() {
363        assert!(pattern_from_protobuf(&MatchRecognizePatternNode { node: None }).is_err());
364    }
365
366    /// The one corrupt-plan input that would allocate factorially/linearly-in-billions BEFORE any
367    /// row is processed must fail like every other malformed decode, not OOM the compute node.
368    #[test]
369    fn rejects_oversized_permute_and_range() {
370        let vars: Vec<String> = (0..7).map(|i| format!("v{i}")).collect();
371        assert!(
372            pattern_from_protobuf(&MatchRecognizePatternNode {
373                node: Some(Node::Permute(MatchRecognizePermutePattern { vars })),
374            })
375            .unwrap_err()
376            .contains("PERMUTE")
377        );
378        assert!(
379            pattern_from_protobuf(&quantified(
380                var("a"),
381                Kind::Range,
382                4_000_000_000,
383                None,
384                false
385            ))
386            .unwrap_err()
387            .contains("quantifier bound")
388        );
389        assert!(
390            pattern_from_protobuf(&quantified(
391                var("a"),
392                Kind::Range,
393                1,
394                Some(4_000_000_000),
395                false
396            ))
397            .unwrap_err()
398            .contains("quantifier bound")
399        );
400    }
401}