ADR-0011: Source: Send (not Sync)
Status: Accepted
Date: 2026-05
Context: The architectural audit (2026-05) noted that Source is Send but not Sync, which forces every parallel chunk worker to open its own DB connection. With --parallel 16 on a long export this means 16 backend processes on the source database. The audit asked whether making Source: Send + Sync (via interior mutability, the pattern StateStore already uses) would be a net improvement.
Decision
Keep Source: Send only. Do not introduce Sync in v0.5.x.
#![allow(unused)]
fn main() {
// src/source/mod.rs
pub trait Source: Send {
fn export(&mut self, request: &ExportRequest<'_>, sink: &mut dyn BatchSink) -> Result<()>;
fn query_scalar(&mut self, sql: &str) -> Result<Option<String>>;
fn type_mappings(...) -> Result<Vec<TypeMapping>>;
}
}
Every method takes &mut self. A worker that needs a Source must own it; sharing &Source between threads is not supported.
Rationale
Why Sync would be tempting
StateStore already uses interior mutability (RefCell wrapping postgres::Client) so its methods take &self. Applying the same pattern to Source would let the chunked engine in pipeline/chunked/exec.rs share one connection across N worker threads. On paper that means:
- 1× DB backend instead of N — large win against a
max_connections=200Postgres. - No
lean_pool_opts()workaround in MySQL (themin=10bug we fixed in f6ba79f). - Faster startup — no per-worker
Client::connect.
Why it is the wrong trade-off today
-
Serialized I/O kills the point of parallelism. A single
postgres::Client(ormysql::PooledConn) is fundamentally not concurrent: eachclient.query()exchanges Postgres wire-protocol packets in lock-step. Wrapping it inMutex<Client>orRefCell<Client>means workers contend on the mutex and the parallel design degrades into batched-sequential. We measured a 1.7× slowdown on a 4-thread chunked export ofcontent_items(200K rows) when prototyped against aMutex<Client>. -
pipeline/chunked/exec.rsalready amortizes connection cost. Workers open their connection once and reuse it for the entire chunk’s lifetime. Per-chunk cost is dominated by the SQL execution, not the connect handshake. -
Backend explosion is a non-problem in practice. A user running
--parallel 16is explicitly opting into 16 concurrent backends. The exporter has guardrails:lean_pool_opts()keeps the MySQL pool atmin=1, ADR-0010 keeps each child process to oneSource, and pooler detection (detect_pg_transaction_pooler) warns when a pgBouncer is multiplexing. Operators who want fewer backends can run with lower--parallel. -
The
Syncrefactor blocks on multiple deps the audit flagged separately.- The
postgres::ClientAPI is&mut-only; making itSyncrequires eitherMutex(slow, see #1) or an async client (a much larger surface change). mysql::PooledConnhas the same problem.- The Source trait’s three methods all mutate session state (timeouts, cursors, prepared statements). Interior mutability without serialization would be unsafe by design, not just slow.
- The
-
The
Sendbound is exactly what we need. Workers move theirSourceintothread::scope. The trait already supports the parallelism model we actually run; the missing capability (one shared connection) is not a capability we want.
Consequences
Cost
- N-worker chunked exports open N connections. Documented in the
--parallelflag help and in pgBouncer guidance. - No “single-conn parallel” mode. Users who want one backend run sequentially (
--parallel 1).
Benefit
- Source trait stays simple —
&mut selfeverywhere, noMutex, noRefCell, noArc<dyn Source>to reason about. - Each worker has independent failure semantics — a panic in one connection cannot poison another worker’s mid-statement state.
- Easy to add new backends:
impl Source for NewDBrequires only&mut selfmethods, the natural shape for any blocking DB driver.
Considered Alternatives
A. Sync via Mutex<Client> inside the impl
Prototyped. Result: workers serialise on the mutex, making --parallel N no better than --parallel 1. Rejected.
B. Sync via async (tokio + tokio-postgres)
Requires rewriting Source as async. Knock-on changes:
BatchSink::on_batchbecomes async, propagating throughpipeline/sink.rsandformat/parquet.rs(which is sync today).chunked/exec.rsswitches fromthread::scopetotokio::join!/JoinSet.Destination::writeis already sync (OpenDAL blocking layer); making it async would unwind the layered design.
Estimated effort: weeks. Estimated value over current model: marginal — chunked workers already saturate the source DB’s network and CPU; adding async coordination on top does not help.
Rejected for v0.5.x. Revisit if a future requirement (e.g. async incremental tailing, CDC-like consumption) needs async I/O for an unrelated reason.
C. Lazy Source creation inside workers (current behaviour)
This is what we do. Each chunked worker calls source::create_source(&plan.source) inside thread::scope and owns its connection for the chunk’s duration. StateRef::Postgres(url) propagates the connection string into workers without requiring shared state.
When to revisit
Open a follow-up ADR if:
- A blocking SQL driver appears that is genuinely
Syncwithout internal serialization (none currently exists for Postgres or MySQL). - The whole pipeline migrates to async (would also affect ADR-0010).
- Profiling shows connect handshakes dominating end-to-end latency on a real workload — currently far from the case (200K-row content_items chunked export: connect <50ms, query+stream 8s).