ADR-0001: Connection pooling for SQL-backed event stores
- Status: Accepted (phase 1 implemented for Postgres and MySQL)
- Date: 2026-08-22
- Scope:
src/postgres.rs,src/mysql.rs,src/sqlite.rs
Context
Every SQL-backed store holds one connection behindArc<Mutex<Connection>>.
All database work in a process serializes on that mutex:
- Native tokio deployments hit a hard throughput ceiling regardless of core
count;
spawn_blockingoffload moves the wait, it does not remove it. - Under Spin/WASI each request handler contends on the same lock even when the host could service concurrent calls.
- Postgres and MySQL are network databases designed for many concurrent connections. SQLite allows only a single writer (WAL permits concurrent readers), so it benefits least.
Decision (implemented)
Replace the single mutex-guarded connection with a small internal connection pool for Postgres and MySQL:- Pool size resolves from an explicit constructor argument, then the
DDD_CQRS_ES_POOL_SIZEenvironment variable, then the CPU count clamped to[2, 8](overrides clamped to[1, 128]). - Acquisition is mutex + condvar based; leases are held across transactions and return to the pool on drop unless marked broken.
- Reads retry once on a fresh connection after transport-level failures; writes run exactly once and only discard connections on transport errors — domain outcomes such as concurrency conflicts recycle healthy connections. Legacy single-connection constructors retain their connection even when an operation fails (no reconnect factory exists to replace it).
- SQLite keeps the current single-connection mutex deliberately (single writer; WAL reader pooling can be a later, separate decision).
Connection; connect_pooled(_with_table_name) constructors are
additive.
Alternatives considered
- Status quo - predictable, but caps throughput at one in-flight query.
- Connection per operation - removes serialization but pays TCP + auth handshake per query; unacceptable for Postgres over TLS.
- Bigger spawn_blocking worker pool - already in place; concurrency still collapses onto the one connection.
Consequences
- Concurrent loads/appends across different streams no longer queue.
- Pool health logic (discard-on-error, optional pre-use
PING) becomes shared infrastructure used by both backends. - More failure modes to test: exhausted pool under load, poisoned mutex replacement, half-open connections after DB restart.
Open questions
- Idle eviction: close surplus connections after N seconds of disuse?
- Health check strategy: pre-use validation vs. blind reuse + retry?
- Should the WASI/Spin feature path expose pooling at all, or keep a single connection there until host-side pooling exists?
Verification plan
Extendcrate::testing contract tests with a concurrency test: N parallel
appends to distinct aggregates must all succeed with expected revisions, and
parallel loads must never interleave transaction fragments.