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