fix: do not block statement cleanup on a busy connection - #361
Conversation
statement_type_destructor took the connection mutex and called sqlite3_finalize. A connection in the busy handler holds that mutex, and SQLite's db mutex, until busy_timeout expires. Cleanup then stalled the process that held the write lock, so the waiting BEGIN IMMEDIATE failed with "database is locked". The destructor now finalizes only when the connection mutex is free. Otherwise it queues the statement. The next locked call, or connection close, finalizes it. Fixes: #360
|
Linter is failing, I'll fix that in a bit. Just want to validate that this resolves the issue. |
|
Excited about this getting in! |
|
Tested
Before the fix, 16 of 25 runs on 0.34.0–0.40.0 stalled for about 2.9 s and the waiter failed with The resource-lifetime path appears sound: the queue node is allocated during prepare; destruction either obtains the connection mutex or enqueues the statement; each statement retains the connection resource until that is complete; and connection destruction drains before Two remaining edge cases:
I have not measured the second case. This investigation (reproduction runs, source review, and this write-up) was done with Claude Opus 5.5 and GPT-5.6 Sol (via Codex). |
|
Awesome news. I'll take a look into the two remaining points and see what's possible. |
`release/2` still took the owning connection mutex. A caller holding the write lock could stall a dirty scheduler for the whole `busy_timeout`, the same failure as a destructor wait. A statement queued during that wait was also left until the next locked call. If it had been stepped, it kept a WAL read mark after the busy call returned. Release now queues instead of waiting, and the unlock path finalizes queued statements before the mutex is free. Fixes: #360
Credo allows two levels of nesting. The poll's cond contained an if, which failed mix lint.
|
@andreasronge I believe this should address all of your points above. |
|
Thanks, and sorry, just being a meat proxy here for my AIs :) This covers both points. On Two minor things from
Reviewed with Claude Opus 5.5 and GPT-6.1 Sol (via Codex). |
|
Tested 329a94f locally: 180s run, 10 connections, 20 short-lived writer processes, I didn't see any stalls. For comparison, I did attempt to get some idea of memory use with I also pushed the fix to a test device (aarch64) and can report back after it has had some time to run. |
|
The test device has now ran ~24 hours with zero "Database busy" errors. The setup has pool size of 10, Oban stager interval back at default (1 s), Thanks @warmwaffles! Looking forward to |
|
Cool, let me button up the last bit of things here and I'll get this in. |
The contended `release/2` path read `statement->statement` while holding `finalize_mutex`. Other releases write that field under `conn->mutex`. Two overlapping releases are enough for the race. `slot` is the field this mutex publishes. The queue decision now uses only that. Drain still finalizes the SQLite statement after the holder returns.
Unlock finalizes queued statements. Finalizing one left mid-result replaces the connection error with "not an error". These NIFs built that tuple after unlock, so a failed execute could lose the busy message callers match on. Copy the message first, as `step` and `prepare` already do.
|
This has been released under |
statement_type_destructortook the connection mutex and calledsqlite3_finalize. A connection in the busy handler holds that mutex, and SQLite's db mutex, untilbusy_timeoutexpires. Cleanup then stalled the process that held the write lock, so the waitingBEGIN IMMEDIATEfailed with "database is locked".The destructor now finalizes only when the connection mutex is free. Otherwise it queues the statement. The next locked call, or connection close, finalizes it.
Grok 4.7 assisted with creating this fix. Most difficult part was making a test that demonstrated the failure repeatedly.
Fixes: #360