Cooperative busy-retry: don't block the scheduler thread on a locked db
In Sigil's single-threaded cooperative scheduler, SQLite's default busytimeout is a deadlock hazard: a contended sqlite3step blocks the one thread for the whole timeout, freezing every fiber -- including the one that holds the lock, which can then never run to commit. A fiber holding a WAL write transaction across a yield while another fiber's db op blocks on the lock deadlocks until busy_timeout expires (kiln sets 5000ms); repeated contention reads as a hang.
Reproduced in isolation: fiber A takes a WAL write lock and sleeps 0.3s while holding it; fiber B's INSERT blocks inside sqlite3step. A's commit is frozen from its intended ~300ms to ~5112ms -- B's blocking step held the thread for the full busytimeout, so A's sleep timer could never fire.
Fix: the native sqlite-exec / sqlite-step force busytimeout=0 (so a lock conflict returns SQLITEBUSY immediately, never blocking the thread) and surface it as the symbol 'busy. Public Scheme sqlite-exec / sqlite-step wrap the native ops with cooperative retry: on 'busy they sleep briefly -- which is async-aware and SUSPENDS the fiber, letting the lock holder run and commit -- then retry, bounded by a timeout budget so a genuine deadlock ERRORS loudly instead of hanging. Uncontended calls never see 'busy, so their behavior is exactly preserved.
With the fix the repro completes in ~240ms (A commits on time, B retries right after) instead of freezing ~5s. Adds test-sqlite-wal-cooperative.sgl (fails on the pre-fix runtime) and preserves the existing suite.
native/sqlite.c | 33 +++++++++++++++++++++++++++------
package.sgl | 2 +-
src/sigil/sqlite.sgl | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
test/test-sqlite-wal-cooperative.sgl | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 162 insertions(+), 22 deletions(-)native/sqlite.cmodified
char *sql = extract_string(args[1]); if (!sql) return SIGIL_FALSE; /* Force non-blocking: never let sqlite3_exec block the single cooperative * scheduler thread waiting on a lock. A contended lock returns SQLITE_BUSY * immediately, which we surface as the symbol 'busy so the Scheme wrapper * can yield to the scheduler + retry cooperatively (letting the lock holder * run and commit). This overrides any PRAGMA busy_timeout the caller set — * that intent is honored by the wrapper's retry budget instead. */ sqlite3_busy_timeout(handle->db, 0); char *errmsg = NULL; int rc = sqlite3_exec(handle->db, sql, NULL, NULL, &errmsg); free(sql); if (errmsg) sqlite3_free(errmsg); if (rc == SQLITE_BUSY || rc == SQLITE_LOCKED) { return sigil_intern_symbol(vm, "busy", 4); } if (rc != SQLITE_OK) { if (errmsg) sqlite3_free(errmsg); return SIGIL_FALSE; } return SIGIL_TRUE;} return SIGIL_UNDEFINED; } /* Force non-blocking (see native_sqlite_exec): a contended lock surfaces as * 'busy for the Scheme wrapper to yield + retry, rather than blocking the * scheduler thread inside sqlite3_step. */ sqlite3_busy_timeout(sqlite3_db_handle(handle->stmt), 0); int rc = sqlite3_step(handle->stmt); if (rc == SQLITE_ROW) { return sigil_intern_symbol(vm, "row", 3); } else if (rc == SQLITE_DONE) { return sigil_intern_symbol(vm, "done", 4); } else if (rc == SQLITE_BUSY || rc == SQLITE_LOCKED) { return sigil_intern_symbol(vm, "busy", 4); } else { return SIGIL_FALSE; } SIGIL_ARITY_EXACT(1), "Open database file"); REGISTER_AND_EXPORT("sqlite-close", native_sqlite_close, SIGIL_ARITY_EXACT(1), "Close database connection"); REGISTER_AND_EXPORT("sqlite-exec", native_sqlite_exec, SIGIL_ARITY_EXACT(2), "Execute SQL without results"); /* Internal: returns 'busy on a contended lock (never blocks). The public * sqlite-exec (Scheme, in sqlite.sgl) wraps this with cooperative retry. */ REGISTER_AND_EXPORT("%sqlite-exec-native", native_sqlite_exec, SIGIL_ARITY_EXACT(2), "Execute SQL without results (non-blocking)"); /* Prepared statements */ REGISTER_AND_EXPORT("sqlite-prepare", native_sqlite_prepare, SIGIL_ARITY_EXACT(2), "Prepare SQL statement"); REGISTER_AND_EXPORT("sqlite-bind", native_sqlite_bind, SIGIL_ARITY_EXACT(3), "Bind parameter value"); REGISTER_AND_EXPORT("sqlite-step", native_sqlite_step, SIGIL_ARITY_EXACT(1), "Execute statement step"); /* Internal: returns 'busy on a contended lock (never blocks). The public * sqlite-step (Scheme, in sqlite.sgl) wraps this with cooperative retry. */ REGISTER_AND_EXPORT("%sqlite-step-native", native_sqlite_step, SIGIL_ARITY_EXACT(1), "Execute statement step (non-blocking)"); REGISTER_AND_EXPORT("sqlite-reset", native_sqlite_reset, SIGIL_ARITY_EXACT(1), "Reset statement to initial state"); REGISTER_AND_EXPORT("sqlite-finalize", native_sqlite_finalize,package.sglmodified
(package name: "sigil-sqlite" version: "0.16.1" version: "0.16.2" sigil: "^0.17" description: "SQLite database bindings for Sigil" url: "https://codeberg.org/sigil/sigil-sqlite"src/sigil/sqlite.sglmodified
;;; (sqlite-run db sql . params) - Execute statement with params(define-library (sigil sqlite) (import (sigil core)) (import (sigil core) (sigil time)) ;; current-second + async-aware sleep for cooperative busy-retry (export ;; Type predicates (define-native (sqlite-close db) (: sqlite-db? -> void?)) ;;; Execute a SQL string directly. ;;; ;;; Suitable for DDL statements and simple queries that don't need ;;; parameter binding. Returns `#t` on success, `#f` on error. ;;; ;;; ```scheme ;;; (sqlite-exec db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") ;;; (sqlite-exec db "PRAGMA journal_mode=WAL") ;;; ``` (define-native (sqlite-exec db sql) (: sqlite-db? string? -> boolean?)) ;; Internal non-blocking exec (native): returns #t on success, #f on error, ;; or 'busy on a contended lock. It forces busy_timeout=0 so it never blocks ;; the scheduler thread. The PUBLIC sqlite-exec (below) wraps it with ;; cooperative retry. (define-native (%sqlite-exec-native db sql) (: sqlite-db? string? -> any?)) ;;; Prepare a SQL statement for execution. ;;; (define-native (sqlite-bind stmt idx val) (: sqlite-stmt? integer? any? -> boolean?)) ;; Internal non-blocking step (native): 'row, 'done, #f on error, or 'busy ;; on a contended lock (forces busy_timeout=0, never blocks the scheduler ;; thread). The PUBLIC sqlite-step (below) wraps it with cooperative retry. (define-native (%sqlite-step-native stmt) (: sqlite-stmt? -> any?)) ;; ========== Cooperative busy-retry ========== ;; ;; SQLite's default busy_timeout makes a contended sqlite3_step BLOCK the ;; calling thread. In Sigil's single-threaded cooperative scheduler that ;; freezes EVERY fiber — including the one holding the lock, which can then ;; never run to commit — a deadlock (t-2f… WAL park). So the native ops force ;; busy_timeout=0 and surface a lock conflict as 'busy, and here we retry ;; cooperatively: `sleep` is async-aware (it SUSPENDS the fiber, letting the ;; lock holder run and commit), so a short sleep + retry resolves the ;; contention without blocking the thread. A budget bounds the wait so a ;; genuine deadlock ERRORS loudly instead of hanging. Uncontended calls never ;; return 'busy, so their behavior is exactly preserved (no sleep, no retry). (define *sqlite-busy-budget-seconds* 5.0) (define *sqlite-busy-retry-interval* 0.002) (define (retry-on-busy op-name thunk) (let ((deadline (+ (current-second) *sqlite-busy-budget-seconds*))) (let loop () (let ((result (thunk))) (if (eq? result 'busy) (if (> (current-second) deadline) (error (string-append op-name ": database is locked (busy-retry budget exhausted)")) (begin (sleep *sqlite-busy-retry-interval*) (loop))) result))))) ;;; Execute a SQL string directly. ;;; ;;; Suitable for DDL statements and simple queries that don't need ;;; parameter binding. Returns `#t` on success, `#f` on error. If the ;;; database is locked by a concurrent writer, retries cooperatively ;;; (yielding to the scheduler) up to a timeout budget, then errors. ;;; ;;; ```scheme ;;; (sqlite-exec db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") ;;; (sqlite-exec db "PRAGMA journal_mode=WAL") ;;; ``` (define (sqlite-exec db sql) (: sqlite-db? string? -> boolean?) (retry-on-busy "sqlite-exec" (lambda () (%sqlite-exec-native db sql)))) ;;; Step through a prepared statement. ;;; ;;; Returns `'row` if a result row is available (use `sqlite-column` ;;; to read values), `'done` when finished, or `#f` on error. ;;; to read values), `'done` when finished, or `#f` on error. On lock ;;; contention, retries cooperatively (yielding to the scheduler) up to a ;;; timeout budget, then errors. ;;; ;;; ```scheme ;;; (sqlite-step stmt) ; => 'row, 'done, or #f ;;; ``` (define-native (sqlite-step stmt) (: sqlite-stmt? -> any?)) (define (sqlite-step stmt) (: sqlite-stmt? -> any?) (retry-on-busy "sqlite-step" (lambda () (%sqlite-step-native stmt)))) ;;; Reset a prepared statement to its initial state. ;;;test/test-sqlite-wal-cooperative.sgladded
;;; Regression test for cooperative WAL busy-retry.;;;;;; Before the fix, a contended sqlite3_step BLOCKED the single cooperative;;; scheduler thread for the whole busy_timeout (kiln sets 5000ms). Fiber A;;; could hold a WAL write lock, yield, and then be UNABLE to run to commit;;; because fiber B's blocking step froze the thread — a deadlock. This test;;; sets up exactly that shape and asserts both fibers finish quickly (well;;; under the busy_timeout), proving the step no longer blocks the scheduler.;;;;;; On the pre-fix runtime A commits only after ~5000ms (the busy_timeout) and;;; this test FAILS; on the fix A commits at ~its intended yield (~200ms) and B;;; retries cooperatively right after.(import (sigil core) (sigil async) (sigil sqlite) (sigil time) (sigil string))(display "Testing SQLite WAL cooperative busy-retry (deadlock regression)...\n")(define dbfile "/tmp/sqlite-wal-coop-test.db")(define A (sqlite-open dbfile))(sqlite-exec A "PRAGMA journal_mode=WAL")(sqlite-exec A "PRAGMA busy_timeout=5000")(sqlite-exec A "DROP TABLE IF EXISTS t")(sqlite-exec A "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")(define B (sqlite-open dbfile))(sqlite-exec B "PRAGMA busy_timeout=5000")(define t0 (current-milliseconds))(define (ms) (- (current-milliseconds) t0))(define a-commit-ms 0)(define b-ms 0)(with-async ;; A: take the WAL write lock, YIELD 0.2s while holding it, then commit. (go (begin (sqlite-exec A "BEGIN IMMEDIATE") (sleep 0.2) (sqlite-exec A "INSERT INTO t (v) VALUES ('a')") (sqlite-exec A "COMMIT") (set! a-commit-ms (ms)))) ;; B: write into the contended db — must cooperatively retry, NOT freeze the ;; scheduler thread (which would prevent A from ever committing). (go (begin (sleep 0.05) (sqlite-exec B "INSERT INTO t (v) VALUES ('b')") (set! b-ms (ms)))))(sqlite-close A)(sqlite-close B);; On the fix both finish ~0.2s; on the bug A only commits after ~5s (busy_timeout).(define wal-cooperative-pass? (and (> a-commit-ms 0) (> b-ms 0) (< a-commit-ms 2000) (< b-ms 2000)))(if wal-cooperative-pass? (display (string-append "PASS: A committed @" (number->string a-commit-ms) "ms, B @" (number->string b-ms) "ms (cooperative retry — scheduler not frozen)\n")) (display (string-append "FAIL: A @" (number->string a-commit-ms) "ms, B @" (number->string b-ms) "ms (>=2000ms => blocking sqlite3_step froze the scheduler)\n")));; Final top-level value drives the harness exit code: 0 on pass, error on fail.(if wal-cooperative-pass? 0 (error "WAL cooperative busy-retry regression"))