Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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=200 Postgres.
  • No lean_pool_opts() workaround in MySQL (the min=10 bug we fixed in f6ba79f).
  • Faster startup — no per-worker Client::connect.

Why it is the wrong trade-off today

  1. Serialized I/O kills the point of parallelism. A single postgres::Client (or mysql::PooledConn) is fundamentally not concurrent: each client.query() exchanges Postgres wire-protocol packets in lock-step. Wrapping it in Mutex<Client> or RefCell<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 of content_items (200K rows) when prototyped against a Mutex<Client>.

  2. pipeline/chunked/exec.rs already 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.

  3. Backend explosion is a non-problem in practice. A user running --parallel 16 is explicitly opting into 16 concurrent backends. The exporter has guardrails: lean_pool_opts() keeps the MySQL pool at min=1, ADR-0010 keeps each child process to one Source, and pooler detection (detect_pg_transaction_pooler) warns when a pgBouncer is multiplexing. Operators who want fewer backends can run with lower --parallel.

  4. The Sync refactor blocks on multiple deps the audit flagged separately.

    • The postgres::Client API is &mut-only; making it Sync requires either Mutex (slow, see #1) or an async client (a much larger surface change).
    • mysql::PooledConn has 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.
  5. The Send bound is exactly what we need. Workers move their Source into thread::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 --parallel flag 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 self everywhere, no Mutex, no RefCell, no Arc<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 NewDB requires only &mut self methods, 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_batch becomes async, propagating through pipeline/sink.rs and format/parquet.rs (which is sync today).
  • chunked/exec.rs switches from thread::scope to tokio::join! / JoinSet.
  • Destination::write is 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 Sync without 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).