Skip to content

feat(stream): add SQL MATCH_RECOGNIZE as a pure matcher over WatermarkSort - #26584

Open
dahankzter wants to merge 10 commits into
risingwavelabs:mainfrom
dahankzter:feat/match-recognize-v2
Open

feat(stream): add SQL MATCH_RECOGNIZE as a pure matcher over WatermarkSort#26584
dahankzter wants to merge 10 commits into
risingwavelabs:mainfrom
dahankzter:feat/match-recognize-v2

Conversation

@dahankzter

@dahankzter dahankzter commented Aug 4, 2026

Copy link
Copy Markdown

I hereby agree to the terms of the RisingWave Labs, Inc. Contributor License Agreement.

What's changed and what's your intention?

Supersedes #25899, implementing the architecture @chenzl25 proposed in review: split ordering from matching. MATCH_RECOGNIZE becomes a pure ordered-input matcher, and all ordering concerns (buffering, out-of-order arrival, watermark release) live in the sort operator that already owns them.

Architecture

StreamExchange HASH(partition keys)   -- exact key order: the matcher's state table hashes in PARTITION BY order
  └─ StreamWatermarkSort              -- full ORDER BY, releases rows strictly below the watermark
       └─ StreamMatchRecognize        -- same fragment (no exchange in between, order preserved)
  • StreamEowcSortStreamWatermarkSort: renamed (it is watermark-driven, not EOWC-specific) and extended with secondary order columns — a state-table PK extension after the sort column — so it realizes the full ORDER BY, not just the leading time column.
  • The matcher is order-oblivious: it feeds rows to the NFA on arrival and emits each match as soon as it is decidable. A match is final when no earlier position could still produce a leftmost-preferred match, and no more-preferred path from its own start can be completed by future rows — or when its WITHIN deadline has strictly passed the watermark, which decides both at once. Results appear when they are decidable, not at window close; the EOWC-only restriction from feat(stream): add SQL MATCH_RECOGNIZE (row pattern recognition) #25899 is gone.
  • State shrinks to one internal table (rows still referenced by live partial matches). The two NFA-snapshot tables from feat(stream): add SQL MATCH_RECOGNIZE (row pattern recognition) #25899 are dropped; recovery rebuilds matcher state by re-feeding the retained rows, and rescaling works the same way. seq, the PK tiebreaker for equal ORDER BY keys, is a per-actor monotonic counter seeded above every retained row on rebuild, so ties are re-fed in arrival order and replay is deterministic.
  • Watermark handling in the matcher is reduced to WITHIN finality and dead-prefix pruning; it does not forward watermarks.
  • Pathological patterns are metered everywhere: every NFA walk — including the match finder — runs under a per-visit scan budget (scoped per row / per partition visit, so one bad partition cannot starve the others) with a soundness-gated memo of failed positions. Budget exhaustion is never converted into a verdict: nothing is emitted, frozen, or evicted on partial information; the condition is reported once per pass and retried.
  • MatchRecognizeInputMode proto enum: EVENT_TIME implemented here; PROCESSING_TIME reserved for a follow-up, and the executor rejects any other mode rather than running against input whose ordering guarantee does not hold.
  • Rows with a NULL leading order key are filtered out below the sort at plan time, mirroring event-time processing dropping NULL-rowtime rows.

SQL scope (unchanged from #25899 v1)

Streaming, append-only input, ONE ROW PER MATCH; PARTITION BY + watermarked ORDER BY; PATTERN with concat, alternation, grouping, quantifiers * + ? {n,m} (greedy + reluctant), PERMUTE; DEFINE with running navigation (PREV/NEXT/FIRST/LAST, cross-variable refs); MEASURES incl. CLASSIFIER(), SUBSET, aggregates; AFTER MATCH SKIP variants; WITHIN.

Known costs and planned follow-ups

  • A partition holding an open partial match re-scans its live window per arriving row; incrementalizing the provisional tail is the main planned perf follow-up. Related constant-factor work: per-row predicate caching, WITHIN deadline precompute, label interning.
  • Each watermark visits every partition; a per-partition wakeup frontier (deadline index) would make that proportional to the partitions that need attention.
  • Retained rows are resident in executor memory (bounded by WITHIN; a partial without WITHIN is retained until its partition decides it — noted at CREATE); wiring this into memory accounting is a follow-up.
  • The sort and the matcher each persist a row once (buffer vs retained rows) — the cost of the ordering/matching split.
  • PROCESSING_TIME input mode: enum reserved, planned follow-up.
  • Empty matches are not emitted: PATTERN (a*) yields no summary row on non-matching input (SQL:2016 would emit one; Flink rejects such patterns).
  • ALL ROWS PER MATCH, MATCH_NUMBER(), pattern anchors ^/$, and batch execution are not implemented (same as feat(stream): add SQL MATCH_RECOGNIZE (row pattern recognition) #25899).
  • The design doc (docs/dev/src/design/match-recognize.md) rewrite for this architecture and operator metrics land as follow-ups on this PR.

Tests

  • 45 planner cases (including multi-column PARTITION BY distribution pinning), parser tests, 86 stream unit tests including a 200-seed randomized operation-sequence oracle checking the incremental matcher against the batch scan and unit tests for the emission-finality gate.
  • 29 e2e SLT files (e2e_test/streaming/match_recognize*.slt) covering skip modes, navigation, WITHIN boundaries, alternation preference (held vs superseded vs killed), PERMUTE listing-order preference via CLASSIFIER(), and state-table introspection; plus a recovery serial (kill/recover, including a multi-row partial over tied order keys) and rescale (ALTER ... SET PARALLELISM) — all green on a local cluster.
  • fmt / clippy clean.

Relevant CI coverage a maintainer may want to enable (I can't add labels as an external contributor): e2e + recovery deterministic simulation, SQLSmith, and user-facing-changes (new SQL syntax).

Checklist

  • I have written necessary rustdoc comments.
  • I have added necessary unit tests and integration tests.
  • I have added test labels as necessary. (No permission — see the note above.)
  • I have added fuzzing tests or opened an issue to track them. (SQLSmith recognizes the new syntax but does not yet generate it; happy to open a tracking issue.)

Documentation

  • My PR needs documentation updates.
Release note

RisingWave now supports the SQL MATCH_RECOGNIZE clause (row pattern recognition) on streaming, append-only inputs with a watermarked ORDER BY column. Patterns are matched per partition in event-time order, and each match is emitted as soon as it is decidable. Supported: ONE ROW PER MATCH, quantified patterns with PERMUTE, DEFINE with navigation functions, MEASURES with CLASSIFIER() and aggregates, AFTER MATCH SKIP variants, and WITHIN time bounds.

The operator is not EOWC-specific: it buffers out-of-order rows, emits
the rows before each watermark in order, and forwards the watermark --
a general watermark-driven sort that EOWC conversion, gap fill and EOWC
over-window happen to use. Naming it for what it does prepares it for
its next consumer: MATCH_RECOGNIZE as an ordered-input matcher, where
the sort satisfies the ordered-input requirement in front of the
matcher rather than being an emit-mode detail.

Mechanical: type, module file, macro entry and plan-display name;
planner test outputs regenerated. No behavior change.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
The SQL:2016 row-pattern-recognition table factor: PARTITION BY /
ORDER BY, MEASURES, ONE ROW PER MATCH, AFTER MATCH SKIP {PAST LAST ROW
| TO NEXT ROW | TO FIRST/LAST var}, PATTERN with concatenation,
alternation, grouping, quantifiers (greedy and reluctant) and PERMUTE,
SUBSET, DEFINE, and WITHIN. AST mirrors upstream sqlparser-rs shapes.

The binder rejects the clause explicitly for now; binding lands next in
the series. Downstream exhaustive matches (catalog rename recursion,
sqlsmith reducer) are extended alongside.

Carried verbatim from the review branch of risingwavelabs#25899.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
The binder lowers MEASURES and DEFINE to expressions over synthetic
navigation-slot rows, registers pattern variables and SUBSET unions as
alias blocks, and enforces the validation battery: plain-column
PARTITION BY / ORDER BY, ascending-only ordering, quantifier bounds and
the whole-pattern NFA state budget, PREV restricted to reads inside the
match span (an exact minimum-start-distance walk over the pattern),
physical NEXT rejected in DEFINE, WITHIN bounds must be positive
constants, aggregate/navigation modifier rejection in both MEASURES and
DEFINE, and skip-target validation against pattern-only variables.

LogicalMatchRecognize carries the standard trait set (predicate-pushdown
barrier, column pruning into the clause's expressions, rewrite-for-
stream with an identity output mapping). to_stream performs the
append-only and watermark checks and then defers: the stream plan --
an ordered-input matcher over a WatermarkSort -- lands next in the
series, together with the planner test data.

Carried from the review branch of risingwavelabs#25899 (rounds 2-4 of review
hardening included).

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
The Thompson-construction NFA and its walkers, carried with all review
hardening: the preference-order finder (first accepting path in
transition order gives greedy/reluctant quantifiers and ordered
alternation their semantics), the pull-based match scan, terminal-
finality probing that follows the same preference order (may_extend),
per-start (state, position) failure memoization sound for path-
independent DEFINE predicates and recorded only at consumption
boundaries, the per-visit evaluation budget that stops walks without
fabricating verdicts, and the static accept-reachability precompute.

Unit-tested standalone (33 tests, including the catastrophic-
backtracking collapse and the budget's conservative outcomes); the
executor that drives it over ordered input lands next in the series.

Carried from the review branch of risingwavelabs#25899.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
The sort executor emits rows in (sort column, buffer-table key) order,
so the emission order is shaped entirely by the inferred table key.
with_secondary_order appends caller-specified columns to that key right
after the sort column -- before the distribution and stream keys --
making the emission order the caller's full ORDER BY with a
deterministic tiebreak. No executor or proto change; existing callers
keep plain watermark sorting.

First consumer is MATCH_RECOGNIZE as an ordered-input matcher, whose
multi-column ORDER BY must survive the sort. Also audited the sort
buffer's watermark comparison while here: it already emits strictly
below the watermark (a row AT the watermark is retained), matching the
tree-wide convention.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
… WatermarkSort

The matcher no longer owns ordering. The stream plan hash-shards the
append-only input by the PARTITION BY key, inserts a WatermarkSort over
the full ORDER BY (leading watermark column plus secondary order
columns), and places the matcher directly above it in the same fragment
-- an exchange between them would destroy the ordering the sort just
established. Rows therefore reach the matcher already in ORDER BY
order, strictly below each forwarded watermark, and the matcher owns
only NFA state and match finalization.

The plain query form is the supported form: with ordering hoisted out,
emission is on match completion rather than an emit-mode property, so
the previous EMIT ON WINDOW CLOSE requirement is gone.

The proto node carries an explicit input mode (EVENT_TIME now;
PROCESSING_TIME reserved for arrival-order matching) and a single state
table -- the retained rows referenced by live partial matches -- in
place of the previous buffer plus two wakeup-frontier tables: the sort
owns out-of-order buffering and time-triggering, so the frontier
machinery has no reason to exist. The executor builder is a loud stub;
the ordered-input executor lands next in the series.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
The matcher core the ordered-input executor drives: feed rows in ORDER
BY order and re-derive the provisional match set over the unfrozen
suffix (consumed history is never rescanned), freeze matches whose scan
region is dead at the boundary, and finalize consumed prefixes with
position rebasing (finalize_before_seq's no-straddle contract, with the
straddling-finalize fast path proven by a 200-seed randomized
operation-sequence oracle against the batch scan).

The catastrophic-backtracking defenses meter every walk INCLUDING the
finder: advance/rescan drive the budgeted, memoized pull scan
(Nfa::next_match) and thread the per-visit budget and memoizability
flag through the freeze-gate liveness checks. A spent budget is never
converted into a verdict -- the scan stops with an incomplete tail and
the freeze loop holds (exhaustion treated as "alive"), so the
degradation is latency, never a wrong or lost match.

The out-of-order invalidation machinery (truncate_from_seq) and the
provisional-changelog helpers are compiled test-only: unreachable under
a WatermarkSort, they stay proven by the differential oracle for the
input modes that would need them.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
Consumes rows already in full ORDER BY order from the upstream
WatermarkSort (same fragment), so the executor owns only NFA state and
match finalization. Each partition keeps an incremental matcher fed on
arrival plus the retained rows its live matches still reference; there
is no out-of-order buffer, and consumed history is never rescanned.

Emission is on decidability, gated by match_is_final: the first
provisional match is emitted once every gap position before it is
provably dead at the boundary (an alive gap could still yield an
earlier, leftmost-preferred match) and no more-preferred path from its
own start can be completed by future rows (may_extend probed at the
buffer boundary -- "a later row exists" is NOT finality: a preferred
branch can be blocked past the follower), or once its WITHIN deadline
has strictly passed the watermark, which decides both questions at
once. Emitting consumes everything up to the match's skip-resume
position, including earlier still-live partials -- the same abandonment
the batch scan performs, and what keeps the hidden _match_id (the start
row's seq) unique forever. The matcher rebases in place where its
finalize contract allows and is rebuilt from the survivors otherwise.

The watermark drives only WITHIN finality and dead-prefix pruning; the
output carries no watermark columns, so none is forwarded. Every NFA
walk shares a per-visit scan budget (one budget per row on the data
path, per partition visit on the watermark path, so one pathological
partition cannot starve the others) with DEFINE-slot memoization where
sound; exhaustion degrades conservatively -- nothing further emitted or
evicted, everything undecided retained, reported once per pass.

State is one table of retained rows keyed (partition..., order...,
seq), where seq is a plain per-actor counter seeded above every
retained row on rebuild: it is the PK tiebreaker for equal ORDER BY
keys, so it must be monotonic in arrival order for recovery to re-feed
ties exactly as the live matcher saw them. Recovery and rescale rebuild
each partition's matcher by re-feeding its rows in key order, one
batched advance per partition -- no emission during rebuild, since an
emittable match is always consumed in the epoch it emits.

The executor builder replaces the loud stub and rejects input modes
other than EVENT_TIME; the pattern decoder re-validates the PERMUTE and
quantifier size caps so a corrupt or skewed plan fails with an error
instead of allocating factorially. Slot resolution and DefineMatcher
navigation are carried verbatim from the review branch with their unit
tests.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
…plan

The full family carried from the review branch, adapted to the split
architecture: the EMIT ON WINDOW CLOSE clause is gone everywhere (plain
form is the form), and the one test that asserted on the operator's
internals -- idle-partition WITHIN eviction -- now distinguishes the
two state tables the split produces: the WatermarkSort's buffer holds
rows not yet released by the watermark, the matcher's table holds only
rows a live partial still references. The timed-out partial must
vanish from the matcher's table with no further input in its
partition; the unreleased sentinel sits in the sort's buffer where it
now belongs. The EOWC-requirement test is deleted with the requirement.

Three additions pin the emission gate and the rebuild order end to end:
preference supersession (a provisional match held while a preferred
branch is blocked past its follower, and while a gap position is still
alive -- each with both the superseding and the killing ending, plus an
idle-partition starvation control), PERMUTE preference (CLASSIFIER
distinguishes the listing-order branch from an arbitrary ordering
choice, which a count-only assertion cannot), and a recovery scenario
with a multi-row partial over TIED order keys (re-feed order must equal
arrival order, pinning the seq monotonicity the rebuild relies on).

29 files plus the recovery serial (exercising the rebuild-by-refeed
recovery path end to end) and the rescale test, all green.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
@dahankzter
dahankzter force-pushed the feat/match-recognize-v2 branch from 5e4ef85 to 1524fb0 Compare August 4, 2026 20:26
Between the state-table storage metrics and the expression-error
surface, the operator itself was a black box: nothing said how many
matches a view produced, how fast its buffer was draining, or whether
the scan budget was degrading it. Three counters, registered and
labelled exactly like the neighbouring over-window set
(table_id/actor_id/fragment_id):

- stream_match_recognize_matches_emitted_count
- stream_match_recognize_evicted_rows_count (rows leaving the buffer,
  whether consumed by an emitted match or pruned as a dead prefix)
- stream_match_recognize_scan_budget_exhausted_count

The last one is the alerting hook for catastrophic-backtracking
degradation: the log line is deduplicated to once per message pass,
while the counter counts every affected visit.

docs/metrics/inventory.tsv is regenerated (the extractor is a CI gate);
the regeneration also picks up rows for metrics that arrived on main
without one.

Signed-off-by: Henrik Ma Johansson <dahankzter@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant