From 98f2893ffef6441ee186615d0ea27c5d76346aa1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 2 Mar 2026 21:52:37 +0100 Subject: [PATCH] fix(db): use WAL mode for SQLite and temp file for ephemeral databases Three SQLite connection issues are fixed: 1. The refactoring in 26c0f71 accidentally dropped _busy_timeout from the file-based SQLite connection string. Without it, concurrent transactions get instant SQLITE_BUSY errors instead of waiting. 2. _txlock=immediate forced ALL transactions (including reads) to acquire the write lock at BEGIN, serializing all database access. WAL mode makes this unnecessary: readers use snapshots and never block writers, so the SHARED-to-RESERVED deadlock cannot occur. 3. In-memory shared cache (file::memory:?cache=shared) uses table-level locking where _busy_timeout is ineffective (returns SQLITE_LOCKED, not SQLITE_BUSY) and concurrent connections deadlock. Replace with a temp file using WAL mode for proper concurrency. --- pkg/db/db.go | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/pkg/db/db.go b/pkg/db/db.go index b35c0ac1b..d407cf1cd 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -250,14 +250,22 @@ func initSqliteEngine() (engine *xorm.Engine, err error) { } if path == "memory" { - engine, err = xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_busy_timeout=5000") + // Use a temp file with WAL mode instead of in-memory shared cache. + // Shared cache (file::memory:?cache=shared) uses table-level locking + // where _busy_timeout is ineffective (returns SQLITE_LOCKED, not + // SQLITE_BUSY) and concurrent connections deadlock. A temp file with + // WAL mode provides proper concurrency: readers never block writers, + // and _busy_timeout handles write-write contention. + tmpDir, mkErr := os.MkdirTemp("", "vikunja-*") + if mkErr != nil { + return nil, fmt.Errorf("could not create temp directory for ephemeral database: %w", mkErr) + } + dbPath := filepath.Join(tmpDir, "vikunja.db") + engine, err = xorm.NewEngine("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL") if err != nil { return } - // In-memory with shared cache requires a single connection to avoid - // "database is locked" since all connections share the same state. - engine.SetMaxOpenConns(1) - engine.SetMaxIdleConns(1) + log.Infof("Using ephemeral SQLite database at: %s", dbPath) return } @@ -284,14 +292,10 @@ func initSqliteEngine() (engine *xorm.Engine, err error) { _ = os.Remove(path) // Remove the file to not prevent the db from creating another one } - // WAL mode allows concurrent readers alongside a single writer. - // _txlock=immediate makes transactions acquire the write lock upfront - // instead of deferring it (the default). Without this, two concurrent - // deferred transactions that both read then write cause a deadlock that - // SQLite detects instantly (SQLITE_BUSY, ignoring busy_timeout). - // With immediate locking the second transaction waits (up to - // busy_timeout ms) for the first to finish, avoiding the deadlock. - engine, err = xorm.NewEngine("sqlite3", path+"?_journal_mode=WAL&_txlock=immediate") + // WAL mode allows concurrent readers alongside a single writer without + // blocking each other. busy_timeout makes concurrent writers wait (up to + // 5 s) instead of failing immediately with SQLITE_BUSY. + engine, err = xorm.NewEngine("sqlite3", path+"?_busy_timeout=5000&_journal_mode=WAL") return }