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(°radation) {
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}