54 lines
2.2 KiB
Markdown
54 lines
2.2 KiB
Markdown
|
|
# 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:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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.
|