Skip to content

Reset a statement when run() fails - #237

Closed
ejc3 wants to merge 1 commit into
tursodatabase:mainfrom
ejc3:reset-statement-after-failed-run
Closed

ejc3 wants to merge 1 commit into
tursodatabase:mainfrom
ejc3:reset-statement-after-failed-run

Conversation

@ejc3

@ejc3 ejc3 commented Sep 21, 2026

Copy link
Copy Markdown

When a write statement fails with SQLITE_BUSY, SQLite leaves it 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. The failed statement then stays in progress until the garbage collector finalizes it, and until then every COMMIT on that connection fails with SQLITE_BUSY: cannot commit transaction - SQL statements in progress, although the lock is free. Through @libsql/client, one write batch that fails busy makes the following batches on the same client fail too.

This resets the statement when run() fails, in both the sync and the promise API, the same way get() already does when its query fails.

The report, with the SQLite source lines and reproductions against this binding alone, is in tursodatabase/libsql-client-ts#352.

Tests

One new case in sync.test.js and one in async.test.js. A second connection holds the write lock, BEGIN IMMEDIATE fails with SQLITE_BUSY after a 50 ms busy timeout, the lock is released, and a write transaction on the same connection must then commit. The failed statement stays referenced, so garbage collection cannot hide the bug.

  • Before the fix, both new cases fail at the COMMIT with SQLITE_BUSY on the libsql provider.
  • The sync case passes on better-sqlite3, which resets a statement after every run.
  • After the fix, npm run test in integration-tests passes: sqlite 53, libsql 53, async 68, extensions 29, concurrency 4 and connections 2.

all() and iterate() already recover after a statement fails busy, checked the same way, so they are unchanged.

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.
@penberg

penberg commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

@ejc3 We probably should copy better-sqlite3 semantics here fully: #238

@ejc3

ejc3 commented Sep 23, 2026

Copy link
Copy Markdown
Author

Closing in favour of #238, which builds on this commit and resets the statement whether the step succeeded or failed, as better-sqlite3 does. That also covers INSERT ... RETURNING and SELECT through run(), which this change left open. Thanks for taking it further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants