tursodatabase/turso · error
BEGIN must start a transaction
Error message
BEGIN must start a transaction
What it means
After the benchmark issues `BEGIN` on a session, it asserts that the connection actually left autocommit mode (`!conn.is_autocommit()`). This check fails when SQLite did not enter an explicit transaction despite the BEGIN statement executing without an error — meaning the transaction control invariant of the workload harness was violated. The library throws it to fail fast rather than silently benchmarking in autocommit mode, which would produce misleading throughput numbers.
Solutions
- Check `conn.is_autocommit()` before issuing BEGIN for each session and roll back (`conn.execute_batch("ROLLBACK")` or `conn.execute_batch("COMMIT")`) any stale open transaction first.
- Ensure every error path between BEGIN and COMMIT issues a ROLLBACK so sessions return to autocommit before the next batch.
- Verify no outer code (wrapper, benchmark harness, tracing hook) has already started a transaction on these connections.
- If a panic occurred in a spawn_blocking worker, recreate the affected connections instead of reusing them.
Example fix
// before
conn.execute_batch("BEGIN")?;
ensure!(!conn.is_autocommit(), "BEGIN must start a transaction");
// after
if !conn.is_autocommit() {
conn.execute_batch("ROLLBACK")?; // clear stale transaction from a failed prior batch
}
conn.execute_batch("BEGIN")?;
ensure!(!conn.is_autocommit(), "BEGIN must start a transaction"); Defensive patterns
Strategy: validation
Validate before calling
// before starting a transactional batch on each connection
if !conn.is_autocommit() {
// stale transaction from a previous failed batch — clear it
conn.execute_batch("ROLLBACK")?;
}
assert!(conn.is_autocommit(), "connection not ready for BEGIN"); Type guard
fn ready_for_begin(conn: &rusqlite::Connection) -> bool {
conn.is_autocommit()
} Try / catch
match workload.run().await {
Ok(result) => result,
Err(e) if e.to_string().contains("BEGIN must start a transaction") => {
// roll back stale transactions on all sessions, then retry once
for conn in workload.sessions() {
let _ = conn.execute_batch("ROLLBACK");
}
workload.run().await?
}
Err(e) => return Err(e),
} Prevention
- Always pair BEGIN/COMMIT in a scope guard so ROLLBACK runs on every error path.
- Check is_autocommit() before each BEGIN in long-running loops that reuse connections.
- Never reuse a connection after a panic in a worker thread without resetting its transaction state.
- Avoid registering hooks or extensions that issue implicit BEGIN during reads.
When it happens
Trigger: Calling `SqliteWorkload::batch` with `config.execution == Execution::Transactions { .. }` when a session's `BEGIN` via `conn.execute_batch("BEGIN")` does not flip the connection out of autocommit — e.g. the connection is already inside an explicit transaction from a prior leaked/uncommitted batch, or a driver/trace hook (such as a statement-level auto-BEGIN or arusqlite busy/interrupt path) consumed or aborted the BEGIN.
Common situations: Reusing connections across batches where an earlier COMMIT failed or was never reached because `query_batch` returned an error mid-transaction (leaving the session with an open transaction); configuring multi-session runs where one worker panics inside `spawn_blocking` and the sessions are re-used; wrapping the whole benchmark in outer transaction-control code that already issued BEGIN.
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
- COMMIT must end the transaction
- Cannot commit in autocommit mode.
- Cannot rollback in autocommit mode.
- COMMIT must end the transaction
- SqliteCommand.ToSqliteException(ex)
AI-assisted analysis of tursodatabase/turso@8d4a589f8d (2026-09-20).
Data as JSON: /api/errors/1921ff920630b482.
Report an issue: GitHub.
Appendix: source
Thrown at perf/fts/src/sqlite.rs:79
};
let mut result = RunResult::default();
for _ in 0..self.config.execution.batches() {
let batch = self.batch(queries).await?;
result.queries += batch.queries;
result.transactions += batch.transactions;
result.rows += batch.rows;
result.id_sum += batch.id_sum;
result.max_active_transactions = batch.max_active_transactions;
}
Ok(result)
}
async fn batch(&mut self, queries: usize) -> Result<RunResult> {
let transactions = matches!(self.config.execution, Execution::Transactions { .. });
if transactions {
for conn in &self.sessions {
conn.execute_batch("BEGIN")?;
ensure!(!conn.is_autocommit(), "BEGIN must start a transaction");
}
}
let mut result = RunResult::default();
if self.sessions.len() == 1 {
result = query_batch(&self.sessions[0], self.config.query, queries)?;
} else {
let mut workers = tokio::task::JoinSet::new();
for conn in self.sessions.drain(..) {
let case = self.config.query;
workers.spawn_blocking(move || {
let result = query_batch(&conn, case, queries);
(conn, result)
});
}
let mut outcomes = Vec::new();
while let Some(outcome) = workers.join_next().await {
outcomes.push(outcome);
}View on GitHub (pinned to 8d4a589f8d)