Multi-Worker SQLite Architecture
Diminuendo stores every session’s events, messages, and usage records in per-tenant SQLite databases. This is a deliberate architectural choice: per-session SQLite files yield zero-ops deployment, trivial backup (copy a file), per-session isolation, and horizontal scaling without shared state. The trade-off is that SQLite serializes all writes within a single database connection. Left unaddressed, a single-threaded writer becomes a bottleneck the moment dozens of concurrent sessions generate events, messages, and token-usage records simultaneously. The solution is a two-worker architecture that separates read and write paths into dedicated Bun Web Workers, communicating with the main thread via structured message passing.Architecture Overview
Both workers open their own database handles to the same underlying SQLite files. WAL (Write-Ahead Logging) mode allows the reader worker to execute
SELECT queries concurrently while the writer worker holds a write lock — there is never contention between a read and a write.The Writer Worker
The writer worker (sqlite-writer.worker.ts) receives fire-and-forget write commands from the main thread. It never sends responses for ordinary writes — only for explicit flush and shutdown commands that require acknowledgement.
Batching Strategy
Rather than executing each write immediately, the worker buffers incoming commands and flushes on whichever condition is met first:- Timer: 50ms since the first buffered command
- Batch size: 100 commands accumulated
sessionId and each group runs inside a single BEGIN / COMMIT transaction. This dramatically improves throughput because SQLite’s per-transaction overhead (fsync, WAL checkpoint) is amortized across many writes rather than paid per-statement.
Supported Write Commands
The writer handles five data-bearing command types, each mapped to a prepared INSERT statement:| Command | Table | Description |
|---|---|---|
insert_event | events | Persistent gateway events with sequence numbers |
insert_event_with_id | events | Same, with an explicit event_id |
insert_message | messages | User or assistant messages tied to a turn |
insert_message_meta | messages | Messages with JSON metadata (e.g., question responses) |
insert_usage | turn_usage | Token counts, model info, cost per turn |
| Command | Behavior |
|---|---|
ensure_db | Opens the DB connection lazily (no-op if already cached) |
close_db | Deferred until after the current batch’s transaction commits, then evicts the handle |
flush | Forces an immediate flush of all buffered commands and sends a flush_ack response |
Shutdown Protocol
Shutdown bypasses the buffer entirely: it flushes all pending commands, closes every cached database handle, and posts ashutdown_ack response. The WorkerManager enforces a 5-second timeout — if the worker doesn’t acknowledge in time, it is forcibly terminated.
The Reader Worker
The reader worker (sqlite-reader.worker.ts) handles SELECT queries using a request/response pattern. Every request carries a requestId (a UUID generated by the main thread), and the response echoes it back for correlation.
Read Operations
| Request Type | SQL Pattern | Use Case |
|---|---|---|
get_history | SELECT FROM messages WHERE session_id = ? AND rowid > ? LIMIT ? | Paginated message history |
get_events | SELECT FROM events WHERE session_id = ? AND seq > ? LIMIT ? | Event replay after a given sequence |
get_snapshot_messages | SELECT FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ? | Recent messages for join snapshots |
The reader opens databases in read-only mode. If the writer hasn’t yet created a database file for a session, the reader temporarily opens it in writable mode to run migrations, closes that handle, and then re-opens read-only. This avoids caching a writable handle in the reader’s LRU.
Error Handling
Every read operation is wrapped in a try/catch. On failure, the worker posts a typedReaderErrorRes with the requestId and a safe error message. The main thread’s sendReaderRequest helper rejects the corresponding Effect.async callback, surfacing the error through the Effect pipeline.
WorkerManager: The Effect Layer
TheWorkerManager is an Effect Context.Tag that provides a typed API for the main thread. It abstracts away the worker boundary entirely — consumers interact with methods like write(), readHistory(), and flush() without knowing that structured messages are being passed across threads.
write()is synchronous and void-returning. The main thread posts the command and moves on. No backpressure, no acknowledgement. This is safe because the writer’s batching strategy ensures writes are committed promptly.readHistory()/readEvents()/readSnapshotMessages()returnEffect.Effect. Under the hood, each generates a UUIDrequestId, posts the request viapostMessage, and suspends the current fiber withEffect.asyncuntil the reader responds.flush()awaits aflush_ack. It is the only writer command that blocks the caller.closeDb()closes handles in both workers. The writer receives a fire-and-forgetclose_db; the reader’sclose_dbis awaited for confirmation.
Worker Lifecycle
Both workers are spawned when theWorkerManagerLive layer is built. The first message sent to each is a string — the sessionsBaseDir path — which configures where they find SQLite files on disk.
Prepared Statement Cache
ThePreparedStatements module provides a WeakMap-based cache that maps each Database handle to a Map<string, Statement>. Statements are prepared once per (db, key) pair and reused on every subsequent call, avoiding repeated SQL compilation on hot paths.
WeakMap keying is deliberate: when a Database handle is closed and evicted from the LRU cache, the entire statement map for that database becomes eligible for garbage collection. No manual cleanup is required beyond the evictStatements(db) call that the DbLruCache issues before closing each handle.
Database Handle LRU Cache
Both workers useDbLruCache to manage open database connections. The writer caches up to 128 handles; the reader caches up to 64. The cache uses Map insertion-order semantics for O(1) get, set, and eviction:
- Get touches the entry by deleting and re-inserting it (moving it to the end of iteration order).
- Set evicts the oldest entry (first in iteration order) if the cache is at capacity.
- Evict calls
evictStatements(db)to clear the prepared statement cache, thendb.close().
Worker Protocol: Type Safety Across Threads
Theworker-protocol.ts module defines discriminated unions for every message that crosses the worker boundary:
- Writer Commands
- Writer Responses
- Reader Requests
- Reader Responses
readonly fields, and command types are string literal discriminants. This makes exhaustive switch statements in both workers fully type-checked at compile time.
Why Not PostgreSQL?
Zero-Ops Deployment
No external process to manage. The gateway binary is the entire backend. Start it, and SQLite files are created on demand.
Per-Session Isolation
Each session’s data lives in its own file. A corrupted database affects exactly one session. There is no blast radius.
Trivial Backup
Back up a session by copying its directory. Restore by placing files on disk. No
pg_dump, no logical replication.Horizontal Scaling
No shared state means no coordination. Each gateway instance manages its own SQLite files. Scale by adding instances behind a load balancer with session affinity.
Performance Characteristics
The two-worker design achieves the following:- Write throughput: Batching amortizes transaction overhead. At 100 commands per batch, a single writer can sustain thousands of writes per second per database.
- Read latency: Reader queries run on a separate thread and never block writes. WAL mode means readers see a consistent snapshot even while the writer is mid-transaction.
- Memory pressure: LRU caches (128 writer, 64 reader) bound the number of open file descriptors. WeakMap-based statement caches ensure garbage collection reclaims memory when databases are evicted.
- Graceful degradation: If a worker crashes, the
onerrorhandler on the main thread rejects all pending promises. The gateway can detect this and restart the worker or shut down cleanly.