Files
sqlite/docs/adr/ADR-002-write-mutex-busy-prevention.md
Rene Nochebuena 237cba9bad feat(sqlite): initial stable release v0.9.0
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/
2026-03-19 13:25:31 +00:00

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_BUSY is eliminated for all write workloads going through UnitOfWork.
  • 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) couples NewUnitOfWork to the concrete type. When a mock or alternative Client is supplied, writeMu is nil and serialisation is skipped silently. This is intentional for testing flexibility.