risingwavelabs/risingwave · error

Exchange executor should not have children!

Error message

Exchange executor should not have children!

What it means

Same monotonicity rule as the buffered-path truncation, but for the historical-data path: when the requested truncation offset is a `TruncateOffset::Barrier { epoch }` covering pre-current-epoch data, the reader rejects truncating at or before the previously recorded truncate offset. Historical truncation is applied at barrier/epoch granularity via `rx.truncate_historical(epoch)`.

Source

Thrown at src/batch/executors/src/executor/generic_exchange.rs:154

                .inspect_err(|e| {
                    if matches!(e, BatchError::RpcError(_)) {
                        mask_failed_serving_worker()
                    }
                })?,
            ))
        }
    }
}

pub struct GenericExchangeExecutorBuilder {}

impl BoxedExecutorBuilder for GenericExchangeExecutorBuilder {
    async fn new_boxed_executor(
        source: &ExecutorBuilder<'_>,
        inputs: Vec<BoxedExecutor>,
    ) -> Result<BoxedExecutor> {
        ensure!(
            inputs.is_empty(),
            "Exchange executor should not have children!"
        );
        let node = try_match_expand!(
            source.plan_node().get_node_body().unwrap(),
            NodeBody::Exchange
        )?;

        let sequential = node.get_sequential();

        ensure!(!node.get_sources().is_empty());
        let proto_sources: Vec<PbExchangeSource> = node.get_sources().clone();
        let source_creators =
            vec![DefaultCreateSource::new(source.context().client_pool()); proto_sources.len()];

        let input_schema: Vec<NodeField> = node.get_input_schema().clone();
        let fields = input_schema.iter().map(Field::from).collect::<Vec<Field>>();
        Ok(Box::new(ExchangeExecutor {
            proto_sources,

View on GitHub (pinned to 6469eb736d)

Solutions

  1. Ensure the caller tracks the last truncated barrier epoch and skips repeats or regressions.
  2. Persist/restore truncate progress correctly across restarts so the first post-recovery truncate is newer.
  3. Audit the epoch source (barrier manager / Hummock watermark) for out-of-order barrier delivery.
  4. If the regression is expected (e.g. testing), reset or recreate the reader instead of re-truncating.

Example fix

// before
reader.truncate(TruncateOffset::Barrier { epoch });
// after
if !matches!(reader.last_truncate_offset(), Some(prev) if TruncateOffset::Barrier { epoch } <= prev) {
    reader.truncate(TruncateOffset::Barrier { epoch });
}
Defensive patterns

Strategy: validation

Validate before calling

// rust
fn safe_truncate_barrier(reader: &mut KvLogStoreReader, epoch: u64) -> anyhow::Result<()> {
    let offset = TruncateOffset::Barrier { epoch };
    if let Some(prev) = reader.last_truncate_offset() {
        anyhow::ensure!(offset > prev, "skip historical truncate {:?} (prev {:?})", offset, prev);
    }
    reader.truncate(offset)
}

Prevention

When it happens

Trigger: Calling `KvLogStoreReader::truncate(TruncateOffset::Barrier { epoch })` while `offset <= self.truncate_offset` and `offset.epoch() < first_write_epoch` (historical region). Thrown at reader.rs:543.

Common situations: Restarted stream actor replays an old barrier epoch; epoch watermark regression after meta failover; mismatch between the epoch the executor believes it has consumed and what the reader last truncated.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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