From a6d83d7777a7c35613ec54494bc69c062c76b99c Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Mon, 21 Sep 2026 13:57:32 -0700 Subject: [PATCH] Reset a statement when run() fails SQLite leaves a statement whose step failed with SQLITE_BUSY in progress, so that a caller can step it again. run() resets a statement only before it runs it and returns as soon as the run fails, so the failed statement stays in progress until garbage collection finalizes it. Until then every COMMIT on that connection fails with "cannot commit transaction - SQL statements in progress", although the lock is free. Reset the statement when run() fails, in the sync and the promise API, as get() already does when its query fails. --- integration-tests/tests/async.test.js | 21 +++++++++++++++++++++ integration-tests/tests/sync.test.js | 20 ++++++++++++++++++++ src/lib.rs | 12 ++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/async.test.js b/integration-tests/tests/async.test.js index f6536a7..aabf4d5 100644 --- a/integration-tests/tests/async.test.js +++ b/integration-tests/tests/async.test.js @@ -987,6 +987,27 @@ test.serial("Database.batch() rejects non-array argument", async (t) => { await t.throwsAsync(() => db.batch("SELECT 1"), { instanceOf: TypeError }); }); +test.serial("A statement that failed with SQLITE_BUSY does not block the next COMMIT", async (t) => { + const path = genDatabaseFilename(); + const [holder] = await connect(path); + await holder.exec("PRAGMA journal_mode=WAL"); + await holder.exec("CREATE TABLE t(x)"); + const [db] = await connect(path, { timeout: 50 }); + await holder.exec("BEGIN IMMEDIATE"); + // Keep the failed statement referenced so that garbage collection cannot finalize it. + const begin = await db.prepare("BEGIN IMMEDIATE"); + await t.throwsAsync(() => begin.run(), { code: "SQLITE_BUSY" }); + await holder.exec("ROLLBACK"); + await (await db.prepare("BEGIN IMMEDIATE")).run(); + await (await db.prepare("INSERT INTO t VALUES (1)")).run(); + await (await db.prepare("COMMIT")).run(); + const row = await (await db.prepare("SELECT count(*) AS n FROM t")).get(); + t.is(row.n, 1); + db.close(); + holder.close(); + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(path + suffix, { force: true }); +}); + const connect = async (path_opt, options = {}) => { const path = path_opt ?? "hello.db"; const provider = process.env.PROVIDER; diff --git a/integration-tests/tests/sync.test.js b/integration-tests/tests/sync.test.js index 7bcb05c..1a5540b 100644 --- a/integration-tests/tests/sync.test.js +++ b/integration-tests/tests/sync.test.js @@ -801,6 +801,26 @@ test.serial("Database.batch() rejects non-array argument", async (t) => { t.throws(() => db.batch("SELECT 1"), { instanceOf: TypeError }); }); +test.serial("A statement that failed with SQLITE_BUSY does not block the next COMMIT", async (t) => { + const path = genDatabaseFilename(); + const [holder] = await connect(path); + holder.exec("PRAGMA journal_mode=WAL"); + holder.exec("CREATE TABLE t(x)"); + const [db, errorType] = await connect(path, { timeout: 50 }); + holder.exec("BEGIN IMMEDIATE"); + // Keep the failed statement referenced so that garbage collection cannot finalize it. + const begin = db.prepare("BEGIN IMMEDIATE"); + t.throws(() => begin.run(), { instanceOf: errorType, code: "SQLITE_BUSY" }); + holder.exec("ROLLBACK"); + db.prepare("BEGIN IMMEDIATE").run(); + db.prepare("INSERT INTO t VALUES (1)").run(); + db.prepare("COMMIT").run(); + t.is(db.prepare("SELECT count(*) AS n FROM t").get().n, 1); + db.close(); + holder.close(); + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(path + suffix, { force: true }); +}); + const connect = async (path_opt, options = {}) => { const path = path_opt ?? "hello.db"; const provider = process.env.PROVIDER; diff --git a/src/lib.rs b/src/lib.rs index d4c0270..7a321f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1066,7 +1066,11 @@ impl Statement { let future = async move { let _timeout_guard = register_timeout(&stmt, query_timeout); - stmt.run(params).await.map_err(Error::from)?; + let result = stmt.run(params).await; + if result.is_err() { + stmt.reset(); + } + result.map_err(Error::from)?; let changes = if conn.total_changes() == total_changes_before { 0 } else { @@ -1386,7 +1390,11 @@ pub fn statement_run_sync( let total_changes_before = conn.total_changes(); let start = std::time::Instant::now(); - inner_stmt.run(params).await.map_err(Error::from)?; + let result = inner_stmt.run(params).await; + if result.is_err() { + inner_stmt.reset(); + } + result.map_err(Error::from)?; let changes = if conn.total_changes() == total_changes_before { 0 } else {