Route N source relations to M ClickHouse destinations (distinct endpoints / databases): fan-out (one source table → several CH clusters) and fan-in (several source tables → one CH table). Large net-new work, gated on the emitter growing a destination abstraction.
Companion TABLESPACES.md covers source-tablespace
correctness; its Tablespace as an emitter-visible attribute section
(§3 there) exposes spc_node, one of the routing keys §2 below can
match on. That's the only coupling — this proposal stands alone.
The pipeline ships to exactly one CH endpoint: the inserter pool
(src/pipeline/inserter.rs) opens N connections all built from a
single EmitterConfig { host, port, database, user, password }
(src/ch_emitter.rs), and DdlApplicator opens one more to the
same endpoint (src/ch_ddl.rs). A relation maps to one
TableMapping { target: String, columns }, keyed by
"<namespace>.<relname>" in pipeline::lookup_mapping; target is
copied verbatim into INSERT INTO {target} ... FORMAT Native in
TablePlan::build. NamespaceMapping.target_database only picks a CH
database on the one endpoint, not an endpoint. There is no list of
endpoints anywhere.
Before designing in-process N:M, state the honest baseline: run K walshadow daemons, each with its own replication slot, each routing a disjoint or overlapping relation subset to one destination. This gives true failure isolation (a dead CH cluster stalls only its own daemon's slot) and zero new code. Its cost is K physical WAL reads on the source and K shadow Postgres instances.
Integrated N:M is worth building only when the shared cost dominates — one WAL read + one shadow + one decode feeding many sinks. That holds when: WAL volume is high, destinations overlap heavily (same tables to several places), or shadow/decode CPU is the bottleneck. When destinations carry disjoint, low-overlap relation sets, multiple daemons is the better answer. The design below should not pretend otherwise; it earns its complexity on the shared-read case.
Generalize the relation→target lookup from "one target string" to
"a routing decision producing zero or more (destination, target)
pairs." A route predicate matches on, in precedence order:
- explicit per-table rule (qualified name) — finest
- per-namespace rule
- per-tablespace rule (
spc_nodefrom TABLESPACES.md §3;0= default) - global default destination
Predicates resolve to a set of destinations (fan-out) each with a
target table name. Fan-in (N source tables → 1 CH table) needs no new
mechanism: two source rules name the same (destination, target);
_lsn/_xid synthetic columns already disambiguate provenance and
_lsn dedup keeps end-state correct (per
[[walshadow-eventual-consistency]]), provided the union schema is
compatible (operator's responsibility, validated at DDL time).
Tablespace as a routing key is supported but weak: tablespace is a physical-storage choice (which disk), rarely aligned with logical routing intent. Namespace or per-table rules are usually the right key. The one real case is tablespace-per-tenant layouts; support it, don't lead with it.
struct DestinationId(String); // operator-named, stable
struct Destination {
id: DestinationId,
endpoint: ChEndpoint, // host, port, user, password, compression
database: String, // default CH db for this dest
insert: Client<'static>, // INSERT pump
ddl: DdlApplicator, // per-dest, separate connection
budgets: EmitterBudgets, // rows/bytes/deadline, per-dest
ack_lsn: AtomicU64, // highest fully-sealed LSN to this dest
}The tail grows dests: HashMap<DestinationId, Destination> — most
naturally one batcher + inserter pool + ack collector per destination
— and per-table encoder state becomes per (DestinationId, table).
The decode pool fans each routed row to every matched destination.
Sealing, budget trips, and reconnect/retry all become per-destination
— a stall or reconnect on one destination must not block sealing on
another (modulo §4).
DDL fans out: a SchemaEvent for a relation routed to destinations
{A,B} applies to A's and B's DdlApplicator independently
(src/ch_ddl.rs), each behind its own barrier fence (today's fence
spans one destination's seqs; per-destination fencing is new
machinery). Auto-create and drop_table_strategy resolve per
destination.
The slot advances on min(shadow_replay, emitter_ack) (overview /
plans/ops.md). With M destinations, emitter_ack becomes
min over all M destination ack_lsn. Therefore:
The slowest (or dead) destination bounds slot advance, and source WAL accumulates until it catches up.
This is the central, unavoidable tension of single-slot fan-out. One physical slot can be released only to the LSN every consumer has durably accepted. Three responses, none free:
- Couple and stall (default).
minacross destinations; a dead destination eventually triggers the existing CH-bounce retry budget (plans/future/ch_bounce_recovery.md) and kills the daemon, cursor resumes on restart. Simple, but one bad destination DoSes the source's WAL retention. Acceptable when destinations are co-located and equally trusted - Decouple via spill. Let a lagging destination fall behind by
buffering its pending rows to the spill area
(
{spill_dir}/dest-<id>/...), advancing the slot on the fast destinations while the slow one drains from spill. Bounds source bloat at the cost of unbounded local disk for the laggard. Needs a per-destination spill budget and a kill switch when it's exceeded. This is the only way to get real isolation from one slot, and it is substantial work — effectively a per-destination durable queue - Separate slots. = §1's multiple-daemon baseline. Rejected for the integrated path; if you want independent slots, run independent daemons
Recommend shipping (1) first with a loud walshadow_dest_lag_bytes{dest}
metric and a per-destination max_lag_bytes that fails fast, then
(2) only if a deployment proves it needs isolation without paying for
multiple WAL reads.
Cursor records a single emitter ack today (plans/ops.md v2 six-LSN
layout; see also [[inproc-harness-large-xact]]). N:M needs
per-destination ack LSN persisted so restart resumes each
destination from its own position rather than re-emitting from the
global min:
destinations: Vec<{ id: String, ack_lsn: Lsn }>
Crucially, _lsn dedup makes per-destination cursors forgiving:
re-emitting rows a destination already has is idempotent (Replacing/
dedup on _lsn), so cursor precision trades disk/bandwidth, not
correctness. A coarse cursor (single global min) is correct but
re-ships data to ahead-destinations on restart; the per-destination
list is a bandwidth optimization, not a correctness requirement. Bump
cursor version; old cursors restore with a single-destination list
(missing field → [{id:"default", ack_lsn: legacy_ack}]).
Destinations are connection descriptors — like [ch] today, they
describe how to reach an external system, so they stay in TOML/CLI, not
in the source-PG overlay (same boundary as
[[runtime-config-from-pg]] draws for connection params):
[destination.warehouse]
host = "ch-warehouse"; port = 9000; database = "wh"; ...
[destination.analytics]
host = "ch-analytics"; port = 9000; database = "an"; ...Routing rules (which relation → which destination) are logical, not
connection state, so they belong in the runtime-config overlay: extend
config_table / config_namespace (schema in ../config.md)
with a destinations text[] column. TOML [table.…] / [namespace.…] blocks
gain a destinations field (absent → the single default destination, preserving
the single-destination behavior byte-for-byte). This makes §0 the
zero-destination-config special case of §2, so existing TOML keeps working
unchanged.
- decode, filter, shadow, xact buffer, TOAST reassembly — all upstream of the emitter, destination-agnostic. N:M is an emitter-and-cursor change
- per-destination ordering and dedup semantics are identical to today's
single-destination guarantees; eventual consistency via
_lsn([[walshadow-eventual-consistency]]) holds per destination - a single source table to a single destination is unchanged
- §2 + §3 routing model +
Destinationabstraction, default-only config so behavior is unchanged, behind a feature gate - §4 response (1) coupled-stall +
walshadow_dest_lag_bytesmetric - §5 cursor schema bump
- §6 TOML rules, then overlay column once runtime-config lands
- §4 response (2) per-destination spill — only if isolation demand is proven
- §6's logical routing rules depend on
plans/future/runtime_config_from_pg.mdfor the overlay column; TOML-only rules have no dependency - §5 reuses the cursor version-bump discipline from
plans/future/two_phase_commit.md(old cursor restores cleanly into new daemon) - tablespace routing key (§2.3) depends on
TABLESPACES.md §3 exposing
spc_nodeon theRelDescriptor; the other routing keys (namespace, table) have no such dependency
- Per-destination DDL divergence. If destination A auto-creates a
table and B has
drop_table_strategy = retainwith a pre-existing incompatible schema, the two destinations diverge. Surface per-dest DDL outcomes; do not let one destination's DDL failure stall the other's INSERT pump - Fan-in schema conflicts. Two source tables → one CH table with
incompatible column sets. Validate union compatibility at route-plan
build (
TablePlan::build) and reject with a clear metric rather than emitting malformed blocks - Slot-advance starvation under spill (§4.2). A destination that
never catches up grows spill without bound; the
max_lag_byteskill must fire before disk fills, and the operator must be told which destination forced it - Is the integrated path ever right? §1 is the honest default. Pin the crossover with measurement: WAL volume × destination overlap where one-read-fan-out beats K daemons. Don't build §3+ until a real deployment lands past that line