risingwavelabs/risingwave · error

update should always be converted to batch plan

Error message

update should always be converted to batch plan

What it means

`LogicalUpdate::to_stream` unconditionally panics with `unreachable!()`. DML statements like UPDATE are never converted into streaming plans: they are executed by the batch engine (DML is lowered to a batch pipeline against the table), so the ToStream path for LogicalUpdate is intentionally unreachable. Reaching it means the optimizer attempted to stream a DML statement.

Source

Thrown at src/frontend/src/optimizer/plan_node/logical_update.rs:128

        let core = generic::Update {
            table_name: self.core.table_name.clone(),
            table_id: self.core.table_id,
            table_version_id: self.core.table_version_id,
            input: new_input,
            old_exprs: self.core.old_exprs.clone(),
            new_exprs: self.core.new_exprs.clone(),
            returning: self.core.returning,
        };
        Ok(BatchUpdate::new(core, self.schema().clone()).into())
    }
}

impl ToStream for LogicalUpdate {
    fn to_stream(
        &self,
        _ctx: &mut ToStreamContext,
    ) -> Result<crate::optimizer::plan_node::StreamPlanRef> {
        unreachable!("update should always be converted to batch plan");
    }

    fn logical_rewrite_for_stream(
        &self,
        _ctx: &mut RewriteStreamContext,
    ) -> Result<(PlanRef, ColIndexMapping)> {
        unreachable!("update should always be converted to batch plan");
    }
}

View on GitHub (pinned to 6469eb736d)

Solutions

  1. Execute the UPDATE through the normal SQL execution path (batch DML), not via streaming query/`CREATE MATERIALIZED VIEW`.
  2. If you need incremental maintenance of derived data, create a materialized view over the table and let UPDATE go to the base table separately.
  3. Check your SQL routing/explain tooling: use `EXPLAIN` (batch) rather than stream explain for DML statements.
  4. If stock RisingWave triggers it, file a bug with the statement and frontend version.

Example fix

// before
CREATE MATERIALIZED VIEW mv AS UPDATE t SET a = 1 WHERE id = 2; -- invalid: DML in streaming plan
// after
UPDATE t SET a = 1 WHERE id = 2; -- executed as batch DML
Defensive patterns

Strategy: validation

Validate before calling

// Route DML to batch execution; never embed UPDATE in streaming DDL.
let stmt = parse(sql);
if matches!(stmt, Statement::Update { .. }) && ctx.is_streaming {
    return Err("UPDATE must run as batch DML, not in a streaming plan");
}

Type guard

fn is_batch_only(stmt: &Statement) -> bool {
    matches!(stmt, Statement::Update { .. } | Statement::Insert { .. } | Statement::Delete { .. })
}

Try / catch

if sql.starts_with("UPDATE") {
    execute_batch(sql) // never send through stream/explain-stream paths
} else {
    execute_streaming(sql)
}

Prevention

When it happens

Trigger: An UPDATE statement routed through the streaming optimizer — e.g. an UPDATE used in a context the frontend treats as a streaming query (such as inside a `CREATE MATERIALIZED VIEW` or a streaming-explain path) instead of the batch DML executor path.

Common situations: Seen by users running DML through streaming-specific entry points or by contributors wiring new statement types into the wrong optimizer branch; normal `UPDATE ...` statements go to batch and never hit this.

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/e6db04a051359f47. Report an issue: GitHub.