Skip to content

Statement destructor blocks on connection mutex held during busy-handler wait #360

Closed

Description

@andreasronge

Summary

statement_type_destructor acquires the owning connection's mutex (statement_acquire_lock) before sqlite3_finalize. A connection that is waiting in the busy handler (for example on BEGIN IMMEDIATE while another connection holds the write lock) holds that same mutex for the whole busy wait. So any process that drops the last reference to a statement belonging to the waiting connection blocks inside the destructor until the waiter's busy_timeout expires.

If the process that blocks is the one holding SQLite's write lock, the waiter cannot succeed (it is waiting for that holder), so the stall always runs to the full busy_timeout and the waiter then fails with database is locked. With DBConnection/Ecto this happens naturally: Exqlite.Connection.handle_execute/4 prepares a fresh statement per execute, the statement term becomes garbage in the calling process, and long-lived callers use different pool connections over time. We saw repeated writer-lock holds of 5/10/15 s (busy_timeout: 15_000) on SELECTs over tiny tables, each ending within milliseconds of another connection's BEGIN IMMEDIATE failing.

Reproduction (exqlite 0.40.0, also present in 0.41.0)

alias Exqlite.Sqlite3
db = Path.join(System.tmp_dir!(), "destructor_#{System.unique_integer([:positive])}.db")
{:ok, setup} = Sqlite3.open(db)
:ok = Sqlite3.execute(setup, "PRAGMA journal_mode=WAL; CREATE TABLE t(id INTEGER PRIMARY KEY)")
{:ok, holder} = Sqlite3.open(db)
{:ok, w} = Sqlite3.open(db)
:ok = Sqlite3.set_busy_timeout(w, 3_000)
parent = self()

writer = spawn(fn ->
  {:ok, foreign_stmt} = Sqlite3.prepare(w, "SELECT 1")   # statement owned by w
  :ok = Sqlite3.execute(holder, "BEGIN IMMEDIATE")
  send(parent, :holding)
  receive do :go -> :ok end
  _ = foreign_stmt
  {us, _} = :timer.tc(fn ->
    :erlang.garbage_collect()                            # drops the last reference
    {:ok, s} = Sqlite3.prepare(holder, "SELECT count(*) FROM t"); Sqlite3.step(holder, s)
  end)
  send(parent, {:holder_ms, div(us, 1000)})
  :ok = Sqlite3.execute(holder, "COMMIT")
end)

receive do :holding -> :ok end
waiter = Task.async(fn ->
  {us, r} = :timer.tc(fn -> Sqlite3.execute(w, "BEGIN IMMEDIATE") end)
  {div(us, 1000), r}
end)
Process.sleep(300)
send(writer, :go)
receive do {:holder_ms, ms} -> IO.puts("holder GC + SELECT: #{ms} ms") end
IO.inspect(Task.await(waiter, 10_000), label: "waiter (ms, result)")

Output:

holder GC + SELECT: 3214 ms
waiter (ms, result): {3504, {:error, "database is locked"}}

Control: preparing foreign_stmt on holder instead of w gives holder GC + SELECT: 7 ms and the waiter acquires the lock normally.

Why it matters

  • The destructor runs in whichever process drops the last reference, usually during GC on a normal scheduler, so an unrelated process can block for up to busy_timeout.
  • Raising busy_timeout to ride out contention makes each stall longer.

Possible direction

Avoid blocking in the destructor: for example enif_mutex_trylock, and if the connection is busy, defer finalisation (queue the sqlite3_stmt* on the connection and finalise it on the connection's next locked call or on close). sqlite3_finalize without exqlite's mutex would still wait on SQLite's db->mutex, which the busy-waiting step also holds in serialized mode, so deferral seems necessary.

Our workaround is a short busy_timeout (250 ms) with the long wait retried in Elixir between attempts.

Activity

  1. warmwaffles commented on Sep 23, 2026

    @warmwaffles
    Member

    Did you hit this while running in a production environment?

    Edit: I am curious what the work load is that caused this. Just need some more context so that a robust solution can be obtained.

  2. andreasronge commented on Sep 28, 2026

    @andreasronge
    Author

    Yes, in production. The application is a small Phoenix LiveView + Oban service on one SQLite file (WAL, ecto_sqlite3 0.24.1, exqlite 0.40.0, pool size 5) running on a 4-core VM that also runs CPU-heavy build and test jobs.

    Workload

    • A long-lived GenServer runs a BEGIN IMMEDIATE transaction every 5 s that updates about a dozen rows.
    • Several other pollers and Oban workers write every few seconds, and LiveView processes read constantly.
    • Nothing unusual: every query goes through Ecto, so handle_execute/4 prepares a fresh statement each time. A long-lived process checks out different pool connections over time, so sooner or later its heap holds garbage statements prepared on a connection that is currently in the busy handler.

    Symptom

    With busy_timeout: 15_000, we saw write-lock holds of almost exactly 15 s (5 s and 10 s at earlier settings) on SELECTs over tables with fewer than 100 rows. Each hold ended within milliseconds of another connection's BEGIN IMMEDIATE failing with database is locked/Database busy. Telemetry showed the time was spent neither in SQLite nor in the pool queue. Downstream, an internal RPC with a 10 s socket timeout failed whenever it hit a stall, so real work failed rather than just ran slowly. The reproduction in the issue came from isolating that pattern; it does not reproduce without a statement from another connection.

    Workaround

    A thin Ecto adapter wrapper sets busy_timeout: 250 and retries a busy BEGIN (or a statement outside a transaction) in Elixir, between attempts, up to the real deadline, so no exqlite mutex is held while waiting. Over the last two days: no transaction held the write lock for more than 1 s, no writer waited more than 1 s, and no job failed on a lock. The cost is about ten disconnected: database is locked log lines a day, one per busy BEGIN that is retried on a fresh connection.

    We'd be glad to test a fix. Deferring sqlite3_finalize when enif_mutex_trylock fails, and draining the deferred statements on the connection's next locked call and on close, would let us remove the wrapper.

  3. warmwaffles commented on Sep 28, 2026

    @warmwaffles
    Member

    If you can reliably reproduce it locally in your project, downgrade exqlite to v0.34.0. If mix deps.get gives you grief, just use force: true. Nothing interface wise between Ecto and this should require a version bump.

    Changes were made in v0.35.0 that introduced newer mutex locking, and I want to attempt to rule it out.

  4. andreasronge commented on Sep 29, 2026

    @andreasronge
    Author

    Tried v0.34.0 and reproduced the stall. The relevant locking predates v0.35.0: statement_type_destructor already acquired the owning connection's mutex through statement_acquire_lock, and exqlite_execute already held that mutex across sqlite3_exec, including the busy wait.

    Five runs each on macOS, busy_timeout 3000. Each entry is the holder's GC + SELECT time in ms and the waiter's result (L = database is locked):

    exqlite busy timeout via runs
    0.34.0 PRAGMA busy_timeout 2852/L 505/ok 536/ok 2851/L 2845/L
    0.35.0 PRAGMA busy_timeout 2850/L 2833/L 2851/L 2823/L 2847/L
    0.39.0 set_busy_timeout/2 2971/L 2935/L 443/ok 179/ok 2933/L
    0.40.0 set_busy_timeout/2 163/ok 2953/L 458/ok 183/ok 312/ok
    0.40.0 PRAGMA busy_timeout 2864/L 2852/L 2834/L 2865/L 634/ok
    0.40.0, control (statement prepared on the holder) set_busy_timeout/2 0 0 0 26 6, all ok

    A correction to my original report: the repro has a scheduling race. The 300 ms sleep makes it likely, but does not guarantee, that the waiter is already in the busy handler when the holder garbage-collects, so the stall is timing-dependent rather than deterministic (16 of 25 non-control runs). It occurred with both SQLite's busy_timeout handler and exqlite's own handler, and was not observed in the five control runs. Versions without set_busy_timeout/2 were tested with PRAGMA busy_timeout.

    Why removing exqlite's mutex from the destructor would not be enough (line numbers at v0.40.0):

    • The bundled SQLite is built with SQLITE_THREADSAFE=1 (Makefile:96), so connections use serialized mode by default.
    • sqlite3_step and sqlite3_exec hold db->mutex for the whole call, and the busy handler runs with it held. SQLite asserts this in btreeInvokeBusyHandler (c_src/sqlite3.c:75732), and the default handler sleeps without releasing it.
    • sqlite3_finalize enters the same db->mutex (c_src/sqlite3.c:93797).

    So a destructor that called sqlite3_finalize without exqlite's mutex would still wait for the busy-waiting connection. The finalize itself has to be deferred.

    A wider effect: resource destructors are not dirty NIF calls. When the last reference is dropped by an ordinary process GC, the blocking enif_mutex_lock in statement_type_destructor (c_src/sqlite3_nif.c:1415) can hold a normal scheduler thread for up to busy_timeout. The bind_* NIFs, which also run on normal schedulers, wait on the same mutex.

    Fix sketch:

    • Give each connection a queue of statements waiting to be finalized, guarded by its own short-held mutex, with the queue node preallocated at prepare so the destructor never allocates.
    • The destructor only moves its sqlite3_stmt* onto that queue. It takes no connection mutex and makes no SQLite call.
    • Drain the queue whenever a connection operation already holds the connection mutex, and before close. The connection destructor drains before sqlite3_close_v2.

    Happy to test a patch against our workload.

  5. jounimakela commented on Sep 29, 2026

    @jounimakela

    We also hit this while running in production with versions 0.36.0 and 0.40.0. SQLite in WAL mode and pool size of 10.

    Our workload is small, ~5 inserts per second, each from a new short-lived process that exits after its insert. The only transactions are Oban's Stager, Lifeline and Pruner.

    We first noticed "Database busy" in the error logs, which I thought could be fixed with default_transaction_mode: :immediate and 5 second busy timeout. Instead, it made the problem more frequent and resulted in a cluster of 2-4 failed inserts every ~1.5 to 14 minutes.

    After debugging, I noticed that the whole application stopped for ~5 seconds, which is suspiciously close to the new busy timeout. When I lowered busy_timeout to 2 seconds, the stops became ~2 seconds, so the freeze follows the busy timeout. Digging further, I came to the same conclusion as the author.

    As a workaround, I first raised the Oban Stager interval to 60 seconds, which made the problem much rarer for us (a cluster every ~4.4 hours), and finally set the database pool size to 1, which shouldn't crash, and hasn't.

    The problem reproduces on 0.34.0 with a standalone script, without Ecto or Oban.

  6. warmwaffles commented on Sep 29, 2026

    @warmwaffles
    Member

    This is good information. I'll work on a solution soon. I appreciate the detail.

  7. warmwaffles commented on Sep 29, 2026

    @warmwaffles
    Member

    @andreasronge and @jounimakela I stood up a fix for this in #361

    I've improved the busy handler to not block as bad as it did. Unfortunately, this fix is on a branch so you'll need to point your dep at the branch. This will necessitate that you have gcc or clang installed in order to compile the dependency as I don't have a precompiled binary up. That will be created if this is successful and I cut a new release for v0.42.0

  8. warmwaffles commented on Sep 29, 2026

    @warmwaffles
    Member

    Something to note about that fix, if you can load test against it and keep tabs on memory usage that would be good. It adds a linked list to cleanup prepared statements at a later time (ms later). The fix will increase memory usage because it introduces a linked list that we use to defer finalizing sqlite3 statements. It should eventually be reclaimed by the running process once the finalization has been performed.

  9. added a commit that references this issue on Sep 30, 2026
    9d38306
  10. added a commit that references this issue on Oct 24, 2026
    933f324
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions