Pure-Go CGO-free SQLite client with launcher lifecycle, write-mutex serialisation, health check, unit-of-work via context injection, and structured error mapping. What's included: - Executor / Tx / Client / Component interfaces using database/sql native types - Tx.Commit() / Tx.Rollback() without ctx, matching the honest database/sql contract - New(logger, cfg) constructor; database opened in OnInit - Config struct with env-tag support; default Pragmas: WAL + 5s busy timeout + FK enforcement - PRAGMA foreign_keys = ON enforced explicitly in OnInit - writeMu sync.Mutex acquired by UnitOfWork.Do to serialise writes and prevent SQLITE_BUSY - UnitOfWork via context injection; GetExecutor(ctx) returns active Tx or *sql.DB - HandleError mapping SQLite extended error codes to xerrors codes (unique/primary-key → AlreadyExists, foreign-key → InvalidInput, ErrNoRows → NotFound) - health.Checkable at LevelCritical; pure-Go modernc.org/sqlite driver (CGO_ENABLED=0 compatible) Tested-via: todo-api POC integration Reviewed-against: docs/adr/
2.2 KiB
ADR-002: Write Mutex to Prevent SQLITE_BUSY Under Concurrent Load
Status: Accepted Date: 2026-03-18
Context
SQLite uses file-level locking. When multiple goroutines attempt write transactions
concurrently, SQLite cannot acquire the write lock immediately and returns SQLITE_BUSY.
Although the default Pragmas configure a 5-second busy timeout (_timeout=5000), this is a
passive wait that still allows competing transactions to collide and fail under sustained
concurrent write pressure.
WAL mode (_journal=WAL) improves read concurrency but does not eliminate write contention:
SQLite still allows only one writer at a time.
Decision
The sqliteComponent holds a writeMu sync.Mutex field. NewUnitOfWork detects when its
Client argument is the concrete *sqliteComponent type and extracts a pointer to that mutex.
unitOfWork.Do acquires the mutex before beginning a transaction and releases it after
commit or rollback:
func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error {
if u.writeMu != nil {
u.writeMu.Lock()
defer u.writeMu.Unlock()
}
tx, err := u.client.Begin(ctx)
...
}
This serialises all write transactions at the application level, guaranteeing that only one
writer reaches SQLite at a time and eliminating SQLITE_BUSY errors entirely.
The mutex is only applied when using NewUnitOfWork. Callers who manage transactions manually
via Begin/Commit/Rollback are not protected and must handle contention themselves.
Consequences
Positive:
SQLITE_BUSYis eliminated for all write workloads going throughUnitOfWork.- Behaviour is deterministic and testable (see
TestUnitOfWork_WriteMutex). - Reads are unaffected; the mutex only wraps writes.
Negative:
- Write throughput is bounded to one goroutine at a time. This is acceptable for SQLite's typical deployment profile (embedded, single-process, modest write rates).
- The type assertion
client.(*sqliteComponent)couplesNewUnitOfWorkto the concrete type. When a mock or alternativeClientis supplied,writeMuisniland serialisation is skipped silently. This is intentional for testing flexibility.