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