risingwavelabs/risingwave · error

unreachable (prev != curr)

Error message

unreachable (prev != curr)

What it means

This is a deliberate panic in RisingWave's dynamic filter executor. In `get_range`, the executor computes a cache-lookup range based on the relationship between the previous and current watermark values; the `(None, None)` arm assumes that if both are None this branch is never taken because `prev != curr` is an invariant maintained by the caller (`execute_inner` only enters this path when the watermark actually advanced). If the invariant is violated the executor panics with `unreachable!()` instead of returning a recoverable error.

Source

Thrown at src/stream/src/executor/dynamic_filter.rs:256

                    let range = match self.comparator {
                        GreaterThan | LessThanOrEqual => (Excluded(c), Included(p)),
                        GreaterThanOrEqual | LessThan => (Included(c), Excluded(p)),
                        _ => unreachable!(),
                    };
                    let is_insert = matches!(self.comparator, GreaterThan | GreaterThanOrEqual);
                    (range, true, is_insert)
                } else {
                    // c > p
                    let range = match self.comparator {
                        GreaterThan | LessThanOrEqual => (Excluded(p), Included(c)),
                        GreaterThanOrEqual | LessThan => (Included(p), Excluded(c)),
                        _ => unreachable!(),
                    };
                    let is_insert = matches!(self.comparator, LessThan | LessThanOrEqual);
                    (range, false, is_insert)
                }
            }
            (None, None) => unreachable!(), // prev != curr
        }
    }

    fn to_row_bound(bound: Bound<ScalarImpl>) -> Bound<impl Row> {
        bound.map(|s| once(Some(s)))
    }

    #[try_stream(ok = Message, error = StreamExecutorError)]
    async fn execute_inner(mut self) {
        let input_l = self.source_l.take().unwrap();
        let input_r = self.source_r.take().unwrap();

        // Derive the dynamic expression
        let l_data_type = input_l.schema().data_types()[self.key_l].clone();
        let r_data_type = input_r.schema().data_types()[0].clone();
        // The types are aligned by frontend.
        assert_eq!(l_data_type, r_data_type);

View on GitHub (pinned to 6469eb736d)

Solutions

  1. Inspect the upstream watermark chain feeding the dynamic filter (source, merge, watermark decoders) to find why a None watermark was re-delivered; fix the producer so watermarks only advance.
  2. Check for a recent RisingWave version regression and upgrade/downgrade the binary accordingly; report the issue with the panic backtrace if reproducible.
  3. As a defensive code change, return an error or log and treat (None, None) as an empty range instead of panicking, so the actor can be gracefully failed instead of aborting the process.
  4. Verify that `execute_inner` gates `get_range` on `prev != curr` (or prev being Some) before the call.

Example fix

// before
(None, None) => unreachable!(), // prev != curr
// after
(None, None) => {
    tracing::warn!("dynamic filter: prev and curr watermark both None; using empty range");
    (Bound::Unbounded, Bound::Unbounded, false)
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Before wiring a dynamic filter, ensure the watermark source always advances monotonically:
// prev.is_some() implies curr.is_some() && curr > prev when get_range is invoked.
fn watermark_advanced(prev: &Option<ScalarImpl>, curr: &Option<ScalarImpl>) -> bool {
    match (prev, curr) {
        (Some(p), Some(c)) => c > p,
        (None, Some(_)) => true,
        _ => false,
    }
}

Type guard

fn has_valid_watermark(w: &Option<ScalarImpl>) -> bool { w.is_some() }

Try / catch

// Run the stream cluster under a supervisor; a panic here fails the actor.
// Capture the panic backtrace from .risingwave/log and report upstream:
match actor_join_handle.await {
    Ok(_) => {},
    Err(e) => log::error!("dynamic filter actor panicked: {e}"),
}

Prevention

When it happens

Trigger: Executing the dynamic filter executor with a None-to-None watermark transition where the cache-range calculation expects an actual prev-to-curr watermark move; i.e., `prev_watermark` and `curr_watermark` are both None while control flow reaches the `(None, None)` match arm in `get_range`.

Common situations: A bug in watermark propagation upstream (e.g., a source or merge executor emitting an initial watermark that is None twice), or a custom/modified executor wiring where `execute_inner` calls `get_range` without first checking that the watermark advanced. Real users should essentially never hit this; hitting it indicates an internal invariant break.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of risingwavelabs/risingwave@6469eb736d (2026-09-11). Data as JSON: /api/errors/1d424a441e4655cc. Report an issue: GitHub.