clockworklabs/SpacetimeDB · error · anyhow::Error

commit without active mutable transaction

Error message

commit without active mutable transaction

What it means

Thrown by the dst (deterministic simulation testing) harness in EngineTarget::execute when an Interaction::CommitTx arrives while active_mut_tx is None. The harness drives RelationalDB as a strict state machine (BeginMutTx -> Insert/Delete -> CommitTx); CommitTx consumes the transaction with Option::take, so committing without a matching begin leaves nothing to commit.

Source

Thrown at crates/dst/src/engine.rs:244

            }
            Interaction::Delete { table, row } => {
                let table_id = self.table_ids[*table];
                let db = self
                    .db
                    .as_ref()
                    .ok_or_else(|| anyhow::anyhow!("database is not open"))?;
                let tx = self
                    .active_mut_tx
                    .as_mut()
                    .ok_or_else(|| anyhow::anyhow!("delete without active mutable transaction"))?;
                db.delete_by_rel(tx, table_id, [row.clone()]);
                Ok(Observation::Deleted)
            }
            Interaction::CommitTx => {
                let tx = self
                    .active_mut_tx
                    .take()
                    .ok_or_else(|| anyhow::anyhow!("commit without active mutable transaction"))?;
                let db = self
                    .db
                    .as_ref()
                    .ok_or_else(|| anyhow::anyhow!("database is not open"))?;
                let Some((_tx_offset, tx_data, _tx_metrics, _reducer)) = db.commit_tx(tx)? else {
                    anyhow::bail!("commit produced no transaction data");
                };
                Ok(Observation::Committed {
                    delta: self.commit_delta_from_tx_data(&tx_data),
                })
            }
            Interaction::Replay => {
                let _ = self.active_mut_tx.take();
                self.reopen_from_commitlog()?;
                Ok(Observation::Replayed {
                    state: self.count_state()?,
                })
            }

View on GitHub (pinned to 6dee26c6ef)

Solutions

  1. Emit Interaction::BeginMutTx before every CommitTx (one commit per begin)
  2. After an Interaction::Replay step, re-issue BeginMutTx before any Insert/Delete/Commit, since Replay drops the open transaction
  3. Add an invariant to the WorkloadGen: only generate CommitTx when a transaction is open
  4. If you wrap EngineTarget in a custom driver, mirror the tx-open flag from observations and assert begin/commit balance at sequence end

Example fix

// before: generator emits an unbalanced commit
seq.push(Interaction::CommitTx);

// after: every commit is paired with a begin
seq.push(Interaction::BeginMutTx);
seq.push(Interaction::CommitTx);
Defensive patterns

Strategy: validation

Validate before calling

// Mirror tx state from observations before issuing CommitTx
let tx_open = match last_observation {
    Some(Observation::BeganMutTx) => true,
    Some(Observation::Committed { .. }) | Some(Observation::Replayed { .. }) => false,
    other => other.is_some(), // Insert/Delete/observations preserve state
};
if !tx_open {
    target.execute(&Interaction::BeginMutTx)?;
}

Type guard

fn can_commit(last: Option<&Observation>) -> bool {
    !matches!(last, None | Some(Observation::Committed { .. }) | Some(Observation::Replayed { .. }))
}

Try / catch

match target.execute(&Interaction::CommitTx) {
    Ok(obs) => { /* continue */ }
    Err(e) if e.to_string().starts_with("commit without active mutable transaction") => {
        // generator sequence bug: fix the sequence, do not retry blindly
        return Err(e.context("unbalanced BeginMutTx/CommitTx in generated workload"));
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Executing Interaction::CommitTx without a prior successful Interaction::BeginMutTx; issuing CommitTx twice in a row (the first take() empties the slot); issuing CommitTx after Interaction::Replay, which silently discards any open transaction via `let _ = self.active_mut_tx.take()`.

Common situations: A custom workload/interaction generator that emits unbalanced begin/commit sequences; a generator that keeps producing interactions after a Replay step without re-opening a transaction; a failed earlier interaction that already consumed the transaction.

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 clockworklabs/SpacetimeDB@6dee26c6ef (2026-08-20). Data as JSON: /api/errors/707354ffde957e98. Report an issue: GitHub.