Distributed mode
The collapsed CLI (urutau run) puts the reader, the worker, and the sink
writer in one process. That is enough for a laptop, but it means one
process owns every table's throughput, and a crash stops everything.
Distributed mode splits the same engine in two:
urutau-coordinator— connects to the source, runs the DBLog snapshot, partitions each table's key range, routes rows to workers over Arrow Flight, commits staged Iceberg cycles, and supervises worker health.urutau-worker— connects to the coordinator, receives an assignment (table, key range, source DSN), and owns the writes to the sink for that partition.
This is the engine Kubernetes automates; if you are on a cluster, read Deploy on Kubernetes instead — the coordinator and workers are the same, just scheduled for you.
How workers are assigned
The coordinator decides the worker count and names; a worker never chooses
its own work. For a table with workers: {number: N}, the coordinator
splits the primary-key range into N contiguous ranges and derives the
group names <pipeline>-<target>-<index>. It waits for exactly those
--names to connect (up to --wait-worker), then hands each its range.
A worker started with an unexpected --name, or one that never connects,
is a startup failure, not a silent degradation: the coordinator waits
--wait-worker and then fails.
Running it by hand
Use the same spec you would give urutau run, with workers: {number: N}
on the tables you want to parallelize.
# Terminal 1 — the coordinator
./bin/urutau-coordinator run -f pipeline.yaml --listen :50051
# Terminal 2..N+1 — one worker per derived name
./bin/urutau-worker run \
--coordinator localhost:50051 \
--name orders-0 \
--catalog-uri http://localhost:8181/api/catalog \
--client-id root --client-secret s3cr3t
The worker names must be exactly the derived ones the coordinator expects.
For a table raw.orders in pipeline orders with number: 3, they are
orders-raw.orders-0, orders-raw.orders-1, orders-raw.orders-2.
The catalog settings (--catalog-uri, --client-id, …) can also come
from URUTAU_SINK_URI, URUTAU_SINK_CLIENT_ID, and friends — see the
CLI reference.
Secure the control plane
The Flight assignment carries the source DSN, credentials included. Run
the control plane under mTLS: give the coordinator
--tls-cert/--tls-key/--tls-ca and every worker the matching client
flags. With no TLS flags the coordinator refuses to boot — it will not
send the DSN in the clear by omission. To accept plaintext explicitly (e.g. a
trusted network), pass --allow-insecure-control-plane, which downgrades the
failure to a startup warning:
WARN coordinator: control plane is PLAINTEXT — the Assignment carries the source DSN; set TLS cert/key/CA (running because --allow-insecure-control-plane was set)
A minimal CA plus a server certificate for the coordinator and a client certificate for each worker:
# CA
openssl req -x509 -newkey rsa:4096 -days 365 -nodes \
-keyout ca.key -out ca.crt -subj "/CN=urutau-ca"
# Coordinator server cert — the SAN must match how workers dial it
openssl req -newkey rsa:4096 -nodes -keyout server.key -out server.csr \
-subj "/CN=urutau-coordinator"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 365 -out server.crt \
-extfile <(printf "subjectAltName=DNS:urutau-coordinator,DNS:localhost,IP:127.0.0.1")
# Worker client cert
openssl req -newkey rsa:4096 -nodes -keyout client.key -out client.csr \
-subj "/CN=urutau-worker"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 365 -out client.crt
urutau-coordinator run -f pipeline.yaml \
--tls-cert server.crt --tls-key server.key --tls-ca ca.crt
urutau-worker run --coordinator urutau-coordinator:50051 \
--tls-cert client.crt --tls-key client.key --tls-ca ca.crt
Source credentials and files on workers
The worker owns the snapshot chunk SELECT, so the coordinator sends it a
source DSN — credentials included. Two consequences for a structured
source.postgres block:
- TLS material is sent as paths, not contents. The DSN carries
sslrootcert/sslcert/sslkey(fromssl.ca/ssl.cert/ssl.key), so every worker must mount those files at the same paths as the coordinator. Otherwise the assignment validates but the snapshot connection fails. Mount them from the same Secret/volume on both. - SSH tunnels are not supported in distributed mode. A DSN cannot carry
the tunnel, and the worker would connect directly to the database. Set
source.snapshotUrito a directly reachable read-only URI (or use the collapsed runner). The coordinator rejects the combination otherwise.
Supervision and resets
A worker that stops acking is not silently dropped. If a worker goes
silent for --ack-timeout (30s default), the coordinator resets the
assignment: the partition is re-routed, and the worker must reconnect and
re-sync from the last committed position. This is the recovery path for a
worker crash or a network partition.
Too many resets in a short window means something is systematically wrong
(a bad sink, a flapping network), so the coordinator stops retrying: after
--max-resets (5) resets within --reset-window (15m), the job
terminates rather than loop forever. Both are configurable.
Resets are safe because the position lives in the sink, not in the worker: a resumed partition reads its last committed position back from the sink and continues. See State & position.
Concurrent writers
Parallelizing a table means several writers committing to it. Both built-in sinks support this, with different mechanisms:
- Iceberg: workers stage data files without committing; the coordinator commits one cycle's staged files as a single unit — one committer, one position write, no race.
- ClickHouse: workers commit independently, but the durable position is kept per partition and read back as the minimum safe value.
A sink that does not support concurrent writers rejects
workers: {number: N > 1} at boot with an error naming the table.
Operator
has the details.
Next steps
- Automate the lifecycle: Deploy on Kubernetes.
- Live signals: Monitoring.
- Audit log, checkpoints, supervision: Reliability.
- All flags: CLI reference.