Skip to main content

risingwave_stream/executor/match_recognize/
executor.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//! Streaming `MATCH_RECOGNIZE` executor over **ordered input**.
16//!
17//! Scope: append-only input, `ONE ROW PER MATCH`,
18//! `AFTER MATCH SKIP {PAST LAST ROW | TO NEXT ROW | TO {FIRST | LAST} <var>}`,
19//! `MEASURES` with per-variable navigation (`FIRST`/`LAST`/bare `var.col`) and `CLASSIFIER()`.
20//!
21//! The ordered-input model: an upstream `EowcSort` (same fragment) delivers rows already in
22//! full `ORDER BY` order, strictly below each watermark it then forwards, with partitions
23//! interleaved. This executor therefore owns **only NFA state and match finalization**: each
24//! partition keeps an [`IncrementalMatcher`] fed on arrival, plus the retained rows its live
25//! matches still reference. There is no out-of-order buffer here, and consumed history is never
26//! rescanned; the matcher does re-scan the *unfrozen suffix* on arrival, so per-row work is
27//! proportional to the rows a still-open partial holds live, not to the new rows alone.
28//!
29//! Emission is on decidability, gated by [`match_is_final`]: the first provisional match is
30//! emitted once no gap position before it is alive at the boundary (an alive gap could still
31//! yield an earlier, leftmost-preferred match), and no more-preferred path from its own start can
32//! be completed by future rows ([`Nfa::may_extend`] probed at the buffer boundary), or its
33//! `WITHIN` deadline has strictly passed the watermark, which decides both questions at once.
34//! Consumed rows are deleted; rows are retained only while a live partial or held match references
35//! them. `AFTER MATCH SKIP TO FIRST|LAST` degradations and scan-budget exhaustion are *reported*,
36//! never silent and never actor-fatal — see [`report_skip_degradation_once`] and
37//! [`report_scan_budget_once`].
38//!
39//! State: one table of the retained rows, keyed `(partition..., order..., seq)`. Recovery rebuilds
40//! each partition's matcher by re-feeding its retained rows in key order — deterministic, so
41//! replayed emission (the hidden `_match_id` is the match's start-row `seq`) is byte-identical.
42//! That guarantee is scoped to recovery and rescale, for matches anchored at committed rows:
43//! a match whose start row was uncommitted at a crash re-mints that row's seq on replay (as
44//! generated ids do system-wide), and `seq` freezes each tie's *arrival* order, so re-creating
45//! the MV or replaying the topic may interleave equal-ORDER-BY rows differently and legitimately
46//! produce different matches (the standard leaves tie order implementation-defined).
47
48use std::collections::HashMap;
49
50use futures::{StreamExt, pin_mut};
51use risingwave_common::array::{Op, StreamChunk};
52use risingwave_common::hash::VnodeBitmapExt;
53use risingwave_common::row::{OwnedRow, Row, RowExt, once};
54use risingwave_common::types::{DataType, Datum, DefaultOrd, ScalarImpl, ToOwnedDatum};
55use risingwave_expr::ExprError;
56use risingwave_expr::aggregate::{AggCall, BoxedAggregateFunction, build_append_only};
57use risingwave_expr::expr::{EvalErrorReport, NonStrictExpression, build_non_strict_from_prost};
58use risingwave_pb::stream_plan::{
59    MatchRecognizeDefine as PbMatchRecognizeDefine,
60    MatchRecognizeMeasure as PbMatchRecognizeMeasure,
61};
62use risingwave_storage::StateStore;
63
64use super::incremental::{Finalized, IncrementalMatcher, Seq};
65use super::nfa::{CandidateMatcher, Nfa, ScanBudget, SkipDegradation, SkipMode};
66use crate::common::table::state_table::StateTable;
67use crate::executor::monitor::MatchRecognizeMetrics;
68use crate::executor::prelude::*;
69use crate::task::ActorEvalErrorReport;
70
71/// Report an `AFTER MATCH SKIP` degradation ([`SkipDegradation`]) — unless the same degradation was
72/// already reported in this watermark pass, in which case it is dropped.
73///
74/// The condition is data-dependent and deliberately not fatal (see [`SkipMode::next_pos`] for why an
75/// error would turn a committed materialized view into a crash loop), so the only thing left is to
76/// make it visible. It goes to the actor's [`EvalErrorReport`], which is the surface every expression
77/// evaluation error in this operator already uses: the rate-limited `stream_expr_error` log and the
78/// `user_compute_error` metric, labelled `["ExprError", executor_name, fragment_id]`.
79///
80/// **The carrier is new, the surface is not.** Nothing else in the tree hands `EvalErrorReport` a
81/// *synthesized* error — every other reporter passes on an error produced by an actual expression
82/// evaluation. [`ExprError`] is nonetheless the only type the trait accepts, and
83/// `ExprError::InvalidParam` is the honest fit: the query's `AFTER MATCH SKIP` parameter cannot be
84/// honored. (`ExprError::Custom` was rejected — it is the UDF error channel and slated for removal;
85/// `Internal`/`InvalidState` would misreport a user-query problem as an engine fault.) Two
86/// consequences to expect when reading the output:
87///
88///  * the log line carries the surface's fixed prefix `failed to evaluate expression`, hardcoded in
89///    `ActorContext::on_compute_error`, even though no expression was evaluated here. The actionable
90///    content is the `error=` field, which is self-contained;
91///  * the metric labels separate this operator from others, but not this operator's own
92///    `DEFINE`/`MEASURES`/`WITHIN` evaluation errors, which report through the same labels. So the
93///    metric reads as "this `MATCH_RECOGNIZE` query is unhealthy" and the log line is what says why.
94///
95/// **Volume policy.** The cause is a property of the query, not of one row: a skip target that no
96/// match can ever bind degrades on every match, forever, and even a target that only *sometimes*
97/// fails to bind (`PATTERN (a? b)` with `SKIP TO FIRST b`, degrading on the matches where `a` did not
98/// bind) repeats without bound. The diagnostic names the skip clause, its target variable and the
99/// applied fallback — and nothing row-, match- or partition-specific — so every repetition within one
100/// watermark pass is a byte-identical duplicate carrying no new information, at the cost of a
101/// `format!` on the emit path. `already_reported` therefore holds the kinds already reported in this
102/// pass (at most two exist, and `Vec::new()` allocates only if one actually fires); it is reset per
103/// pass, so a persisting condition keeps producing one report per kind per watermark — a steady
104/// signal, bounded by watermark frequency rather than by match or partition count. The trade-off is
105/// deliberate: the metric counts *passes* that degraded, not degradations.
106fn report_skip_degradation_once(
107    report: &impl EvalErrorReport,
108    skip: &SkipMode,
109    degradation: SkipDegradation,
110    already_reported: &mut Vec<SkipDegradation>,
111) {
112    if already_reported.contains(&degradation) {
113        return;
114    }
115    already_reported.push(degradation);
116    // The mode is named once, by `clause_name`; `describe` names the target variable and the fallback.
117    report.report(ExprError::InvalidParam {
118        name: skip.clause_name(),
119        reason: degradation.describe(skip).into(),
120    });
121}
122
123/// Walk steps — predicate evaluations plus recursion descents, ε-transitions included — one
124/// partition visit may spend across all its NFA walks (matching, eviction liveness, extension
125/// probing) before the visit degrades; see [`ScanBudget`]. The
126/// matcher's worst case is exponential for pathological patterns whose `DEFINE`s read the running
127/// label assignment (path-independent patterns are memoized and never approach this); the budget
128/// converts that from a pinned compute node into a bounded, reported degradation. Sized so that
129/// realistic patterns stay orders of magnitude below it: a memoized scan costs
130/// O(states × rows) per start.
131const SCAN_BUDGET_EVALUATIONS: usize = 1 << 20;
132
133/// Report a spent [`ScanBudget`] — once per message pass (the cause is a property of the query
134/// and its buffered data, so per-visit repeats add volume, not information).
135fn report_scan_budget_once(report: &impl EvalErrorReport, already_reported: &mut bool) {
136    if *already_reported {
137        return;
138    }
139    *already_reported = true;
140    report.report(ExprError::InvalidParam {
141        name: "MATCH_RECOGNIZE",
142        reason: format!(
143            "pattern-match scan budget ({SCAN_BUDGET_EVALUATIONS} predicate evaluations) \
144             exhausted while processing one partition; the partition is left undecided for this \
145             visit (nothing emitted or evicted beyond what was already decided) and will be \
146             retried. This indicates a pattern with catastrophic backtracking over the buffered \
147             data — simplify nested optional/alternation quantifiers, or add/tighten WITHIN"
148        )
149        .into(),
150    });
151}
152
153/// How a [`MeasureSlot`] resolves against the rows of a match: the wire enum, used directly (the
154/// variants are documented in `stream_plan.proto`) — a parallel executor-side enum was one more
155/// thing to keep in lockstep with the planner for no representational gain. `Unspecified` is
156/// rejected in [`CompiledMeasure::from_protobuf`], so no constructed slot carries it.
157use risingwave_pb::stream_plan::match_recognize_measure_slot::Kind as MeasureSlotKind;
158
159/// A `SUM`/`AVG` aggregate kernel for a slot, plus the input column type used to feed it.
160struct AggSlot {
161    func: BoxedAggregateFunction,
162    col_type: DataType,
163}
164
165/// One navigation input that a measure expression reads. The executor materializes one value per
166/// slot from a match's rows and labels, forming the synthetic row the measure is evaluated over.
167struct MeasureSlot {
168    kind: MeasureSlotKind,
169    /// Pattern variables this slot navigates over (several for a `SUBSET`). A row matches if its
170    /// label is any of these. Empty for [`MeasureSlotKind::Classifier`].
171    vars: Vec<String>,
172    /// Input column index to read. Unused for [`MeasureSlotKind::Classifier`].
173    col_idx: usize,
174    /// The aggregate kernel for [`MeasureSlotKind::Sum`] (`AVG` is lowered to `Sum` plus `Count`).
175    agg: Option<AggSlot>,
176}
177
178/// A `MEASURES` item compiled for execution.
179pub struct CompiledMeasure {
180    /// Expression over the synthetic per-match row: `InputRef(i)` reads `slots[i]`.
181    expr: NonStrictExpression,
182    slots: Vec<MeasureSlot>,
183}
184
185impl CompiledMeasure {
186    /// Builds a compiled measure from its protobuf, building any aggregate kernels its slots need.
187    pub fn from_protobuf(
188        pb: &PbMatchRecognizeMeasure,
189        error_report: impl EvalErrorReport + 'static,
190    ) -> StreamExecutorResult<Self> {
191        let expr = build_non_strict_from_prost(
192            pb.expr
193                .as_ref()
194                .ok_or_else(|| anyhow::anyhow!("MATCH_RECOGNIZE measure missing expression"))?,
195            error_report,
196        )?;
197        let slots = pb
198            .slots
199            .iter()
200            .map(|s| {
201                let kind = s.kind();
202                // Fail fast rather than silently changing measure semantics under a corrupt plan
203                // or version skew. (An out-of-range wire value decodes as UNSPECIFIED via
204                // `s.kind()`.) Every later match on the kind relies on this rejection.
205                if kind == MeasureSlotKind::Unspecified {
206                    return Err(anyhow::anyhow!(
207                        "invalid MATCH_RECOGNIZE measure slot kind: {}",
208                        s.kind
209                    )
210                    .into());
211                }
212                let agg = match kind {
213                    MeasureSlotKind::Sum => {
214                        let call =
215                            AggCall::from_protobuf(s.agg_call.as_ref().ok_or_else(|| {
216                                anyhow::anyhow!(
217                                    "MATCH_RECOGNIZE SUM/AVG measure slot missing agg_call"
218                                )
219                            })?)?;
220                        let col_type = call.args.arg_types()[0].clone();
221                        let func = build_append_only(&call)?;
222                        Some(AggSlot { func, col_type })
223                    }
224                    _ => None,
225                };
226                Ok(MeasureSlot {
227                    kind,
228                    vars: s.vars.clone(),
229                    col_idx: s.col_idx as usize,
230                    agg,
231                })
232            })
233            .collect::<StreamExecutorResult<Vec<_>>>()?;
234        Ok(CompiledMeasure { expr, slots })
235    }
236}
237
238impl MeasureSlot {
239    /// Resolves this slot against a match: `rows[start..]` are the matched rows and `labels[i]` is
240    /// the pattern variable bound to `rows[start + i]`.
241    async fn resolve(
242        &self,
243        rows: &[BufferedRow],
244        start: usize,
245        labels: &[String],
246        error_report: &impl risingwave_expr::expr::EvalErrorReport,
247    ) -> StreamExecutorResult<Datum> {
248        // The column value of the row at match-relative index `j`.
249        let col_at = |j: usize| rows[start + j].row.datum_at(self.col_idx).to_owned_datum();
250        // Whether a row's label is one this slot navigates over (a plain var, or any SUBSET member).
251        let matches = |l: &String| self.vars.iter().any(|v| v == l);
252        Ok(match self.kind {
253            MeasureSlotKind::Classifier => {
254                labels.last().map(|s| ScalarImpl::Utf8(s.as_str().into()))
255            }
256            MeasureSlotKind::First => labels.iter().position(&matches).and_then(col_at),
257            MeasureSlotKind::Last => labels.iter().rposition(&matches).and_then(col_at),
258            MeasureSlotKind::CountStar => Some(ScalarImpl::Int64(labels.len() as i64)),
259            MeasureSlotKind::Count => {
260                // Compare by reference: owning every candidate datum just to count non-nulls
261                // would clone each one.
262                let n = labels
263                    .iter()
264                    .enumerate()
265                    .filter(|(j, l)| {
266                        matches(l) && rows[start + *j].row.datum_at(self.col_idx).is_some()
267                    })
268                    .count();
269                Some(ScalarImpl::Int64(n as i64))
270            }
271            MeasureSlotKind::Min => labels
272                .iter()
273                .enumerate()
274                .filter(|(_, l)| matches(l))
275                .filter_map(|(j, _)| rows[start + j].row.datum_at(self.col_idx))
276                .min_by(|a, b| a.default_cmp(b))
277                .map(|r| r.into_scalar_impl()),
278            MeasureSlotKind::Max => labels
279                .iter()
280                .enumerate()
281                .filter(|(_, l)| matches(l))
282                .filter_map(|(j, _)| rows[start + j].row.datum_at(self.col_idx))
283                .max_by(|a, b| a.default_cmp(b))
284                .map(|r| r.into_scalar_impl()),
285            // Rejected in `from_protobuf`; a constructed slot never carries it. NULL (the
286            // non-strict convention) rather than a panic, should that invariant ever break.
287            MeasureSlotKind::Unspecified => None,
288            MeasureSlotKind::Sum => {
289                let agg = self.agg.as_ref().ok_or_else(|| {
290                    anyhow::anyhow!("MATCH_RECOGNIZE SUM measure slot has no kernel")
291                })?;
292                // Feed the kernel a single-column chunk of the col values over the matching rows.
293                let input: Vec<(Op, OwnedRow)> = labels
294                    .iter()
295                    .enumerate()
296                    .filter(|(_, l)| matches(l))
297                    .map(|(j, _)| (Op::Insert, OwnedRow::new(vec![col_at(j)])))
298                    .collect();
299                if input.is_empty() {
300                    None
301                } else {
302                    let chunk = StreamChunk::from_rows(&input, std::slice::from_ref(&agg.col_type));
303                    // A kernel error here is a DATA error — numeric overflow in SUM is the
304                    // canonical one — on rows that recovery will replay verbatim: propagating it
305                    // kills the actor and every restart replays the same rows into the same
306                    // overflow, an unrecoverable crash loop from one bad match. Mirror what
307                    // `NonStrictExpression` does for every other expression in this operator:
308                    // report through the actor's error report and yield NULL for the measure.
309                    let evaluated = async {
310                        let mut state = agg.func.create_state()?;
311                        agg.func.update(&mut state, &chunk).await?;
312                        agg.func.get_result(&state).await
313                    }
314                    .await;
315                    match evaluated {
316                        Ok(d) => d,
317                        Err(e) => {
318                            error_report.report(e);
319                            None
320                        }
321                    }
322                }
323            }
324        })
325    }
326}
327
328/// How a [`DefineSlot`] resolves against the candidate row: the wire enum, used directly (see
329/// [`MeasureSlotKind`] for the rationale). `Unspecified` and physical `Next` — which the binder
330/// rejects in `DEFINE` — are rejected in [`CompiledDefine::from_protobuf`], so no constructed
331/// slot carries them.
332use risingwave_pb::stream_plan::match_recognize_define_slot::Kind as DefineSlotKind;
333
334/// One input a `DEFINE` predicate reads (mirrors the planner's [`DefineSlot`]).
335struct DefineSlot {
336    kind: DefineSlotKind,
337    vars: Vec<String>,
338    col_idx: usize,
339    offset: usize,
340}
341
342/// A `DEFINE` predicate compiled for execution: a boolean condition over a synthetic slot row.
343pub struct CompiledDefine {
344    symbol: String,
345    condition: NonStrictExpression,
346    slots: Vec<DefineSlot>,
347}
348
349impl CompiledDefine {
350    pub fn from_protobuf(
351        pb: &PbMatchRecognizeDefine,
352        error_report: impl EvalErrorReport + 'static,
353    ) -> StreamExecutorResult<Self> {
354        let condition = build_non_strict_from_prost(
355            pb.condition
356                .as_ref()
357                .ok_or_else(|| anyhow::anyhow!("MATCH_RECOGNIZE define missing condition"))?,
358            error_report,
359        )?;
360        let slots = pb
361            .slots
362            .iter()
363            .map(|s| {
364                let kind = s.kind();
365                // The binder rejects physical NEXT in DEFINE (a verdict depending on rows after
366                // the candidate needs per-candidate decidability), so no plan this frontend
367                // produces carries it — reject rather than evaluate a watermark-unsafe,
368                // arrival-order-dependent read from a skewed plan. UNSPECIFIED (also what an
369                // out-of-range wire value decodes to) fails fast rather than silently changing
370                // the predicate's meaning. Every later match on the kind relies on this.
371                if kind == DefineSlotKind::Next {
372                    return Err(StreamExecutorError::from(anyhow::anyhow!(
373                        "physical NEXT in a MATCH_RECOGNIZE DEFINE is not supported"
374                    )));
375                }
376                if kind == DefineSlotKind::Unspecified {
377                    return Err(StreamExecutorError::from(anyhow::anyhow!(
378                        "invalid MATCH_RECOGNIZE define slot kind: {}",
379                        s.kind
380                    )));
381                }
382                Ok(DefineSlot {
383                    kind,
384                    vars: s.vars.clone(),
385                    col_idx: s.col_idx as usize,
386                    offset: s.offset as usize,
387                })
388            })
389            .collect::<StreamExecutorResult<Vec<_>>>()?;
390        Ok(CompiledDefine {
391            symbol: pb.symbol.clone(),
392            condition,
393            slots,
394        })
395    }
396}
397
398/// Evaluates `DEFINE` predicates against the in-progress match, driving the NFA. Holds the
399/// retained rows of one partition and the compiled `DEFINE`s; a variable with no `DEFINE` is
400/// universally true.
401struct DefineMatcher<'a> {
402    rows: &'a [BufferedRow],
403    defines: &'a HashMap<String, CompiledDefine>,
404    /// `WITHIN` span predicate over `[last_order_key, first_order_key]`. Applied as a candidate is
405    /// bound so the NFA prunes any extension that would push the match's span past the bound,
406    /// yielding the longest match that fits the window rather than rejecting an overshooting greedy
407    /// match after the fact.
408    within: Option<&'a NonStrictExpression>,
409}
410
411impl DefineMatcher<'_> {
412    /// The value a slot reads for a candidate at `pos` being tested for pattern variable `var`, where
413    /// `match_start` is the match's first row and `labels[k]` is the variable bound to
414    /// `rows[match_start + k]`.
415    ///
416    /// `labels` covers only the rows *already* bound, so for running navigation the candidate is the
417    /// implicit trailing label: while its membership is still tentative, the running set a `DEFINE`
418    /// predicate sees is `labels ++ [var]`. It therefore participates in `RunningFirst`/`RunningLast`
419    /// whenever the slot's variable set contains `var` — including via a `SUBSET` that has `var` as a
420    /// member. This is what makes `DEFINE a AS LAST(a.v) = a.v` a tautology, as SQL:2016 requires: a
421    /// pattern-variable-qualified column reference *is* `RUNNING LAST` of that column, and the binder
422    /// already lowers the bare `a.v` inside `a`'s own `DEFINE` to the candidate row.
423    fn slot_value(
424        &self,
425        slot: &DefineSlot,
426        var: &str,
427        pos: usize,
428        match_start: usize,
429        labels: &[String],
430    ) -> Datum {
431        let col_at = |i: usize| self.rows[i].row.datum_at(slot.col_idx).to_owned_datum();
432        let in_var = |l: &str| slot.vars.iter().any(|v| v == l);
433        // Whether the candidate row itself belongs to the set this slot navigates over.
434        let candidate_in_var = in_var(var);
435        match slot.kind {
436            DefineSlotKind::SelfCol => col_at(pos),
437            DefineSlotKind::Prev => pos.checked_sub(slot.offset).and_then(col_at),
438            // The candidate is the running first only when no earlier row of the match is in the set.
439            DefineSlotKind::RunningFirst => labels
440                .iter()
441                .position(|l| in_var(l))
442                .map(|k| match_start + k)
443                .or_else(|| candidate_in_var.then_some(pos))
444                .and_then(col_at),
445            // The candidate is the newest row, so it is the running last whenever it is in the set.
446            DefineSlotKind::RunningLast => candidate_in_var
447                .then_some(pos)
448                .or_else(|| {
449                    labels
450                        .iter()
451                        .rposition(|l| in_var(l))
452                        .map(|k| match_start + k)
453                })
454                .and_then(col_at),
455            // Both rejected in `from_protobuf`; a constructed slot never carries them. NULL (the
456            // non-strict convention) rather than a panic, should that invariant ever break.
457            DefineSlotKind::Next | DefineSlotKind::Unspecified => None,
458        }
459    }
460}
461
462impl CandidateMatcher for DefineMatcher<'_> {
463    async fn matches(
464        &self,
465        var: &str,
466        pos: usize,
467        labels: &[String],
468    ) -> StreamExecutorResult<bool> {
469        // `labels` covers the rows before the candidate (see the trait contract), so it is never
470        // longer than `pos`. A caller that broke that would, without overflow checks, wrap
471        // `match_start` past the buffer; refuse the candidate instead and say so.
472        let Some(match_start) = pos.checked_sub(labels.len()) else {
473            crate::consistency::consistency_panic!(
474                pos,
475                labels = labels.len(),
476                "MATCH_RECOGNIZE candidate has more bound labels than preceding rows",
477            );
478            return Ok(false);
479        };
480        // A pattern variable with no DEFINE matches every row; one with a DEFINE must satisfy it.
481        if let Some(def) = self.defines.get(var) {
482            let synthetic: Vec<Datum> = def
483                .slots
484                .iter()
485                .map(|slot| self.slot_value(slot, var, pos, match_start, labels))
486                .collect();
487            let value = def
488                .condition
489                .eval_row_infallible(&OwnedRow::new(synthetic))
490                .await;
491            if !value.is_some_and(|s| s.into_bool()) {
492                return Ok(false);
493            }
494        }
495        // WITHIN: binding `pos` extends the match to span `[match_start, pos]`. Reject the candidate
496        // if that span exceeds the bound, so the NFA backtracks to a shorter match that fits.
497        //
498        // One comparison, not an expression call. The span predicate is `last <= first + bound` and
499        // `BufferedRow::deadline` is that same `first + bound`, already evaluated once per row at
500        // ingest — the binder builds both from one expression precisely so its two WITHIN consumers
501        // agree on every input, including calendar intervals, and `lower_within`'s
502        // `within_predicate_right_hand_side_is_the_deadline` test pins that. Reusing it here removes
503        // an allocation, two `Datum` clones and a boxed expression walk from the hottest path in the
504        // operator: this runs once per predicate evaluation, up to the whole scan budget per visit,
505        // and for a pattern variable with no `DEFINE` it was the entire cost of an evaluation.
506        //
507        // A deadline past the order key type's range admits every row ([`Deadline::Never`]; see
508        // [`eval_deadline`]). `order_key` is never NULL for a buffered row — those are dropped at
509        // ingest.
510        if self.within.is_some() {
511            let fits = match &self.rows[pos].order_key {
512                Some(last) => self.rows[match_start].deadline.admits(last),
513                None => false,
514            };
515            if !fits {
516                return Ok(false);
517            }
518        }
519        Ok(true)
520    }
521}
522
523pub struct MatchRecognizeExecutorArgs<S: StateStore> {
524    pub ctx: ActorContextRef,
525    pub input: Executor,
526    /// Output schema: the `PARTITION BY` columns followed by the `MEASURES` columns.
527    pub schema: Schema,
528    pub chunk_size: usize,
529    pub partition_key_indices: Vec<usize>,
530    pub order_key_indices: Vec<usize>,
531    pub measures: Vec<CompiledMeasure>,
532    pub defines: Vec<CompiledDefine>,
533    /// `WITHIN` span check over `[last_order_key, first_order_key]`; rejects matches that exceed it.
534    pub within: Option<NonStrictExpression>,
535    /// `WITHIN` deadline `first_order_key + interval` over a synthetic `[first_order_key]` row; the
536    /// watermark at which a partial starting at that row expires. Used to wake idle partitions to
537    /// evict timed-out partials. `None` when there is no `WITHIN`.
538    ///
539    /// Non-strict like every other expression here, but built over a [`DeadlineErrorReport`]: a sum
540    /// that leaves the order key's range is a meaningful outcome ([`Deadline::Never`]), not a
541    /// failure to report. See [`eval_deadline`].
542    pub within_deadline: Option<NonStrictExpression>,
543    pub nfa: Nfa,
544    pub skip: SkipMode,
545    /// Where the actor's compute-error reports go. The compiled `DEFINE`/`MEASURES`/`WITHIN`
546    /// expressions already report evaluation errors through it; the executor itself uses it for
547    /// `AFTER MATCH SKIP` degradations (see [`report_skip_degradation_once`]).
548    pub eval_error_report: ActorEvalErrorReport,
549    pub state_table: StateTable<S>,
550}
551
552pub struct MatchRecognizeExecutor<S: StateStore> {
553    ctx: ActorContextRef,
554    input: Executor,
555    schema: Schema,
556    chunk_size: usize,
557    partition_key_indices: Vec<usize>,
558    /// Input column index of the leading ORDER BY column (the watermark column). The full ORDER BY
559    /// is encoded in the state-table key, so the buffer scans back already ordered; the executor
560    /// only needs the leading column here, to find the safe prefix against the watermark.
561    time_col: usize,
562    measures: Vec<CompiledMeasure>,
563    /// Compiled `DEFINE` predicates keyed by their pattern variable.
564    defines: HashMap<String, CompiledDefine>,
565    within: Option<NonStrictExpression>,
566    /// `WITHIN` deadline expr (see [`MatchRecognizeExecutorArgs`]); consulted on every watermark
567    /// pass — which visits every partition, so an idle partition's timed-out partial is emitted or
568    /// evicted without new input in that partition.
569    within_deadline: Option<NonStrictExpression>,
570    nfa: Nfa,
571    skip: SkipMode,
572    /// Where `AFTER MATCH SKIP` degradations are reported (see [`MatchRecognizeExecutorArgs`]).
573    eval_error_report: ActorEvalErrorReport,
574    state_table: StateTable<S>,
575}
576
577/// When a row's `WITHIN` window closes: the watermark at which a partial match starting at that row
578/// expires, and the latest order key a row may carry and still extend such a match.
579#[derive(Debug, Clone, PartialEq, Eq)]
580enum Deadline {
581    /// `first + bound`, in the order key's type (`lower_within` guarantees that), so it compares
582    /// directly against order keys and watermarks.
583    At(ScalarImpl),
584    /// The window never closes. Without a `WITHIN` clause that is simply the semantics; with one,
585    /// it is what `first + bound` denotes when the sum lies past the order key type's range: every
586    /// representable order key is `<= first + bound`, so every candidate row is inside the span,
587    /// and no representable watermark can pass the deadline. Folding the overflow into NULL
588    /// instead — what non-strict evaluation did — rejected every such match (a `smallint` key at
589    /// `32766` with `WITHIN 2::smallint` could not match its own next row) while leaving the
590    /// partial unevictable.
591    ///
592    /// The reading rests on the addition being monotone in the bound, so that "out of range" can
593    /// only mean "past the maximum": the binder guarantees a positive bound with, for intervals,
594    /// no negative component (`timestamp + interval` adds months, days and microseconds as
595    /// separate checked steps, and a mixed-sign interval could overflow on one while its true sum
596    /// is representable).
597    Never,
598}
599
600// `BufferedRow` is the operator's whole retained state; the enum must not cost more than the
601// `Datum` it replaced (the `ScalarImpl` niche carries the second variant for free).
602const _: () = assert!(std::mem::size_of::<Deadline>() == std::mem::size_of::<Datum>());
603
604impl Deadline {
605    /// The finality test: the window has closed at watermark `w`. Strict, because a row with
606    /// `order_key == w` may still arrive and would still fall inside the inclusive span bound.
607    fn closed_at(&self, w: &ScalarImpl) -> bool {
608        match self {
609            Deadline::At(d) => d.default_cmp(w).is_lt(),
610            Deadline::Never => false,
611        }
612    }
613
614    /// The span test: a match starting at this row may extend to a row with order key `last`.
615    /// This is the lowered span predicate `last <= first + bound`, read off the cached deadline.
616    fn admits(&self, last: &ScalarImpl) -> bool {
617        match self {
618            Deadline::At(d) => last.default_cmp(d).is_le(),
619            Deadline::Never => true,
620        }
621    }
622}
623
624/// The error report the `WITHIN` deadline expression is built over: the sum leaving the order key
625/// type's range is not a failure but the window that never closes ([`Deadline::Never`]), so it is
626/// dropped here instead of being counted and logged as a compute error on every affected row. Every
627/// other error still reaches the actor's report.
628///
629/// The expression is `first + bound` with `bound` a positive constant of the order key's own type
630/// (`lower_within` enforces both), so out-of-range is the one error it can raise; anything else
631/// would mean the expression is no longer the one the binder emits, and deserves the report.
632#[derive(Clone)]
633pub struct DeadlineErrorReport<R> {
634    inner: R,
635}
636
637impl<R: EvalErrorReport> DeadlineErrorReport<R> {
638    pub fn new(inner: R) -> Self {
639        Self { inner }
640    }
641}
642
643impl<R: EvalErrorReport> EvalErrorReport for DeadlineErrorReport<R> {
644    fn report(&self, error: ExprError) {
645        if !is_out_of_range(&error) {
646            self.inner.report(error);
647        }
648    }
649}
650
651/// Whether `error` is, or wraps, an out-of-range arithmetic error. A generated function
652/// implementation does not return its function's error bare: it wraps it in
653/// [`ExprError::Function`] with the call rendered for display (`add('32766', '2')`), so the variant
654/// has to be found through that wrapper.
655fn is_out_of_range(error: &ExprError) -> bool {
656    match error {
657        // Not `NumericUnderflow`: the bound is positive at bind time, so the sum can only leave
658        // the range upwards. An underflow would mean the expression is not the one the binder
659        // emits, and must be reported rather than read as a window that never closes.
660        ExprError::NumericOutOfRange | ExprError::NumericOverflow => true,
661        ExprError::Function { source, .. } => source
662            .downcast_ref::<ExprError>()
663            .is_some_and(is_out_of_range),
664        _ => false,
665    }
666}
667
668/// Per-row WITHIN-deadline evaluation, run once when a row enters the buffer; every later
669/// consultation reads [`BufferedRow::deadline`].
670///
671/// A NULL result can only be an evaluation error padded to NULL: a NULL sum needs a NULL operand,
672/// and the order key is non-null for every buffered row (NULLs are dropped at ingest) while a NULL
673/// bound is rejected at bind time. And the one reachable error is the sum leaving the order key
674/// type's range (see [`DeadlineErrorReport`]) — the window that never closes. An unexpected error
675/// has already been reported by the wrapper and is read the same way, since the alternative —
676/// rejecting every match from that row — is the silent data loss this exists to prevent.
677async fn eval_deadline(
678    within_deadline: &Option<NonStrictExpression>,
679    order_key: &Datum,
680) -> Deadline {
681    let Some(expr) = within_deadline else {
682        return Deadline::Never;
683    };
684    let synthetic = OwnedRow::new(vec![order_key.clone()]);
685    match expr.eval_row_infallible(&synthetic).await {
686        Some(deadline) => Deadline::At(deadline),
687        None => Deadline::Never,
688    }
689}
690
691/// A buffered input row, materialized from the state table while processing one partition.
692struct BufferedRow {
693    /// Per-actor monotonic id; the state-table key tiebreaker (keeps rows with equal ORDER BY keys
694    /// distinct and stably ordered).
695    seq: i64,
696    /// Leading ORDER BY value (a copy of `row[time_col]`), compared against the watermark to find
697    /// the safe prefix. The buffer arrives pre-sorted by the full ORDER BY key (state-table PK).
698    order_key: Datum,
699    /// Precomputed `WITHIN` deadline (`order_key + bound`). A pure function of the row, consulted
700    /// on every finality test and every prune pass — evaluating the expression per consultation
701    /// would put a boxed expression call on each of those paths for what is one comparison.
702    deadline: Deadline,
703    /// The raw input row, read by DEFINE and MEASURES navigation slots at match time.
704    row: OwnedRow,
705}
706
707impl<S: StateStore> MatchRecognizeExecutor<S> {
708    pub fn new(args: MatchRecognizeExecutorArgs<S>) -> Self {
709        let time_col = args.order_key_indices[0];
710        let defines = args
711            .defines
712            .into_iter()
713            .map(|d| (d.symbol.clone(), d))
714            .collect();
715        Self {
716            ctx: args.ctx,
717            input: args.input,
718            schema: args.schema,
719            chunk_size: args.chunk_size,
720            partition_key_indices: args.partition_key_indices,
721            time_col,
722            measures: args.measures,
723            defines,
724            within: args.within,
725            within_deadline: args.within_deadline,
726            nfa: args.nfa,
727            skip: args.skip,
728            eval_error_report: args.eval_error_report,
729            state_table: args.state_table,
730        }
731    }
732
733    /// Emit every match the current state has decided, in scan order, mirroring the batch
734    /// executor's guard: a match is final when a fed row follows it, or — ending exactly at the
735    /// fed boundary — when its accepting path is terminal ([`Nfa::may_extend`] false) or, given a
736    /// watermark, its `WITHIN` deadline has strictly passed. Emitting a match consumes everything
737    /// up to its skip-resume position — including any earlier still-live partial, which can no
738    /// longer produce a non-overlapping match before the emitted one (the same abandonment the
739    /// batch scan performs). Returns the chunks that filled while appending.
740    #[allow(clippy::too_many_arguments)]
741    async fn emit_ready(
742        run: &mut PartitionRun,
743        partition_key: &OwnedRow,
744        nfa: &Nfa,
745        skip: &SkipMode,
746        defines: &HashMap<String, CompiledDefine>,
747        within: Option<&NonStrictExpression>,
748        measures: &[CompiledMeasure],
749        watermark: Option<&ScalarImpl>,
750        state_table: &mut StateTable<S>,
751        builder: &mut StreamChunkBuilder,
752        eval_error_report: &ActorEvalErrorReport,
753        reported_degradations: &mut Vec<SkipDegradation>,
754        budget: &mut ScanBudget,
755        memoizable: bool,
756        statically_terminal: bool,
757        metrics: &MatchRecognizeMetrics,
758    ) -> StreamExecutorResult<Vec<StreamChunk>> {
759        let mut out = Vec::new();
760        // With no watermark there is no `within_final`, so a spent budget can decide nothing at all
761        // on this path — bail before the per-match seq lookup below rather than walking the whole
762        // buffer once per arriving row only to break. (The data path always passes `None`; only the
763        // watermark path can reach the drain.)
764        if budget.hit && watermark.is_none() {
765            return Ok(out);
766        }
767        // Only the `Copy` identity fields before the gate: cloning the whole match (its label vector
768        // in particular) on every ATTEMPT would copy it once per visit for a held match; the clone
769        // happens below, after the gate passes.
770        while let Some((start_seq, labels_len)) = run
771            .matcher
772            .provisional()
773            .first()
774            .map(|m| (m.start_seq, m.labels.len()))
775        {
776            // `end_seq` is a synthetic exclusive bound (last row's seq + 1), not a real row's
777            // seq; the span length is the label count (one label per matched row). The scan runs
778            // from 0, NOT from the matcher's resume position: `provisional()` leads with FROZEN
779            // but not-yet-emitted matches, whose starts sit before the resume position (it points
780            // past the LAST frozen match).
781            let resume_pos = run.matcher.resume_pos().min(run.rows.len());
782            // The gap check below walks `[resume_pos, start)`; positions the freeze already proved
783            // dead (`dead_prefix_end`, monotone under appends) need no walk, so start it past them.
784            let gap_from = resume_pos.max(run.matcher.dead_prefix_end());
785            // `seq` is strictly increasing in buffer position — rows are appended in mint order and
786            // the recovery rebuild re-feeds them in key order — the same invariant the dead-prefix
787            // prune already binary-searches on.
788            let Ok(start) = run.rows.binary_search_by_key(&start_seq.0, |r| r.seq) else {
789                // A provisional match referencing an unfed seq is a matcher-invariant violation.
790                // Fail loud: breaking here instead would re-hit the same match on every visit —
791                // the partition would silently never emit or evict again while its state grows.
792                return Err(anyhow::anyhow!(
793                    "provisional match references seq {:?} not present in the row buffer",
794                    start_seq
795                )
796                .into());
797            };
798            let end = start + labels_len;
799            debug_assert!(end <= run.rows.len());
800
801            let within_final = if let Some(w) = watermark {
802                run.rows[start].deadline.closed_at(w)
803            } else {
804                false
805            };
806            // A spent budget cannot decide a STRUCTURAL hold: every walk short-circuits to
807            // "undecided", which the gate must read as hold. A WITHIN-final match is different —
808            // its window has closed, so `match_is_final` returns FINAL for it before spending
809            // anything, and it needs no walk at all. Those MUST still be drained: leaving one
810            // withheld while `prune_dead_prefix` treats its window-closed start row as dead is how
811            // a starved visit loses a match outright. It is also the ONLY way a starved partition
812            // sheds anything — `prune_dead_prefix` returns early while the matcher is incomplete —
813            // though only about one match per visit: emitting a provisional match rebuilds the
814            // matcher under the same spent budget, which empties the tail and ends this loop. That
815            // is an improvement on shedding nothing, not convergence; see the design doc.
816            //
817            // (Nothing between the loop head and here spends budget: the provisional read, the seq
818            // lookup and the deadline comparison are all plain reads.)
819            if budget.hit && !within_final {
820                break;
821            }
822            // Short-circuit a match the gate already held under identical state: only the
823            // watermark-dependent WITHIN test can change the answer.
824            if run.held == Some((start_seq, resume_pos, run.rows.len())) && !within_final {
825                break;
826            }
827            let final_now = {
828                let matcher = DefineMatcher {
829                    rows: &run.rows,
830                    defines,
831                    within,
832                };
833                match_is_final(
834                    nfa,
835                    &matcher,
836                    gap_from,
837                    start,
838                    run.rows.len(),
839                    within_final,
840                    statically_terminal,
841                    budget,
842                    memoizable,
843                )
844                .await?
845            };
846            if !final_now {
847                if !budget.hit {
848                    run.held = Some((start_seq, resume_pos, run.rows.len()));
849                }
850                break;
851            }
852            let m = run
853                .matcher
854                .provisional()
855                .first()
856                .cloned()
857                .expect("checked non-empty above; nothing mutated the matcher since");
858
859            // Evaluate each measure over the synthetic row its slots produce from the matched rows
860            // and labels. WITHIN is enforced inside the matcher.
861            let mut measure_datums: Vec<Datum> = Vec::with_capacity(measures.len());
862            for measure in measures {
863                let mut synthetic = Vec::with_capacity(measure.slots.len());
864                for slot in &measure.slots {
865                    synthetic.push(
866                        slot.resolve(&run.rows, start, &m.labels, eval_error_report)
867                            .await?,
868                    );
869                }
870                let synthetic = OwnedRow::new(synthetic);
871                measure_datums.push(measure.expr.eval_row_infallible(&synthetic).await);
872            }
873            // The match's identity is its start row's `seq`: deterministic across recovery replay,
874            // and unique forever — consumption alone does not guarantee that (consumed rows are
875            // deleted, so a naively re-seeded counter could re-mint their seqs); the epoch floor
876            // on the seq counter (see the seeding comment in `execute_inner`) is what makes reuse
877            // impossible.
878            let match_id = run.rows[start].seq;
879            let measures_row = OwnedRow::new(measure_datums);
880            metrics.match_recognize_matches_emitted_count.inc();
881            if let Some(c) = builder.append_row(
882                Op::Insert,
883                partition_key
884                    .chain(&measures_row)
885                    .chain(once(Some(ScalarImpl::Int64(match_id)))),
886            ) {
887                out.push(c);
888            }
889
890            // Where the scan resumes. A variable-targeted skip whose target row does not exist in
891            // this match degrades to a weaker strategy instead of failing the actor; reported, not
892            // silent.
893            let (resume, degradation) = skip.next_pos(start, end, &m.labels);
894            if let Some(degradation) = degradation {
895                report_skip_degradation_once(
896                    eval_error_report,
897                    skip,
898                    degradation,
899                    reported_degradations,
900                );
901            }
902            Self::consume_prefix(
903                run,
904                resume,
905                defines,
906                within,
907                state_table,
908                budget,
909                memoizable,
910            )
911            .await?;
912        }
913        Ok(out)
914    }
915
916    /// Drop the dead prefix at a watermark: rows before the first position that is still a live
917    /// match start — structurally alive at the fed boundary AND, under `WITHIN`, its window still
918    /// open (`deadline >= w`, the strict complement of the finality test) — can never join a match
919    /// again. On a spent budget everything undecided is retained.
920    #[allow(clippy::too_many_arguments)]
921    async fn prune_dead_prefix(
922        run: &mut PartitionRun,
923        nfa: &Nfa,
924        defines: &HashMap<String, CompiledDefine>,
925        within: Option<&NonStrictExpression>,
926        w: &ScalarImpl,
927        state_table: &mut StateTable<S>,
928        budget: &mut ScanBudget,
929        memoizable: bool,
930    ) -> StreamExecutorResult<()> {
931        // A budget-truncated provisional tail means absence-of-a-match is NOT evidence: the
932        // WITHIN-deadline skip below treats window-closed rows as dead on the argument that
933        // `emit_ready` already drained every within-final match — which only holds for a COMPLETE
934        // tail. The executor re-derives (fresh budget) before this pass; if even that was
935        // truncated, retain everything and let the next visit retry.
936        if run.matcher.is_incomplete() {
937            return Ok(());
938        }
939        let n = run.rows.len();
940        // Positions the matcher's freeze walks already proved dead at the boundary (monotone under
941        // appends; see `IncrementalMatcher::dead_prefix_end`) need no walk here. Buffer positions
942        // and fed positions coincide (`consume_prefix` keeps them aligned).
943        let proven_dead = run.matcher.dead_prefix_end().min(n);
944        let mut retain_from = n;
945        for p in 0..n {
946            // Window closed (deadline < w): `p` is dead, skip it. A window that never closes (no
947            // WITHIN, or a deadline past the type's range) fails this test, so `p` is retained.
948            if run.rows[p].deadline.closed_at(w) {
949                continue;
950            }
951            if p < proven_dead {
952                continue;
953            }
954            let matcher = DefineMatcher {
955                rows: &run.rows,
956                defines,
957                within,
958            };
959            let alive = nfa
960                .reaches_boundary_alive(p, n, &matcher, budget, memoizable)
961                .await?;
962            if budget.hit || alive {
963                // Spent budget: `p` is undecided — retain it all rather than fabricate "dead".
964                retain_from = p;
965                break;
966            }
967        }
968        // Never consume the start row of a match the matcher still holds.
969        //
970        // This is NOT about a spent budget — do not gate it on `budget.hit`. With the budget spent
971        // this function has already returned above, and even reaching here the loop stops at the
972        // first window-open position, which is at or before any held match's start. The case it
973        // guards has budget to spare: the loop skips a window-closed row on `deadline < w` WITHOUT
974        // consulting the matcher, while the emission gate holds a match because a *gap* position
975        // before its start is still alive at the boundary. The held match's own start row can then
976        // be dead at the boundary (a path from it accepts before the buffer end), so the loop marches
977        // straight past it and `consume_prefix` deletes the row of a match that was never emitted —
978        // losing it, and emitting in its place a match a batch evaluation never produces.
979        //
980        // `provisional()` is ordered by start position and includes frozen-but-unemitted matches, so
981        // its first entry is a lower bound on every undrained match. Skipped entirely when nothing
982        // would be consumed, since the scan below is O(n) and cannot change a zero.
983        if retain_from > 0
984            && let Some(first) = run.matcher.provisional().first()
985            // `seq` is strictly increasing in buffer position: rows are appended in mint order, and
986            // the recovery rebuild re-feeds them in `(partition.., order.., seq)` key order, which
987            // under the ordered-input model is the same order.
988            && let Ok(pos) = run.rows.binary_search_by_key(&first.start_seq.0, |r| r.seq)
989        {
990            retain_from = retain_from.min(pos);
991        }
992        Self::consume_prefix(
993            run,
994            retain_from,
995            defines,
996            within,
997            state_table,
998            budget,
999            memoizable,
1000        )
1001        .await
1002    }
1003
1004    /// Consume `rows[..upto]`: delete them from the state table, drain the window, and bring the
1005    /// matcher along — rebasing in place where its finalize contract allows, rebuilding it from the
1006    /// survivors otherwise (the straddle shapes).
1007    #[allow(clippy::too_many_arguments)]
1008    async fn consume_prefix(
1009        run: &mut PartitionRun,
1010        upto: usize,
1011        defines: &HashMap<String, CompiledDefine>,
1012        within: Option<&NonStrictExpression>,
1013        state_table: &mut StateTable<S>,
1014        budget: &mut ScanBudget,
1015        memoizable: bool,
1016    ) -> StreamExecutorResult<()> {
1017        if upto == 0 {
1018            return Ok(());
1019        }
1020        // Invalidate the emission-gate cache: its key is `(start_seq, resume_pos, rows.len())`,
1021        // which identifies gate state only while `rows` is immutable — and this function rebases
1022        // the buffer, so both the position and the length can return to a value they held under a
1023        // DIFFERENT set of rows. A stale hit then skips the gate for a match the gate would now
1024        // decide FINAL, withholding it until the next row arrives in that partition (and, without
1025        // WITHIN on an idle partition, indefinitely). Cheaper to drop the cache on every rebase
1026        // than to carry a generation counter: the cache exists to save repeated walks on a HELD
1027        // match, and a rebase means the next visit has to re-walk anyway.
1028        run.held = None;
1029        for c in &run.rows[..upto] {
1030            state_table.delete(once(Some(ScalarImpl::Int64(c.seq))).chain(&c.row));
1031        }
1032        let rebuild = if upto >= run.rows.len() {
1033            run.rows.clear();
1034            true
1035        } else {
1036            let boundary = Seq(run.rows[upto].seq);
1037            let rebased = matches!(
1038                run.matcher.finalize_evicted_prefix(boundary),
1039                Finalized::Rebased
1040            );
1041            run.rows.drain(..upto);
1042            !rebased
1043        };
1044        if rebuild {
1045            // Full reset (keeps the shared automaton and the allocations) — reconstructing here
1046            // would deep-copy the skip mode once per emitted match under PAST LAST ROW.
1047            run.matcher.reset();
1048            if !run.rows.is_empty() {
1049                let seqs: Vec<Seq> = run.rows.iter().map(|r| Seq(r.seq)).collect();
1050                let matcher = DefineMatcher {
1051                    rows: &run.rows,
1052                    defines,
1053                    within,
1054                };
1055                run.matcher
1056                    .advance(&seqs, &matcher, budget, memoizable)
1057                    .await?;
1058            }
1059        }
1060        Ok(())
1061    }
1062
1063    /// Recovery rebuild (rescale restarts the actor and re-enters through this same path):
1064    /// re-feed every retained row in key order — `(partition...,
1065    /// order..., seq)`, so each partition arrives contiguous and ordered. No emission here: an
1066    /// emittable match is consumed in the same epoch it emits, so retained rows only carry held or
1067    /// partial matches; anything mid-epoch at a crash is re-delivered by replay and re-triggers.
1068    ///
1069    /// Rows are collected first and each partition is fed with ONE `advance` call: feeding
1070    /// row-by-row would rescan the partition's live suffix per row — O(retained²) predicate
1071    /// evaluations inside the barrier path, turning a partition legitimately retaining many rows
1072    /// (long `WITHIN`, no completion) into a recovery stall.
1073    ///
1074    /// Returns the largest committed `seq` seen (`-1` if none), so the caller can seed the
1075    /// per-actor seq counter strictly above every retained row.
1076    #[allow(clippy::too_many_arguments)]
1077    async fn rebuild_partitions(
1078        parts: &mut hashbrown::HashMap<OwnedRow, PartitionRun>,
1079        state_table: &StateTable<S>,
1080        partition_key_indices: &[usize],
1081        time_col: usize,
1082        nfa: &std::sync::Arc<Nfa>,
1083        skip: &SkipMode,
1084        defines: &HashMap<String, CompiledDefine>,
1085        within: Option<&NonStrictExpression>,
1086        within_deadline: &Option<NonStrictExpression>,
1087        memoizable: bool,
1088        eval_error_report: &ActorEvalErrorReport,
1089        metrics: &MatchRecognizeMetrics,
1090    ) -> StreamExecutorResult<i64> {
1091        parts.clear();
1092        let mut max_seq: i64 = -1;
1093        let vnodes: Vec<_> = state_table.vnodes().iter_vnodes().collect();
1094        for vnode in vnodes {
1095            let stream = state_table
1096                .iter_keyed_row_with_vnode(
1097                    vnode,
1098                    &(
1099                        std::ops::Bound::<OwnedRow>::Unbounded,
1100                        std::ops::Bound::<OwnedRow>::Unbounded,
1101                    ),
1102                    Default::default(),
1103                )
1104                .await?;
1105            pin_mut!(stream);
1106            while let Some(kv) = stream.next().await {
1107                let kv = kv?;
1108                let stored = kv.row();
1109                // Stored layout: `[ seq, <input cols..> ]`. Fail descriptive on a corrupt row —
1110                // a panic here would crash-loop recovery on state that recovery cannot fix.
1111                let seq = match stored.datum_at(0) {
1112                    Some(ScalarRefImpl::Int64(s)) => s,
1113                    other => {
1114                        return Err(anyhow::anyhow!(
1115                            "corrupt MATCH_RECOGNIZE state row: seq column must be a non-null \
1116                             int64, got {other:?}"
1117                        )
1118                        .into());
1119                    }
1120                };
1121                max_seq = max_seq.max(seq);
1122                let input_row = OwnedRow::new(
1123                    (1..stored.len())
1124                        .map(|i| stored.datum_at(i).to_owned_datum())
1125                        .collect(),
1126                );
1127                let pk = (&input_row).project(partition_key_indices).into_owned_row();
1128                let order_key = input_row.datum_at(time_col).to_owned_datum();
1129                // Not counted here: the ingest path counted this row once already, and a rebuild
1130                // re-evaluates every retained row on each recovery.
1131                let deadline = eval_deadline(within_deadline, &order_key).await;
1132                let run = parts.entry(pk).or_insert_with(|| PartitionRun {
1133                    rows: Vec::new(),
1134                    matcher: IncrementalMatcher::new(nfa.clone(), skip.clone()),
1135                    held: None,
1136                });
1137                // The emit path and the dead-prefix prune binary-search `rows` by `seq`; pin the
1138                // invariant where it is produced: state-table key order must feed each partition's
1139                // seqs in strictly increasing order (the ordered-input contract).
1140                debug_assert!(
1141                    run.rows.last().is_none_or(|last| last.seq < seq),
1142                    "state-table iteration fed a non-increasing seq into a partition buffer"
1143                );
1144                run.rows.push(BufferedRow {
1145                    seq,
1146                    order_key,
1147                    deadline,
1148                    row: input_row,
1149                });
1150            }
1151        }
1152        // One BOUNDED budget per partition, exactly like a steady-state visit: recovery and
1153        // rescale run inside the barrier path, so an unmetered exponential pattern here would
1154        // hang the actor instead of degrading. A spent budget is safe for the same reason it is
1155        // safe on the data path — no emission happens during rebuild, the freeze loop holds on
1156        // exhaustion, and the next visit rescans the suffix with a fresh budget.
1157        let mut reported_budget = false;
1158        for run in parts.values_mut() {
1159            let mut budget = ScanBudget::new(SCAN_BUDGET_EVALUATIONS);
1160            let fed: Vec<Seq> = run.rows.iter().map(|r| Seq(r.seq)).collect();
1161            let matcher = DefineMatcher {
1162                rows: &run.rows,
1163                defines,
1164                within,
1165            };
1166            run.matcher
1167                .advance(&fed, &matcher, &mut budget, memoizable)
1168                .await?;
1169            if budget.hit {
1170                metrics.match_recognize_scan_budget_exhausted_count.inc();
1171                report_scan_budget_once(eval_error_report, &mut reported_budget);
1172            }
1173        }
1174        Ok(max_seq)
1175    }
1176
1177    #[try_stream(ok = Message, error = StreamExecutorError)]
1178    async fn execute_inner(self: Box<Self>) {
1179        let Self {
1180            ctx,
1181            input,
1182            schema,
1183            chunk_size,
1184            partition_key_indices,
1185            time_col,
1186            measures,
1187            defines,
1188            within,
1189            within_deadline,
1190            nfa,
1191            skip,
1192            eval_error_report,
1193            mut state_table,
1194        } = *self;
1195
1196        // Whether the per-start `(state, position)` failure memo is sound for this query: no
1197        // `DEFINE` slot may read the running label assignment. See `Memo` in the NFA module.
1198        let memoizable = defines.values().all(|d| {
1199            d.slots
1200                .iter()
1201                .all(|s| matches!(s.kind, DefineSlotKind::SelfCol | DefineSlotKind::Prev))
1202        });
1203
1204        // One shared automaton for every per-partition matcher (and every post-consumption
1205        // reset); the matchers hold it by `Arc`.
1206        let nfa = std::sync::Arc::new(nfa);
1207        // Fixed-length linear patterns ((a b) and friends) can skip the per-emit extension probe.
1208        let statically_terminal = nfa.is_linear();
1209
1210        let metrics = ctx.streaming_metrics.new_match_recognize_metrics(
1211            state_table.table_id(),
1212            ctx.id,
1213            ctx.fragment_id,
1214        );
1215
1216        let mut input = input.execute();
1217        let barrier = expect_first_barrier(&mut input).await?;
1218        let first_epoch = barrier.epoch;
1219        yield Message::Barrier(barrier);
1220        state_table.init_epoch(first_epoch).await?;
1221
1222        // `hashbrown` rather than std for `entry_ref` (see the ingest path below); std's raw
1223        // entry API never stabilized.
1224        let mut parts: hashbrown::HashMap<OwnedRow, PartitionRun> = hashbrown::HashMap::new();
1225
1226        // Recovery / rescale rebuild: see `rebuild_partitions`.
1227        let max_seq = Self::rebuild_partitions(
1228            &mut parts,
1229            &state_table,
1230            &partition_key_indices,
1231            time_col,
1232            &nfa,
1233            &skip,
1234            &defines,
1235            within.as_ref(),
1236            &within_deadline,
1237            memoizable,
1238            &eval_error_report,
1239            &metrics,
1240        )
1241        .await?;
1242
1243        // `seq` is the PK tiebreaker for rows with equal ORDER BY keys, so it MUST be monotonic
1244        // in arrival order — the state-table order is the re-feed order on recovery/rescale, and
1245        // a tie re-fed in a different order than the live matcher saw silently changes which row
1246        // a match binds (a snowflake-style generator breaks this: its ids interleave vnode bits
1247        // above the sequence bits, so they are not monotonic within a millisecond). A plain
1248        // counter is monotonic by construction; its seed must be strictly above BOTH every
1249        // retained row's seq AND every seq ever minted before — consumed rows are deleted, so
1250        // "max retained + 1" alone would re-mint the seqs of fully-consumed matches after a
1251        // restart or rescale, and a reused seq collides `_match_id` values: the new match's
1252        // output row silently REPLACES the old one in the materialized view (same stream key).
1253        // The epoch floor provides the never-look-back bound: barrier epochs carry a physical
1254        // timestamp that only grows, and 2^20 seqs per millisecond is beyond any actor's mint
1255        // rate. `_match_id` is NOT minted here — it is the match's start-row `seq` (see
1256        // `emit_ready`), so replayed emission is deterministic.
1257        let seq_floor = |epoch: risingwave_common::util::epoch::EpochPair| -> i64 {
1258            (risingwave_common::util::epoch::Epoch(epoch.curr).physical_time() as i64) << 20
1259        };
1260        let mut next_seq: i64 = (max_seq + 1).max(seq_floor(first_epoch));
1261
1262        // Rows currently retained in memory across this actor's partitions, mirrored into the
1263        // `retained_rows` gauge at the end of every buffer-mutating message (chunk, watermark,
1264        // and the recovery rebuild; a plain barrier mutates no buffer). Retention is bounded only by
1265        // match liveness and `WITHIN`, so without this gauge a partition set growing toward memory
1266        // exhaustion (a pattern whose closer never arrives keeps its rows forever) is invisible
1267        // until the OOM. The chunk arm maintains it incrementally (an increment beside the push,
1268        // a decrement beside its eviction counter) because it never iterates the whole partition
1269        // map; the watermark arm and the rebuild sites recount exactly, which self-heals any
1270        // accounting slip within one watermark.
1271        let mut retained_rows: i64 = parts.values().map(|r| r.rows.len() as i64).sum();
1272        metrics.match_recognize_retained_rows.set(retained_rows);
1273
1274        #[for_await]
1275        for msg in input {
1276            let msg = msg?;
1277            match msg {
1278                Message::Chunk(chunk) => {
1279                    let chunk = chunk.compact_vis();
1280                    let mut builder = StreamChunkBuilder::new(chunk_size, schema.data_types());
1281                    let mut reported_budget = false;
1282                    let mut reported_degradations: Vec<SkipDegradation> = Vec::new();
1283                    // One budget per CHUNK, not per row. Per-row was 256 fresh budgets for a default
1284                    // chunk, so a single degraded partition could spend 2^28 predicate evaluations in
1285                    // one message — tens of seconds of single-threaded work with the barrier queued
1286                    // behind it, which stalls checkpointing well beyond this job. Sharing it across
1287                    // the chunk bounds a message at 2^20 regardless of chunk size.
1288                    //
1289                    // Safe because nothing on this path needs budget for correctness: every row is
1290                    // still buffered and fed, a truncated `advance` only defers match derivation and
1291                    // latches `incomplete`, and the next watermark re-derives with a fresh budget.
1292                    // Later rows of a chunk that exhausts the budget emit nothing, which is the same
1293                    // degraded-latency behaviour the design describes.
1294                    //
1295                    // TODO: the watermark arm still grants one budget per partition, so a pass costs
1296                    // `starved_partitions * 2^20` and a message is bounded only by partition count.
1297                    // Fixing that needs a pass-level cap plus a rotating start offset — a shared
1298                    // budget alone would starve whichever partitions sort last, pass after pass, as
1299                    // the comment on that arm says. Left for the follow-up that also addresses
1300                    // convergence (scanning the window-bounded expiring region under its own budget).
1301                    let mut budget = ScanBudget::new(SCAN_BUDGET_EVALUATIONS);
1302                    for (op, row_ref) in chunk.rows() {
1303                        // Append-only input is enforced at planning time, so a non-Insert here is
1304                        // an upstream inconsistency. Follow the operator convention rather than
1305                        // erroring the actor unconditionally: panic under strict consistency,
1306                        // report-and-skip the record when the cluster runs with strict consistency
1307                        // disabled — the escape hatch that lets a job limp past bad data instead of
1308                        // crash-looping on it.
1309                        if !matches!(op, Op::Insert) {
1310                            crate::consistency::consistency_panic!(
1311                                ?op,
1312                                "MATCH_RECOGNIZE requires append-only input",
1313                            );
1314                            continue;
1315                        }
1316                        let order_key = row_ref.datum_at(time_col).to_owned_datum();
1317                        // A NULL order key has no event time: the sort would never release it in
1318                        // any defined position. Drop it, as event-time processing does.
1319                        if order_key.is_none() {
1320                            continue;
1321                        }
1322                        let seq = next_seq;
1323                        next_seq += 1;
1324                        let deadline = eval_deadline(&within_deadline, &order_key).await;
1325                        // A `WITHIN` that silently stopped bounding this row's partial is worth
1326                        // seeing: an integer order key near its type's maximum is a schema smell.
1327                        if within_deadline.is_some() && deadline == Deadline::Never {
1328                            metrics.match_recognize_within_deadline_overflow_count.inc();
1329                        }
1330                        state_table.insert(once(Some(ScalarImpl::Int64(seq))).chain(row_ref));
1331                        let pk = row_ref.project(&partition_key_indices).into_owned_row();
1332                        // `entry_ref` hashes and probes once, materializing the key only on a
1333                        // vacant insert — one probe fewer than the contains_key/get_mut pair it
1334                        // replaced. (The `pk` allocation above is per-row either way.)
1335                        let run = parts.entry_ref(&pk).or_insert_with(|| PartitionRun {
1336                            rows: Vec::new(),
1337                            matcher: IncrementalMatcher::new(nfa.clone(), skip.clone()),
1338                            held: None,
1339                        });
1340                        run.rows.push(BufferedRow {
1341                            seq,
1342                            order_key,
1343                            deadline,
1344                            row: row_ref.into_owned_row(),
1345                        });
1346                        retained_rows += 1;
1347                        {
1348                            let fed = [Seq(seq)];
1349                            let matcher = DefineMatcher {
1350                                rows: &run.rows,
1351                                defines: &defines,
1352                                within: within.as_ref(),
1353                            };
1354                            run.matcher
1355                                .advance(&fed, &matcher, &mut budget, memoizable)
1356                                .await?;
1357                        }
1358                        let rows_before = run.rows.len();
1359                        let filled = Self::emit_ready(
1360                            run,
1361                            &pk,
1362                            &nfa,
1363                            &skip,
1364                            &defines,
1365                            within.as_ref(),
1366                            &measures,
1367                            None,
1368                            &mut state_table,
1369                            &mut builder,
1370                            &eval_error_report,
1371                            &mut reported_degradations,
1372                            &mut budget,
1373                            memoizable,
1374                            statically_terminal,
1375                            &metrics,
1376                        )
1377                        .await?;
1378                        // Eviction only shrinks the buffer; saturate rather than wrap a counter
1379                        // should that ever stop holding.
1380                        let evicted = rows_before.saturating_sub(run.rows.len()) as u64;
1381                        metrics.match_recognize_evicted_rows_count.inc_by(evicted);
1382                        retained_rows -= evicted as i64;
1383                        // Captured while the entry is still borrowed; the removal below needs the
1384                        // borrow released.
1385                        let partition_emptied = run.rows.is_empty();
1386                        let emptied_capacity = run.rows.capacity();
1387                        for c in filled {
1388                            yield Message::Chunk(c);
1389                        }
1390                        // Only drop a partition whose buffers actually grew. Removing it
1391                        // unconditionally looked tidier but cost more than the wart it fixed: any
1392                        // pattern that consumes its whole buffer per match (the common case under
1393                        // the default PAST LAST ROW — `PATTERN (d w)`, say) empties its partition on
1394                        // essentially every row, and each removal forces the next row down the slow
1395                        // branch, paying a partition-key clone, a skip-mode clone and regrowth of
1396                        // three vectors — exactly the reconstruction `consume_prefix`'s `reset()`
1397                        // exists to avoid. Above the threshold the retained capacity is worth more
1398                        // than the reconstruction: a partition that peaked at tens of thousands of
1399                        // rows holds a large allocation for an entry with nothing in it. Below it,
1400                        // the next watermark pass sweeps the entry anyway.
1401                        const DROP_EMPTY_PARTITION_CAPACITY: usize = 1024;
1402                        if partition_emptied && emptied_capacity >= DROP_EMPTY_PARTITION_CAPACITY {
1403                            // Safe for the same reason as the watermark arm: `consume_prefix` resets
1404                            // the matcher when it consumes the whole buffer, so an empty-rows
1405                            // partition carries no state a later row would need.
1406                            parts.remove(&pk);
1407                        }
1408                    }
1409                    // Once per chunk, not once per row: the budget is now shared across the chunk, so
1410                    // a per-row check would count every row seen after exhaustion and the counter
1411                    // would read as "rows processed while starved" rather than "visits that ran out".
1412                    if budget.hit {
1413                        metrics.match_recognize_scan_budget_exhausted_count.inc();
1414                        report_scan_budget_once(&eval_error_report, &mut reported_budget);
1415                    }
1416                    metrics.match_recognize_retained_rows.set(retained_rows);
1417                    if let Some(c) = builder.take() {
1418                        yield Message::Chunk(c);
1419                    }
1420                    // Bound mem-table growth between barriers, as the sibling EOWC executors do.
1421                    state_table.try_flush().await?;
1422                }
1423                Message::Watermark(w) => {
1424                    // Only the leading ORDER BY column's watermark drives WITHIN finality and
1425                    // pruning; the output schema carries no watermark columns, so none is
1426                    // forwarded downstream.
1427                    if w.col_idx != time_col {
1428                        continue;
1429                    }
1430                    let mut builder = StreamChunkBuilder::new(chunk_size, schema.data_types());
1431                    let mut reported_budget = false;
1432                    let mut reported_degradations: Vec<SkipDegradation> = Vec::new();
1433                    let mut emptied: Vec<OwnedRow> = Vec::new();
1434                    for (pk, run) in &mut parts {
1435                        // One budget per partition VISIT: a shared pass-wide budget would let a
1436                        // single pathological partition starve emission and eviction for every
1437                        // partition iterated after it, pass after pass (map order is stable).
1438                        // The budget is a cap, not a spend — a healthy partition never nears it.
1439                        let mut budget = ScanBudget::new(SCAN_BUDGET_EVALUATIONS);
1440                        // A previous visit's rescan may have been budget-truncated, leaving the
1441                        // provisional tail an under-approximation. Re-derive with this visit's
1442                        // fresh budget BEFORE deciding anything: the deadline prune below treats
1443                        // missing matches as decided, which is only sound over a complete tail.
1444                        // A budget-truncated FREEZE asks for the same: it left proven-dead
1445                        // progress to resume from, and an idle partition gets no arrival to do it.
1446                        if run.matcher.needs_refresh() {
1447                            let matcher = DefineMatcher {
1448                                rows: &run.rows,
1449                                defines: &defines,
1450                                within: within.as_ref(),
1451                            };
1452                            run.matcher
1453                                .refresh(&matcher, &mut budget, memoizable)
1454                                .await?;
1455                        }
1456                        let rows_before = run.rows.len();
1457                        let filled = Self::emit_ready(
1458                            run,
1459                            pk,
1460                            &nfa,
1461                            &skip,
1462                            &defines,
1463                            within.as_ref(),
1464                            &measures,
1465                            Some(&w.val),
1466                            &mut state_table,
1467                            &mut builder,
1468                            &eval_error_report,
1469                            &mut reported_degradations,
1470                            &mut budget,
1471                            memoizable,
1472                            statically_terminal,
1473                            &metrics,
1474                        )
1475                        .await?;
1476                        for c in filled {
1477                            yield Message::Chunk(c);
1478                        }
1479                        Self::prune_dead_prefix(
1480                            run,
1481                            &nfa,
1482                            &defines,
1483                            within.as_ref(),
1484                            &w.val,
1485                            &mut state_table,
1486                            &mut budget,
1487                            memoizable,
1488                        )
1489                        .await?;
1490                        metrics
1491                            .match_recognize_evicted_rows_count
1492                            .inc_by(rows_before.saturating_sub(run.rows.len()) as u64);
1493                        if run.rows.is_empty() {
1494                            emptied.push(pk.clone());
1495                        }
1496                        if budget.hit {
1497                            metrics.match_recognize_scan_budget_exhausted_count.inc();
1498                            report_scan_budget_once(&eval_error_report, &mut reported_budget);
1499                        }
1500                    }
1501                    for pk in emptied {
1502                        parts.remove(&pk);
1503                    }
1504                    // Exact recount, not the incremental counter: this arm just iterated every
1505                    // partition, so the recount costs what the pass already paid — and it bounds
1506                    // the lifetime of any future accounting slip to one watermark instead of
1507                    // forever. (The chunk arm keeps the incremental counter: a recount there
1508                    // would add a whole-map walk per chunk.)
1509                    retained_rows = parts.values().map(|r| r.rows.len() as i64).sum();
1510                    metrics.match_recognize_retained_rows.set(retained_rows);
1511                    if let Some(c) = builder.take() {
1512                        yield Message::Chunk(c);
1513                    }
1514                    // A watermark can expire rows across every partition at once (a WITHIN cliff);
1515                    // bound the mem-table growth exactly as the chunk arm does.
1516                    state_table.try_flush().await?;
1517                }
1518                Message::Barrier(barrier) => {
1519                    // In-place vnode-bitmap updates are a deprecated scaling path: rescale
1520                    // restarts the actor, and the post-first-barrier rebuild above reconstructs
1521                    // every partition from the re-sharded table (the epoch floor on the seq
1522                    // counter is what keeps re-minted seqs impossible across that restart — see
1523                    // the seeding comment above). Assert the assumption instead of carrying a
1524                    // second, in-place rebuild branch.
1525                    barrier.assume_no_update_vnode_bitmap(ctx.id)?;
1526                    state_table
1527                        .commit_assert_no_update_vnode_bitmap(barrier.epoch)
1528                        .await?;
1529                    yield Message::Barrier(barrier);
1530                }
1531            }
1532        }
1533    }
1534}
1535
1536/// Per-partition live state: the retained rows (exactly what live partials and held matches still
1537/// reference) and the incremental matcher fed with them. `rows` and the matcher's fed positions
1538/// stay aligned: pushed exactly when fed, drained exactly when the matcher finalizes past them.
1539struct PartitionRun {
1540    rows: Vec<BufferedRow>,
1541    matcher: IncrementalMatcher,
1542    /// Emission-gate short-circuit: `(first match's start seq, resume_pos, rows.len())` for which
1543    /// the gate last answered "hold" with an UNSPENT budget. The gate's gap-liveness and
1544    /// extension verdicts are pure functions of that triple over immutable rows, so while it is
1545    /// unchanged only the watermark-dependent `WITHIN`-finality test can flip the answer — a held
1546    /// match would otherwise re-pay the full walk set on every visit until decided. Budget-hit
1547    /// verdicts are never cached (they are not verdicts).
1548    ///
1549    /// The triple identifies gate state only over an UNCHANGED buffer, so `consume_prefix` clears
1550    /// this on every rebase: after a prune, the same `(resume_pos, len)` can recur over a different
1551    /// set of rows and a stale hit would withhold a now-decidable match.
1552    held: Option<(Seq, usize, usize)>,
1553}
1554
1555impl<S: StateStore> Execute for MatchRecognizeExecutor<S> {
1556    fn execute(self: Box<Self>) -> BoxedMessageStream {
1557        self.execute_inner().boxed()
1558    }
1559}
1560
1561/// Whether the first provisional match `[start, ..)` is FINAL — emitting it now agrees with the
1562/// batch answer over every possible future input — or must be held.
1563///
1564/// "A later row exists" is not finality. Two shapes prove it: a more-preferred branch from the
1565/// same start can be blocked at the BUFFER boundary rather than the match's end (`(a b c d | a b)`
1566/// over rows a,b,c: the provisional match (0,2) is followed, but a future `d` makes the preferred
1567/// branch win), and a *gap* position in `[resume_pos, start)` can still be alive at the boundary
1568/// (`(x n n | n)` over rows x,n: the match at 1 is terminal from its own start, but position 0
1569/// plus a future `n` yields the leftmost-preferred match). The matcher's freeze gate proves region
1570/// deadness before retiring its cursor; this is the same discipline anchored at emission:
1571///
1572/// - a closed `WITHIN` window (`within_final`) decides everything: every gap row's window closed
1573///   no later than this match's (order keys are non-decreasing), a gap match over the existing
1574///   rows would have been the finder's leftmost result already, and any future row violates the
1575///   inclusive span bound for every start at or before this one;
1576/// - otherwise every gap position must be provably dead at the boundary — on a spent budget the
1577///   answer is HOLD, since a fabricated "dead" is precisely the lost-match bug class — and the
1578///   finder's preferred result from `start` must be un-improvable by future rows
1579///   ([`Nfa::may_extend`] probed at the buffer boundary, which itself answers "may extend" on a
1580///   spent budget).
1581///
1582/// Positions strictly inside the match (past `start`) are deliberately NOT checked: once the
1583/// leftmost match is final, the skip mode consumes through them in batch too — abandoning their
1584/// partials is the batch semantics, not a divergence.
1585#[allow(clippy::too_many_arguments)]
1586async fn match_is_final(
1587    nfa: &Nfa,
1588    matcher: &(impl CandidateMatcher + Sync),
1589    resume_pos: usize,
1590    start: usize,
1591    n_rows: usize,
1592    within_final: bool,
1593    statically_terminal: bool,
1594    budget: &mut ScanBudget,
1595    memoize: bool,
1596) -> StreamExecutorResult<bool> {
1597    // All three positions are in the same coordinate system: indices into the partition's
1598    // retained-row buffer (= the matcher's fed positions; `consume_prefix` keeps them aligned).
1599    // `resume_pos > start` is a LEGITIMATE state, not a violation: a frozen but not-yet-emitted
1600    // match starts before the resume position (which points past the LAST frozen match), and for
1601    // it the gap range below is deliberately empty — its region was already proven dead by the
1602    // freeze gate.
1603    debug_assert!(
1604        start <= n_rows,
1605        "match_is_final start out of range: start={start} n={n_rows}"
1606    );
1607    if within_final {
1608        return Ok(true);
1609    }
1610    for p in resume_pos..start {
1611        let alive = nfa
1612            .reaches_boundary_alive(p, n_rows, matcher, budget, memoize)
1613            .await?;
1614        if budget.hit || alive {
1615            return Ok(false);
1616        }
1617    }
1618    // A fixed-length linear pattern has exactly one path: an accepted match can never be
1619    // superseded from its own start, so the probe is statically decided (see [`Nfa::is_linear`]).
1620    if statically_terminal {
1621        return Ok(true);
1622    }
1623    let extend = nfa
1624        .may_extend(start, n_rows, matcher, budget, memoize)
1625        .await?;
1626    Ok(!extend)
1627}
1628
1629#[cfg(test)]
1630mod tests {
1631    use std::sync::{Arc, Mutex};
1632
1633    use risingwave_expr::expr::LogReport;
1634    use risingwave_pb::expr::expr_node::{RexNode, Type as PbExprType};
1635    use risingwave_pb::expr::{ExprNode, FunctionCall as PbFunctionCall};
1636    use risingwave_pb::stream_plan::MatchRecognizeDefineSlot as PbDefineSlot;
1637
1638    use super::*;
1639    use crate::executor::match_recognize::nfa::{LabeledMatch, Pattern, Quantifier};
1640
1641    /// Slot kinds as the planner encodes them (see `MatchRecognizeDefineSlot.kind`).
1642    const KIND_SELF: i32 = 1;
1643    const KIND_PREV: i32 = 2;
1644    const KIND_RUNNING_FIRST: i32 = 4;
1645    const KIND_RUNNING_LAST: i32 = 5;
1646
1647    /// One `int` column named `v` at index 0; `order_key` mirrors the physical position (unused
1648    /// without `WITHIN`, but kept consistent).
1649    fn buffered(vals: &[i32]) -> Vec<BufferedRow> {
1650        vals.iter()
1651            .enumerate()
1652            .map(|(i, v)| BufferedRow {
1653                seq: i as i64,
1654                order_key: Some(ScalarImpl::Int32(i as i32)),
1655                deadline: Deadline::Never,
1656                row: OwnedRow::new(vec![Some(ScalarImpl::Int32(*v))]),
1657            })
1658            .collect()
1659    }
1660
1661    fn input_ref(idx: u32) -> ExprNode {
1662        ExprNode {
1663            function_type: PbExprType::Unspecified as i32,
1664            return_type: Some(DataType::Int32.to_protobuf()),
1665            rex_node: Some(RexNode::InputRef(idx)),
1666        }
1667    }
1668
1669    /// `slots[0] = slots[1]`, i.e. the navigation slot compared against the candidate's own column.
1670    fn nav_eq_self_condition() -> ExprNode {
1671        ExprNode {
1672            function_type: PbExprType::Equal as i32,
1673            return_type: Some(DataType::Boolean.to_protobuf()),
1674            rex_node: Some(RexNode::FuncCall(PbFunctionCall {
1675                children: vec![input_ref(0), input_ref(1)],
1676            })),
1677        }
1678    }
1679
1680    /// A navigation slot over column `v`.
1681    fn nav_slot(kind: i32, vars: &[&str], offset: u32) -> PbDefineSlot {
1682        PbDefineSlot {
1683            kind,
1684            vars: vars.iter().map(|v| (*v).to_owned()).collect(),
1685            col_idx: 0,
1686            offset,
1687        }
1688    }
1689
1690    /// `DEFINE <symbol> AS <nav> = <symbol>.v`, compiled through the real proto lowering so the slot
1691    /// kinds are the planner's.
1692    fn nav_eq_self(symbol: &str, nav: PbDefineSlot) -> (String, CompiledDefine) {
1693        let pb = PbMatchRecognizeDefine {
1694            symbol: symbol.to_owned(),
1695            condition: Some(nav_eq_self_condition()),
1696            slots: vec![nav, nav_slot(KIND_SELF, &[], 0)],
1697        };
1698        (
1699            symbol.to_owned(),
1700            CompiledDefine::from_protobuf(&pb, LogReport).unwrap(),
1701        )
1702    }
1703
1704    fn plus(var: &str) -> Pattern {
1705        Pattern::Quantified(
1706            Box::new(Pattern::Var(var.to_owned())),
1707            Quantifier::Plus,
1708            false,
1709        )
1710    }
1711
1712    fn labels(s: &str) -> Vec<String> {
1713        s.chars().map(|c| c.to_string()).collect()
1714    }
1715
1716    /// All matches over `vals`, with the whole buffer safe (no watermark boundary in play).
1717    async fn find_all(
1718        nfa: &Nfa,
1719        defines: &HashMap<String, CompiledDefine>,
1720        vals: &[i32],
1721    ) -> Vec<LabeledMatch> {
1722        let rows = buffered(vals);
1723        let matcher = DefineMatcher {
1724            rows: &rows,
1725            defines,
1726            within: None,
1727        };
1728        nfa.find_matches_dynamic(rows.len(), &matcher, &SkipMode::PastLastRow)
1729            .await
1730            .unwrap()
1731    }
1732
1733    /// `DEFINE a AS LAST(a.v) = a.v` is a tautology: SQL:2016 defines a pattern-variable-qualified
1734    /// column reference as `RUNNING LAST` of that column, and the binder already resolves the bare
1735    /// `a.v` inside `a`'s own DEFINE to the candidate row. So the running navigation must see the
1736    /// candidate too — including on the match's first row, where no earlier `a` exists.
1737    #[tokio::test]
1738    async fn define_running_last_of_self_sees_candidate() {
1739        let defines = HashMap::from([nav_eq_self("a", nav_slot(KIND_RUNNING_LAST, &["a"], 0))]);
1740        assert_eq!(
1741            find_all(&Nfa::compile(&plus("a")), &defines, &[1, 2, 3]).await,
1742            vec![LabeledMatch {
1743                start: 0,
1744                end: 3,
1745                labels: labels("aaa"),
1746            }]
1747        );
1748    }
1749
1750    /// The eviction walker shares the finder's satisfies-source, so it must reach the same verdict:
1751    /// a lone row that satisfies `a AS LAST(a.v) = a.v` is a live partial match of `(a b)` and must
1752    /// be retained. Were the two to disagree, eviction would delete rows the matcher still needs.
1753    #[tokio::test]
1754    async fn define_running_last_of_self_keeps_start_alive() {
1755        let rows = buffered(&[1]);
1756        let defines = HashMap::from([nav_eq_self("a", nav_slot(KIND_RUNNING_LAST, &["a"], 0))]);
1757        let matcher = DefineMatcher {
1758            rows: &rows,
1759            defines: &defines,
1760            within: None,
1761        };
1762        let nfa = Nfa::compile(&Pattern::Concat(vec![
1763            Pattern::Var("a".to_owned()),
1764            Pattern::Var("b".to_owned()),
1765        ]));
1766        assert!(
1767            nfa.reaches_boundary_alive(
1768                0,
1769                rows.len(),
1770                &matcher,
1771                &mut ScanBudget::unlimited(),
1772                false,
1773            )
1774            .await
1775            .unwrap()
1776        );
1777    }
1778
1779    /// `DEFINE a AS FIRST(a.v) = a.v`: the candidate is the *first* `a` only while no earlier `a` is
1780    /// bound, so this holds for the match's first row and then pins later rows to that value.
1781    #[tokio::test]
1782    async fn define_running_first_of_self_sees_candidate() {
1783        let defines = HashMap::from([nav_eq_self("a", nav_slot(KIND_RUNNING_FIRST, &["a"], 0))]);
1784        assert_eq!(
1785            find_all(&Nfa::compile(&plus("a")), &defines, &[5, 5, 7]).await,
1786            vec![
1787                // 5, 5 share the first value; 7 breaks it and starts its own match.
1788                LabeledMatch {
1789                    start: 0,
1790                    end: 2,
1791                    labels: labels("aa"),
1792                },
1793                LabeledMatch {
1794                    start: 2,
1795                    end: 3,
1796                    labels: labels("a"),
1797                },
1798            ]
1799        );
1800    }
1801
1802    /// Running navigation that falls back on `labels` still indexes from the match's start. Here
1803    /// `x AS PREV(x.v) = x.v` cannot hold at position 0 (there is no previous row), so the match
1804    /// starts at 1, and `a+` binds two rows, so the third row's `FIRST(a.v)` resolves through
1805    /// `labels[1]` — pinning the `match_start + k` arithmetic where neither term is 0.
1806    #[tokio::test]
1807    async fn define_running_first_indexes_from_match_start() {
1808        let defines = HashMap::from([
1809            nav_eq_self("x", nav_slot(KIND_PREV, &[], 1)),
1810            nav_eq_self("a", nav_slot(KIND_RUNNING_FIRST, &["a"], 0)),
1811        ]);
1812        let nfa = Nfa::compile(&Pattern::Concat(vec![
1813            Pattern::Var("x".to_owned()),
1814            plus("a"),
1815        ]));
1816        assert_eq!(
1817            // `x` = the second 9 (its physical predecessor is the first 9); the run of 7s is `a+`,
1818            // whose `FIRST` is `rows[2]`, so the trailing 5 ends the match.
1819            find_all(&nfa, &defines, &[9, 9, 7, 7, 5]).await,
1820            vec![LabeledMatch {
1821                start: 1,
1822                end: 4,
1823                labels: labels("xaa"),
1824            }]
1825        );
1826    }
1827
1828    /// `SUBSET u = (a, b)` + `DEFINE a AS LAST(u.v) = a.v`: the candidate is tentatively an `a`, and
1829    /// `a ∈ u`, so it counts as the running last of `u`. Both spellings lower to this same slot —
1830    /// `LAST(u.v)` via the navigation path and the bare `u.v` via the input-ref rewriter (whose
1831    /// self-reference exemption is name-exact and therefore misses the subset).
1832    #[tokio::test]
1833    async fn define_running_last_of_subset_containing_self_sees_candidate() {
1834        // The slot's `vars` is `members_of(u)`, which preserves the SUBSET's declaration order, so
1835        // both orders must behave identically: membership is a set test, not a look at `vars[0]`.
1836        for members in [["a", "b"], ["b", "a"]] {
1837            let defines =
1838                HashMap::from([nav_eq_self("a", nav_slot(KIND_RUNNING_LAST, &members, 0))]);
1839            assert_eq!(
1840                find_all(&Nfa::compile(&plus("a")), &defines, &[1, 2, 3]).await,
1841                vec![LabeledMatch {
1842                    start: 0,
1843                    end: 3,
1844                    labels: labels("aaa"),
1845                }],
1846                "SUBSET u = ({}, {})",
1847                members[0],
1848                members[1]
1849            );
1850        }
1851    }
1852
1853    /// Navigation over a variable set that does *not* contain the candidate's own variable keeps
1854    /// resolving to the earlier row: `DEFINE b AS LAST(a.v) = b.v` compares against the `a`, never
1855    /// against the candidate `b`. This is the shape every existing DEFINE test uses.
1856    #[tokio::test]
1857    async fn define_running_last_of_other_var_excludes_candidate() {
1858        let defines = HashMap::from([nav_eq_self("b", nav_slot(KIND_RUNNING_LAST, &["a"], 0))]);
1859        let nfa = Nfa::compile(&Pattern::Concat(vec![
1860            Pattern::Var("a".to_owned()),
1861            Pattern::Var("b".to_owned()),
1862        ]));
1863        // Equal values: the `b` row equals the running `a`, so `(a b)` matches.
1864        assert_eq!(
1865            find_all(&nfa, &defines, &[7, 7]).await,
1866            vec![LabeledMatch {
1867                start: 0,
1868                end: 2,
1869                labels: labels("ab"),
1870            }]
1871        );
1872        // Different values: had the candidate been treated as the running last of `a`, this would
1873        // become a tautology and match.
1874        assert_eq!(find_all(&nfa, &defines, &[7, 8]).await, vec![]);
1875    }
1876
1877    /// Collects what the executor reports, so the `AFTER MATCH SKIP` diagnostic can be asserted
1878    /// without an actor (in production the report goes to `ActorEvalErrorReport`).
1879    #[derive(Clone, Default)]
1880    struct CollectReport(Arc<Mutex<Vec<String>>>);
1881
1882    impl EvalErrorReport for CollectReport {
1883        fn report(&self, error: ExprError) {
1884            self.0.lock().unwrap().push(error.to_string());
1885        }
1886    }
1887
1888    impl CollectReport {
1889        fn messages(&self) -> Vec<String> {
1890            self.0.lock().unwrap().clone()
1891        }
1892    }
1893
1894    /// The reported error must be actionable on its own — it is what lands in the `error=` field of
1895    /// the `stream_expr_error` log line. Pinned verbatim: it names the skip mode (once), the target
1896    /// variable, and the strategy the resume position degraded to.
1897    #[test]
1898    fn skip_degradation_report_names_clause_and_fallback() {
1899        let report = CollectReport::default();
1900        let mut reported = Vec::new();
1901        report_skip_degradation_once(
1902            &report,
1903            &SkipMode::ToLast("c".to_owned()),
1904            SkipDegradation::TargetAbsent,
1905            &mut reported,
1906        );
1907        report_skip_degradation_once(
1908            &report,
1909            &SkipMode::ToFirst("a".to_owned()),
1910            SkipDegradation::TargetAtMatchStart,
1911            &mut reported,
1912        );
1913        assert_eq!(
1914            report.messages(),
1915            vec![
1916                "Invalid parameter AFTER MATCH SKIP TO LAST: target variable `c` is bound to no row \
1917                 of the match, so there is no row to resume at; the scan resumed past the match's \
1918                 last row instead (degraded to SKIP PAST LAST ROW)",
1919                "Invalid parameter AFTER MATCH SKIP TO FIRST: target variable `a` resolves to the \
1920                 match's own first row, so resuming there would re-find the same match forever; the \
1921                 scan resumed at the row after the match's first row instead (degraded to SKIP TO \
1922                 NEXT ROW)",
1923            ]
1924        );
1925    }
1926
1927    /// Volume policy: the degradation repeats without bound (on every match, when no match can bind
1928    /// the target), and the message has no row, match or partition identity, so a per-match report
1929    /// would be byte-identical chatter. One report per kind per watermark pass.
1930    #[test]
1931    fn skip_degradation_report_is_deduplicated_per_pass() {
1932        let report = CollectReport::default();
1933        let skip = SkipMode::ToLast("x".to_owned());
1934        let mut reported = Vec::new();
1935        for _ in 0..5 {
1936            report_skip_degradation_once(
1937                &report,
1938                &skip,
1939                SkipDegradation::TargetAbsent,
1940                &mut reported,
1941            );
1942        }
1943        assert_eq!(report.messages().len(), 1, "{:?}", report.messages());
1944        // A different degradation is a different diagnostic, so it is reported once too.
1945        report_skip_degradation_once(
1946            &report,
1947            &skip,
1948            SkipDegradation::TargetAtMatchStart,
1949            &mut reported,
1950        );
1951        assert_eq!(report.messages().len(), 2, "{:?}", report.messages());
1952        // The next pass starts with a fresh set, so a persisting condition keeps being visible.
1953        let mut next_pass = Vec::new();
1954        report_skip_degradation_once(
1955            &report,
1956            &skip,
1957            SkipDegradation::TargetAbsent,
1958            &mut next_pass,
1959        );
1960        assert_eq!(report.messages().len(), 3, "{:?}", report.messages());
1961    }
1962
1963    /// The emit-finality gate must agree with batch preference semantics, not with the positional
1964    /// "a later row exists" proxy. These pin the two supersession shapes that proxy gets wrong.
1965    /// `WITHIN` deadline evaluation, and the two tests the executor reads off the cached value.
1966    mod within_deadline {
1967        use risingwave_common::types::DatumRef;
1968        use risingwave_expr::expr::{LiteralExpression, build_from_pretty};
1969
1970        use super::*;
1971
1972        /// Records every error it is handed, so a test can see what a wrapper let through.
1973        #[derive(Clone, Default)]
1974        struct RecordingReport(Arc<Mutex<Vec<String>>>);
1975
1976        impl EvalErrorReport for RecordingReport {
1977            fn report(&self, error: ExprError) {
1978                self.0.lock().unwrap().push(error.to_string());
1979            }
1980        }
1981
1982        /// `first + 2::smallint` over an int2 order key — the deadline `lower_within` emits for
1983        /// `ORDER BY <smallint> ... WITHIN 2::smallint` — built the way `from_proto` builds it.
1984        fn int2_plus_two(report: RecordingReport) -> Option<NonStrictExpression> {
1985            Some(NonStrictExpression::new_topmost(
1986                build_from_pretty("(add:int2 $0:int2 2:int2)"),
1987                DeadlineErrorReport::new(report),
1988            ))
1989        }
1990
1991        async fn deadline_of(order_key: i16) -> Deadline {
1992            eval_deadline(
1993                &int2_plus_two(RecordingReport::default()),
1994                &Some(ScalarImpl::Int16(order_key)),
1995            )
1996            .await
1997        }
1998
1999        #[tokio::test]
2000        async fn representable_sum_is_the_deadline() {
2001            assert_eq!(deadline_of(1).await, Deadline::At(ScalarImpl::Int16(3)));
2002            // Landing exactly on the type's maximum is still representable.
2003            assert_eq!(
2004                deadline_of(32765).await,
2005                Deadline::At(ScalarImpl::Int16(i16::MAX))
2006            );
2007        }
2008
2009        /// `32766 + 2` leaves int2. Non-strict evaluation folded that into NULL, which the span
2010        /// check read as "outside the window": a valid zero-span match at the top of the key's
2011        /// range was silently dropped. Past the type's range the window never closes.
2012        #[tokio::test]
2013        async fn overflowing_sum_never_closes() {
2014            assert_eq!(deadline_of(32766).await, Deadline::Never);
2015            assert_eq!(deadline_of(i16::MAX).await, Deadline::Never);
2016        }
2017
2018        #[tokio::test]
2019        async fn absent_within_never_closes() {
2020            let d = eval_deadline(&None, &Some(ScalarImpl::Int16(0))).await;
2021            assert_eq!(d, Deadline::Never);
2022        }
2023
2024        /// The overflow is a legitimate outcome, not a compute error: it must not be counted and
2025        /// logged against the actor for every row at the top of the key's range. Anything else
2026        /// the expression raises still is — bare or inside the `Function` wrapper a generated
2027        /// implementation puts around its function's error.
2028        #[tokio::test]
2029        async fn overflow_is_not_reported_but_other_errors_are() {
2030            let report = RecordingReport::default();
2031            let expr = int2_plus_two(report.clone());
2032            assert_eq!(
2033                eval_deadline(&expr, &Some(ScalarImpl::Int16(32766))).await,
2034                Deadline::Never
2035            );
2036            assert!(
2037                report.0.lock().unwrap().is_empty(),
2038                "an out-of-range deadline must not reach the actor's error report"
2039            );
2040
2041            let wrapper = DeadlineErrorReport::new(report.clone());
2042            let no_args = || Vec::<DatumRef<'_>>::new();
2043            wrapper.report(ExprError::NumericOutOfRange);
2044            wrapper.report(ExprError::function(
2045                "add",
2046                no_args(),
2047                ExprError::NumericOverflow,
2048            ));
2049            assert!(report.0.lock().unwrap().is_empty());
2050
2051            let wrapped = ExprError::function("divide", no_args(), ExprError::DivisionByZero);
2052            let wrapped_text = wrapped.to_string();
2053            wrapper.report(ExprError::DivisionByZero);
2054            wrapper.report(wrapped);
2055            assert_eq!(
2056                *report.0.lock().unwrap(),
2057                vec![ExprError::DivisionByZero.to_string(), wrapped_text],
2058                "every other error is forwarded untouched"
2059            );
2060        }
2061
2062        /// Against a never-closing window the span test admits every order key and the finality
2063        /// test never fires — so a match whose deadline overflowed is decided structurally,
2064        /// exactly like a match without `WITHIN`. A representable deadline keeps the inclusive
2065        /// span bound and the strict watermark boundary.
2066        #[test]
2067        fn never_admits_everything_and_never_closes() {
2068            let never = Deadline::Never;
2069            assert!(never.admits(&ScalarImpl::Int16(i16::MAX)));
2070            assert!(!never.closed_at(&ScalarImpl::Int16(i16::MAX)));
2071
2072            let at = Deadline::At(ScalarImpl::Int16(10));
2073            assert!(
2074                at.admits(&ScalarImpl::Int16(10)),
2075                "the span bound is inclusive"
2076            );
2077            assert!(!at.admits(&ScalarImpl::Int16(11)));
2078            assert!(
2079                !at.closed_at(&ScalarImpl::Int16(10)),
2080                "the watermark boundary is strict"
2081            );
2082            assert!(at.closed_at(&ScalarImpl::Int16(11)));
2083        }
2084
2085        /// Two rows with int2 order keys, each carrying the deadline `eval_deadline` computes for
2086        /// it, no `DEFINE` (every row satisfies every variable), matched against `(a b)` with the
2087        /// span check armed.
2088        async fn ab_matches(order_keys: [i16; 2]) -> Vec<LabeledMatch> {
2089            let mut rows = Vec::new();
2090            let expr = int2_plus_two(RecordingReport::default());
2091            for (i, k) in order_keys.into_iter().enumerate() {
2092                let order_key = Some(ScalarImpl::Int16(k));
2093                let deadline = eval_deadline(&expr, &order_key).await;
2094                rows.push(BufferedRow {
2095                    seq: i as i64,
2096                    order_key,
2097                    deadline,
2098                    row: OwnedRow::new(vec![Some(ScalarImpl::Int32(0))]),
2099                });
2100            }
2101            // Only `is_some()` is read on the hot path; the predicate itself is never evaluated.
2102            let within = NonStrictExpression::for_test(LiteralExpression::new(
2103                DataType::Boolean,
2104                Some(ScalarImpl::Bool(true)),
2105            ));
2106            let defines = HashMap::new();
2107            let matcher = DefineMatcher {
2108                rows: &rows,
2109                defines: &defines,
2110                within: Some(&within),
2111            };
2112            let nfa = Nfa::compile(&Pattern::Concat(vec![
2113                Pattern::Var("a".to_owned()),
2114                Pattern::Var("b".to_owned()),
2115            ]));
2116            nfa.find_matches_dynamic(rows.len(), &matcher, &SkipMode::PastLastRow)
2117                .await
2118                .unwrap()
2119        }
2120
2121        /// The reported case: `a`@32766, `b`@32766, `WITHIN 2::smallint`. The span is 0, and the
2122        /// start row's deadline overflows. The match must be found.
2123        #[tokio::test]
2124        async fn overflowed_deadline_does_not_reject_a_match_inside_the_bound() {
2125            assert_eq!(
2126                ab_matches([32766, 32766]).await,
2127                vec![LabeledMatch {
2128                    start: 0,
2129                    end: 2,
2130                    labels: labels("ab"),
2131                }]
2132            );
2133        }
2134
2135        /// Control: the span check still bites where the deadline IS representable. `a`@32763 has
2136        /// deadline 32765, so `b`@32766 is outside the window; `b` cannot start `(a b)` alone.
2137        #[tokio::test]
2138        async fn representable_deadline_still_rejects_a_match_past_the_bound() {
2139            assert!(ab_matches([32763, 32766]).await.is_empty());
2140        }
2141    }
2142
2143    mod finality_gate {
2144        use std::collections::BTreeSet;
2145
2146        use super::super::match_is_final;
2147        use super::*;
2148        use crate::executor::match_recognize::nfa::{Nfa, ScanBudget, SetMatcher};
2149
2150        fn sets(seq: &str) -> Vec<BTreeSet<String>> {
2151            seq.chars()
2152                .map(|c| BTreeSet::from([c.to_string()]))
2153                .collect()
2154        }
2155
2156        fn var(s: &str) -> Pattern {
2157            Pattern::Var(s.to_owned())
2158        }
2159
2160        fn concat(names: &str) -> Pattern {
2161            Pattern::Concat(names.chars().map(|c| var(&c.to_string())).collect())
2162        }
2163
2164        async fn gate(
2165            pattern: &Pattern,
2166            rows: &str,
2167            resume_pos: usize,
2168            start: usize,
2169            within_final: bool,
2170        ) -> bool {
2171            let nfa = Nfa::compile(pattern);
2172            let matcher = SetMatcher::new(sets(rows));
2173            let mut budget = ScanBudget::unlimited();
2174            match_is_final(
2175                &nfa,
2176                &matcher,
2177                resume_pos,
2178                start,
2179                rows.len(),
2180                within_final,
2181                // Always exercise the real probe in these tests, even for linear patterns.
2182                false,
2183                &mut budget,
2184                true,
2185            )
2186            .await
2187            .unwrap()
2188        }
2189
2190        /// `PATTERN (a b c d | a b)` over rows a,b,c: the provisional match is (0,2) via the
2191        /// second branch and a later row exists (`c`), but the preferred first branch is blocked
2192        /// at the BUFFER boundary (it consumed a,b,c and needs d) — a future `d` row makes the
2193        /// batch answer (0,4). "Followed" is not "final": the match must be held.
2194        #[tokio::test]
2195        async fn blocked_preferred_branch_holds_followed_match() {
2196            let pattern = Pattern::Alt(vec![concat("abcd"), concat("ab")]);
2197            assert!(!gate(&pattern, "abc", 0, 0, false).await);
2198        }
2199
2200        /// `PATTERN (x n n | n)` over rows x,n: the only provisional match is (1,2) via the second
2201        /// branch and it is terminal from its own start — but position 0 is still alive at the
2202        /// boundary (`x n` inside the preferred branch), and a future `n` row makes the batch
2203        /// answer (0,3). Leftmost preference: the gap position must hold the emission.
2204        #[tokio::test]
2205        async fn alive_gap_position_holds_boundary_match() {
2206            let pattern = Pattern::Alt(vec![concat("xnn"), var("n")]);
2207            assert!(!gate(&pattern, "xn", 0, 1, false).await);
2208        }
2209
2210        /// Positive control: `PATTERN (a b)` over rows a,b,z — the follower `z` kills every
2211        /// extension path, no gap, so the match is final at arrival.
2212        #[tokio::test]
2213        async fn decided_followed_match_is_final() {
2214            let pattern = concat("ab");
2215            assert!(gate(&pattern, "abz", 0, 0, false).await);
2216        }
2217
2218        /// Positive control: a boundary match whose pattern has no extension path is final
2219        /// without any follower.
2220        #[tokio::test]
2221        async fn terminal_boundary_match_is_final() {
2222            let pattern = var("b");
2223            assert!(gate(&pattern, "b", 0, 0, false).await);
2224        }
2225
2226        /// A closed WITHIN window decides everything: the gap row's window closed no later than
2227        /// the match's own (order keys are ordered), and no future row can satisfy the span bound,
2228        /// so within-finality bypasses both the gap and the extension probe.
2229        #[tokio::test]
2230        async fn within_finality_overrides_alive_gap() {
2231            let pattern = Pattern::Alt(vec![concat("xnn"), var("n")]);
2232            assert!(gate(&pattern, "xn", 0, 1, true).await);
2233        }
2234    }
2235
2236    /// A candidate with more bound labels than preceding rows breaks the `CandidateMatcher`
2237    /// contract; previously `pos - labels.len()` wrapped into a match start past the buffer.
2238    /// Under strict consistency (the default) the breach panics; non-strict refuses the candidate.
2239    #[tokio::test]
2240    #[should_panic(expected = "inconsistency")]
2241    async fn more_labels_than_rows_is_reported_not_wrapped() {
2242        let rows = buffered(&[1, 2]);
2243        let defines = HashMap::new();
2244        let matcher = DefineMatcher {
2245            rows: &rows,
2246            defines: &defines,
2247            within: None,
2248        };
2249        let _ = matcher.matches("a", 1, &labels("aaa")).await;
2250    }
2251
2252    /// Non-strict: the same breach refuses the candidate instead of reading past the buffer.
2253    #[tokio::test]
2254    async fn non_strict_more_labels_than_rows_refuses_the_candidate() {
2255        let config = risingwave_common::config::StreamingConfig {
2256            unsafe_disable_strict_consistency: true,
2257            ..Default::default()
2258        };
2259        crate::CONFIG
2260            .scope(Arc::new(config), async {
2261                let rows = buffered(&[1, 2]);
2262                let defines = HashMap::new();
2263                let matcher = DefineMatcher {
2264                    rows: &rows,
2265                    defines: &defines,
2266                    within: None,
2267                };
2268                assert!(!matcher.matches("a", 1, &labels("aaa")).await.unwrap());
2269                // A well-formed question on the same matcher still answers normally.
2270                assert!(matcher.matches("a", 1, &labels("a")).await.unwrap());
2271            })
2272            .await;
2273    }
2274}