clockworklabs/SpacetimeDB · error

expected reducer transaction update {request_id}, got reques

Error message

expected reducer transaction update {request_id}, got request id {:?}

What it means

Test-harness helper recv_reducer_update(request_id) in crates/testing/src/modules.rs:145 pops exactly one message from the client's outbound stream and requires it to be a full TxUpdate whose event.request_id equals the given RequestId. The outbound stream is FIFO across all traffic (other reducers, subscription updates, energy updates), so if the next message belongs to a different transaction, the helper bails with this mismatch error. It is a strict single-message assertion, not a search.

Source

Thrown at crates/testing/src/modules.rs:145

    pub async fn recv_message(&mut self) -> Option<OutboundMessage> {
        let mut buf = Vec::with_capacity(1);
        (self.receiver.recv_many(&mut buf, 1).await != 0).then(|| buf.remove(0))
    }

    pub async fn recv_reducer_update(&mut self, request_id: RequestId) -> anyhow::Result<()> {
        let message = self
            .recv_message()
            .await
            .ok_or_else(|| anyhow::anyhow!("client receiver closed before reducer update {request_id}"))?;
        let OutboundMessage::V1(SerializableMessage::TxUpdate(update)) = message else {
            anyhow::bail!("expected reducer transaction update {request_id}, got {message:?}");
        };
        let Some(event) = update.event else {
            anyhow::bail!("expected full reducer transaction update {request_id}, got light update");
        };
        if event.request_id != Some(request_id) {
            anyhow::bail!(
                "expected reducer transaction update {request_id}, got request id {:?}",
                event.request_id
            );
        }
        match &event.status {
            EventStatus::Committed(_) => Ok(()),
            EventStatus::FailedUser(err) | EventStatus::FailedInternal(err) => {
                anyhow::bail!("reducer transaction update {request_id} failed: {err}")
            }
            EventStatus::OutOfEnergy => anyhow::bail!("reducer transaction update {request_id} ran out of energy"),
        }
    }

    pub async fn read_log(&self, size: Option<u32>) -> String {
        let bytes = self
            .client
            .module()
            .database_logger()

View on GitHub (pinned to fb7282411b)

Solutions

  1. Call recv_reducer_update(request_id) immediately after the exact call_reducer* call that returned that request_id, before triggering any other transaction on the connection.
  2. Consume preceding unrelated updates first with recv_message() in a loop until you reach the TxUpdate whose event.request_id matches.
  3. Verify the RequestId comes from the same exec/call - the call_reducer_binary_result helper at crates/testing/src/modules.rs:104 returns it.
  4. If tests genuinely interleave transactions, write a small dispatcher that routes each TxUpdate by request_id instead of assuming order.

Example fix

// before: unrelated update arrives first, mismatch is fatal
let rid = module.call_reducer_binary_result("add", &args).await?;
module.recv_reducer_update(rid).await?; // bails if another TxUpdate is queued ahead
// after: drain until the matching update
while let Some(msg) = module.recv_message().await {
    if let OutboundMessage::V1(SerializableMessage::TxUpdate(u)) = &msg {
        if u.event.as_ref().and_then(|e| e.request_id) == Some(rid) {
            break; // found ours; now assert on its status
        }
    }
}
Defensive patterns

Strategy: try-catch

Try / catch

// Rust test: route updates by request_id instead of assuming FIFO position.
loop {
    let Some(msg) = module.recv_message().await else { panic!("client closed") };
    let OutboundMessage::V1(SerializableMessage::TxUpdate(u)) = msg else { continue };
    let Some(ev) = u.event else { continue };
    if ev.request_id == Some(rid) {
        assert!(matches!(ev.status, EventStatus::Committed(_)), "status: {:?}", ev.status);
        break;
    }
}

Prevention

When it happens

Trigger: Calling recv_reducer_update(rid_a) when an earlier transaction (another reducer call, a subscription initial load, a second client's write) produced an update that has not been consumed yet; passing a RequestId captured from a different call_reducer invocation; firing two reducers back-to-back and awaiting them in reverse order.

Common situations: Test modules that start subscriptions and then call reducers without draining the subscription's initial data first; helper functions that call reducers internally without awaiting their updates; copy-pasting request ids between tests.

Related errors


AI-assisted analysis of clockworklabs/SpacetimeDB@fb7282411b (2026-08-20). Data as JSON: /api/errors/66895befadb0477c. Report an issue: GitHub.