Operate a sync fleet
Supervise many continuous syncs from one process — failure-isolated, observable, and reconfigurable without a restart.
Once you keep several cross-database syncs alive at once, running each sync start as its own pod or systemd unit gets unwieldy. sync run collapses that to one supervised process driven by a single fleet config: it runs N independent syncs, each with its own stream-id, and — the load-bearing property — fully failure-isolated, so one sync crashing, erroring, or even panicking can never take down its healthy peers (ADR-0122). This guide covers running a fleet, observing it, and reconfiguring it live.
The fleet config #
A fleet is a YAML file listing each sync as a curated subset of the sync start flags you already know, in kebab-case. A top-level restart block tunes the supervisor's bounded-backoff policy:
# syncs.yaml
syncs:
- stream-id: orders
source-driver: postgres
source: postgres://user:pass@src-a:5432/app
target-driver: mysql
target: mysql://user:pass@dst:3306/app
slot-name: orders # distinct per Postgres source (see below)
apply-concurrency: 4
metrics-listen: :9101
- stream-id: inventory
source-driver: mysql
source: mysql://user:pass@src-b:3306/inv
target-driver: postgres
target: postgres://user:pass@dst:5432/inv
apply-delay: 60s
metrics-listen: :9102
restart:
backoff-base: 1s
backoff-cap: 30s
max-consecutive-failures: 0 # 0 = restart forever with capped backoff
slot-name would fight over one single-consumer slot — silent corruption — so the loader refuses the config, naming both stream-ids and the slot. Duplicate stream-ids on the same target (which would clobber each other's position row) are refused the same way. When several syncs point at one target server, the loader WARNs that they share a connection budget so you can size apply-concurrency accordingly.Each sync's own retry (ADR-0093 re-snapshot, apply-retry backoff) is the inner loop; the supervisor's restart is the outer loop. A sync that drains cleanly (a sync stop or Ctrl-C) is left stopped; a sync that dies with the process still live is logged loudly, backed off, and restarted. The consecutive-failure counter resets once a sync has run longer than the healthy threshold, so a sync that ran for hours before dying carries no restart debt.
UNFORWARDED-SCHEMA-CHANGE (v0.156.0+). A leg that stopped because its Postgres or MySQL/MariaDB binlog source took a constraint, row-level-security, policy or DEFAULT change the stream cannot carry (what that covers) is marked failed at once and logged at ERROR, naming the acknowledgement route; the other legs keep running. A restart could only refuse again — the refusal is recorded on the target — and this is the one refusal where a restart that somehow skipped the record would accept the change silently. Since v0.156.7, more refusals are not restarted either (listed below); every other failure, other terminal errors included, is still restarted exactly as above. There is deliberately no syncs.yaml key for the acknowledgement, because standing config would pre-accept refusals. Since v0.156.1 the same applies to an interrupted added-column backfill, ADD-COLUMN-BACKFILL-INCOMPLETE (its message begins UNFORWARDED-SCHEMA-CHANGE: ADD-COLUMN-BACKFILL-INCOMPLETE:), which the supervisor treats identically — but for that one, step 1 below is different: there is no DDL to apply, so instead copy the added column's values from the source for the rows that predate the ADD COLUMN, keyed by primary key (or re-copy by passing --restart-from-scratch on the acknowledged start). Since v0.156.2 the supervisor's ERROR line leads with that repair for this refusal; on v0.156.1 it said "apply the change to the target" (Bug 289). Stopping a leg, or the fleet, while a backfill runs produces it, so wait for the leg's backfill complete line first. To opt a leg out of the backfill, set no-backfill-added-column: true on it. To bring the leg back:
- Apply the same change to the target (the leg is already stopped).
- Start that stream once outside the fleet with the leg's own source, target and flags:
sluice sync start --stream-id <id> … --accept-unforwarded-schema-change=<fingerprint>, using the fingerprint the replayed refusal prints. - Stop it with
sluice sync stop --stream-id <id> … --wait. - Restart the fleet process. On Linux and macOS a
SIGHUPalso works: a reload starts a failed leg that is still insyncs.yaml.
failed and is not restarted, whatever max-consecutive-failures says; the other legs keep running. Each is a condition every restart would hit again:
SLOT-ACKED-PAST-TARGET-POSITION: the slot and the persisted position are both durable, so a restart compares the same two values.SHARDED-TARGET-VINDEX-UPDATE: the refused change is after the persisted position, so a restart re-delivers it.APPLY-MARK-MISMATCH: the marks are durable, and a restart re-delivers the same transaction onto them.CHARSET-NOT-DECODABLE: a restart reads the same value again.DSN-TIME-ZONE-NOT-UTC: a restart parses the same DSN.SLUICE-E-CDC-KEY-MATCHED-MULTIPLE-ROWS(markerKEY-SCOPED-WRITE-MATCHED-MULTIPLE-ROWS, v0.156.8+): the target transaction holding the change is rolled back, so a restart replays the same source changes onto the same committed rows. It needs the re-copy the refusal names.CHANGE-LOG-WATERMARK-STALLED(v0.156.9+,postgres-triggersources): a restart re-reads the same immutable change-log window through the same code. The halt means an unknown sluice bug, so whether every possible cause repeats is unverified; it is listed because restarting forever would turn the halt back into the silent stall it exists to end. Report it.HEARTBEAT-TABLE-NOT-SLUICES(v0.156.9+): the existing source table under the heartbeat's name is still not sluice's. A fleet spec has no heartbeat key today, so no fleet leg reaches it yet; it is listed so one never restart-loops on it.RESUME-SCHEMA-DIVERGENCE(v0.156.10+): the check compares the source's current table with the target's catalog and records nothing, so a restart refuses again until the target matches. Make the target match the source, then reload the fleet; a reload starts a failed leg still insyncs.yaml. A failure to read the target's catalog is not this marker and is still restarted.SCHEMA-CHANGE-REFUSED(v0.156.10+, on legs that forward no DDL:schema-changes: refuse, multi-database and multi-schema legs,--inject-shard-columnwith--no-coordinate-live-ddl): not restarted for the same reason. Apply the change on the target, then reload. A refusal that also carriesAMBIGUOUS-SCHEMA-BOUNDARYis covered too; the fleet spec has no acknowledgement key, so after the checks start that leg once outside the fleet with--accept-unforwarded-schema-change=<fingerprint>.COLUMN-ORDER-CHANGED(v0.158.0+): the source flipped the declared order of two of a table's columns (aRENAME COLUMNswap or a reorder), and nothing proves the target's own order moved to match. The check records nothing, so a restart refuses again: apply the same change on the target, then reload. Where the message names a re-copy instead (no record for the table, or a target that cannot reorder in place), runsync start --reset-target-data --yesfor that leg outside the fleet.DEFERRED-SCHEMA-CHANGE(v0.158.0+, MySQL-family binlog sources; underbinlog_row_metadata=FULL, and since v0.159.2 underMINIMALas well): a row carries a type or collation change that no schema boundary will describe before its rows are applied, and a restart re-reads the same binlog. Since v0.159.2 it is also raised markedCOLLATION-CHANGE-UNANNOUNCED, underMINIMALorFULL, for a character column whose collation changed with no logged DDL of the table (for example anALTERrun withsql_log_bin=0). The remedy is a re-copy (sync start --reset-target-data --yes), outside the fleet.COLUMN-IDENTITY-CHANGED(v0.159.0+, MySQL-family binlog sources, any metadata setting): the source'sALTERs left a column name holding a different column (aDROP COLUMNand re-ADDof the name, or aRENAMEof another column onto it). The refused DDL is never crossed, so a restart re-reads it and refuses again. Also raised asCOLUMN-IDENTITY-UNREADfor such anALTERsluice could not parse. The remedy is a re-copy (sync start --reset-target-data --yes), outside the fleet; there is no acknowledgement flag.KEY-COLLATION-MERGE(v0.159.0+, every target): a CDC upsert, or since v0.159.1 a cold start's VStream COPY upsert, matched a target row through a primary or unique key whose target collation equates two values the source's own collation keeps apart, so applying it would merge two source rows. A restart re-delivers the same row and refuses again. Give the target key column the collation the message names, then reload: since v0.159.1 that is a binary collation such asutf8mb4_binfor a Postgres-source key on a MySQL-family target, and the NO PADutf8mb4_uca1400_nopad_*form for a MySQL 8 key on MariaDB. Where the message names a re-copy (the identifying key itself is compared differently, or the table has no primary key), run it outside the fleet. A key armed to compare bytes (a Postgres source collation on a MySQL-family target) gets no re-copy offer, because a re-copy recreates the target's equating default; use the named collation. WithKEY-COLLATION-GUARD-UNSUPPORTEDin the message, the target lacks the collation the check compares under and none of the table's changes were applied: create the collation on the target or align the key column on both sides.KEY-COLLATION-CHANGEDarrives insideRESUME-SCHEMA-DIVERGENCEorSCHEMA-CHANGE-REFUSED, already listed above.KEY-COLLATION-HISTORY-UNORDERED(v0.159.2+, inside aKEY-COLLATION-MERGErefusal, so classified with it):sync from-backup, or a chain restore that cannot place a window's schema versions between its changes (a trigger-CDC chain), refused a backup window that changes a key column's collation. It comes from those commands rather than from async startleg. Neither restarting nor changing the target key converges. The remedy is a newbackup fullafter the key change, thensluice sync from-backup run --reset-target-data --yesagainst the new chain (destructive), run by hand. AKEY-COLLATION-MERGEfromsluice restorewhose message says the incremental changes a key column's collation inside its own window needs that fresh full backup too, since every restore re-applies the change ahead of the same rows.KEY-COLLATION-VERSION-NAMESPACE(v0.159.3+) also arrives inside aKEY-COLLATION-MERGEfrom chain restore and is classified with it; its remedy is the same fresh full backup.UNIQUE-KEY-CONFLICT-OTHER-ROW(v0.161.1+, every target): an upsert, CDC or copy, matched a different target row through a unique key the target has and the source evidently no longer does, so applying it would overwrite that row with the incoming one. A restart re-delivers the same row and refuses again. Drop that key on the target, then reload; or re-copy outside the fleet withsync start --restart-from-scratch(from a Postgres pgoutput source, after dropping the stream's replication slot). Both re-copy paths are destructive.SOURCE-UNIQUE-KEY-ABSENT(v0.162.0+, every source): the target has a unique key no source key implies, usually a sourceDROP INDEXthe stream did not see, so the source's next duplicate of it would overwrite a different target row. Every start reads the same two catalogs and nothing is recorded, so a restart refuses again. Drop the key on the target, then reload; where the message says rows may already have merged, re-copy outside the fleet. A key the target is meant to have is accepted in the leg's spec with the standingaccept-target-only-unique-keylist (the value the refusal prints), then reload. ASOURCE-UNIQUE-KEY-RECHECK-FAILEDstop (a catalog read failed) is not this marker and is still restarted.INCLUDE-TABLE-SCOPE-DRIFT(v0.163.0+, every source's warm resume): the leg's scope holds a table the stream never copied or stopped keeping up. The check reads the stream's record of copied tables and pending add-tables on its control row, which a restart does not change, so it refuses again. The same marker carries the shapes below; apply the remedy the message names: forLIVE-ADDED-TABLE-EXCLUDED(the leg'sexclude-tablenames a tableschema add-tablebrought in), narrow the exclusion in the spec or runsluice schema add-table --abandon <table> --yes, then reload; forTABLE-IDENTITY-CHANGED(a table swapped or re-created while stopped), re-copy outside the fleet; for a rename no engine id proves, rename the target table and start once outside the fleet with--accept-table-rename <old>=<new>; forCOLD-START-INCOMPLETE(a cold start stopped after it began changing the target), re-run that cold start outside the fleet; for a dead add-table of a table since dropped or descoped, runsluice schema add-table --abandon <table> --yes, then reload. A failure to read the catalog or the control row is not this marker and is still restarted.
PRE-ALTER-REPLAY-WEDGE. A wedged leg is restarted and refuses again each time (GC-44 F27, unchanged from v0.156.9), so alert on a leg that keeps restarting with that marker and apply the recovery outside the fleet. Nor is KEY-COLLATION-REPLAY-AMBIGUOUS (v0.159.1+), deliberately: a restart runs the copy again, which is its remedy. Nor, since v0.163.0, are INCLUDE-TABLE-ADD-IN-PROGRESS (every table the scope check refuses has a schema add-table still running; the restart after backoff resumes once the add completes, and an add that died goes stale and becomes the listed INCLUDE-TABLE-SCOPE-DRIFT) and INCLUDE-TABLE-NARROWING-UNRECORDED (a start that narrows the scope could not record the narrowing; a transient failure clears on a restart, while one that persists usually means a missing record column, so ship the ALTER it names).
The supervisor logs each at ERROR with a marker= attribute naming the refusal, so alert on failed legs and on that attribute. Before v0.156.7 the first five were restarted after backoff and refused again, indefinitely under the default max-consecutive-failures: 0, while the fleet looked healthy. Other coded SLUICE-E-* refusals and other terminal errors are still restarted under the failure cap, because some clear without any change to the leg (for example SLUICE-E-CDC-REPLICATION-HEADROOM once another slot is freed). DEFERRED-KEY-CHECK-FAILED-AT-COMMIT is deliberately among them: one of its causes depends on arrival timing, and a restart can clear it.
Apply the remedy the message names. The fleet config has no key for --restart-from-scratch or for the one-shot acknowledgement flags, so a remedy that needs one runs outside the fleet. The exception is the standing accept-target-only-unique-key list (v0.162.0+): it is a fleet spec key, because an accepted target key stays on the target and must be accepted on every start, so put it in the leg's spec and reload rather than taking the leg out. --accept-table-rename (v0.163.0) has no fleet key and runs outside the fleet like the others. sluice schema add-table --abandon is a separate command, not a flag on the leg: run it against the leg's stream, then reload. For the others:
- Remove the leg from
syncs.yamland reload the fleet (restart it, orSIGHUPon Linux and macOS). A reload starts every failed leg still in the config, so do not reload with the leg still listed until the fix is in. - Run that stream once with
sync start, with the leg's own source, target and flags plus the remedy flag, for example--restart-from-scratchor--accept-slot-acked-past-position=<LSN>. Stop it withsluice sync stop --stream-id <id> … --waitonce it is running cleanly. - Put the leg back in
syncs.yamland reload.
Run the fleet #
Validate the config first with --dry-run (it checks required fields, stream-id and slot-name uniqueness, and retry bounds, then prints the resolved plan without starting anything), then run it:
# validate + print the plan, start nothing
sluice sync run --config syncs.yaml --dry-run
# run the fleet, with a read-only dashboard on :9300
sluice sync run --config syncs.yaml --dashboard-listen :9300
The process blocks until every sync exits. Ctrl-C / SIGTERM stops all of them cleanly. A single-sync fleet that can never start exits non-zero, but a fleet with any healthy peer keeps running regardless of what its neighbors do.
Reload without a restart (SIGHUP) #
Edit syncs.yaml and send the running process a SIGHUP: sluice re-reads and re-validates the file, then reconciles the live fleet — starting newly-added syncs, draining and stopping removed ones, and restarting any whose spec changed (detected by a per-stream fingerprint, so unchanged syncs are left untouched):
kill -HUP "$(pgrep -f 'sluice sync run')"
SIGHUP is POSIX-only; on Windows, restart the process to change the fleet.include-table or exclude-table and sending SIGHUP restarts that leg, and a warm resume copies nothing, so the scope rules apply: widening to a table the stream never copied — named literally, or admitted by a glob when its changes would be applied anyway — refuses with INCLUDE-TABLE-SCOPE-DRIFT (the leg is marked failed); narrowing logs a WARN and records the table as dropped, so widening back to it later refuses until it is copied again; and excluding a table schema add-table brought in refuses with LIVE-ADDED-TABLE-EXCLUDED. To add a table to a running leg, use sluice schema add-table against its stream, not the spec.See the whole fleet at once #
sync status --all rolls up every stream across every target named in the fleet config into one table — reading the target control tables directly, so no running supervisor is required. A target that can't be reached is reported inline and skipped rather than blanking the whole view:
sluice sync status --all --config syncs.yaml --summary
# live-refresh every 2s, machine-readable
sluice sync status --all --config syncs.yaml --format json --watch 2s
Observe it: Prometheus metrics and readiness #
Give each sync a metrics-listen address in the config (as above) and it binds a Prometheus-format /metrics endpoint plus /healthz (liveness) and /readyz (flips to 200 once the sync has finished its snapshot/warm-resume preamble and entered the apply loop). The exported sluice_* gauge families include:
| Gauge | What it tells you |
|---|---|
sluice_sync_lag_seconds | Seconds the target trails the source's latest applied commit (engine-neutral apply lag; 0 when caught up). |
sluice_seconds_since_last_apply | Wall-clock seconds since this stream's most recent applier commit — the staleness signal. Never negative: skew of up to 60 s reads 0, and a row dated further in the future reads +Inf under a # CONTROL-TIMESTAMP-IN-FUTURE comment, so a > N alert fires (v0.156.6). |
sluice_stream_known | Constant 1 per tracked stream; count(sluice_stream_known) gives a stream-count alert. |
sluice_apply_batch_size_current / _p95_seconds | The AIMD apply-batch controller's current target size and rolling p95 latency. |
sluice_target_* | Target CPU / memory / storage utilisation and replica lag — present only when PlanetScale telemetry is configured. |
Because /readyz is a real readiness signal, it wires straight into a Kubernetes probe on the sync's metrics port:
readinessProbe:
httpGet:
path: /readyz
port: 9101
periodSeconds: 10
Pre-emptive Postgres slot-health warnings #
For a Postgres source, a replication slot that outruns its retention budget gets evicted and the stream breaks. sluice watches for that ahead of time and emits severity-graded slog warnings: a WARN when retention pressure crosses 70% of max_slot_wal_keep_size, a CRITICAL at 85% (eviction imminent), and a WARN when the slot has been observed inactive for 30 minutes or more (ADR-0059). These are rate-limited and emit a "cleared" INFO when the condition resolves, so the alarm turns off visibly.
Live dashboards: web and terminal #
sync run --dashboard-listen ADDR serves a self-contained, auto-refreshing HTML page of the live fleet — per-sync state, restart count, consecutive failures, last error, uptime — backed by a stable GET /api/fleet JSON API (ADR-0124). It is strictly read-only: no stop/restart controls, no data path, and it exposes only what sync status --all already does (stream-ids, states, error strings — no DSNs, no row data). It has no authentication, so bind it to localhost or a trusted network.
For a terminal equivalent, sync tui --connect is a full-screen client that polls that same /api/fleet endpoint — so it works locally or over an SSH tunnel to the dashboard port, without disturbing the fleet process:
# terminal 1: run the fleet with the dashboard API exposed
sluice sync run --config syncs.yaml --dashboard-listen :9300
# terminal 2 (local or over an SSH tunnel): live terminal view
sluice sync tui --connect :9300 --refresh 2s
Threshold alerts (advisory) #
The fleet can push threshold alerts to a webhook or Slack. Set the sink URL via its env var (SLUICE_NOTIFY_WEBHOOK / SLUICE_NOTIFY_SLACK), then arm one or more thresholds. Alerts are edge-triggered, cooldown'd (--notify-cooldown, default 15m), and advisory + failure-isolated — a dead sink is logged and swallowed, never affecting the sync:
| Threshold | Fires when… |
|---|---|
--notify-sync-lag-seconds | sluice's own apply lag (sluice_sync_lag_seconds) is at or above N. Ungated — works on MySQL and Postgres alike, needing only a sink. |
--notify-lag-seconds | The target's control-plane replica lag is at or above N. Requires PlanetScale telemetry. |
--notify-storage-util / --notify-cpu-util / --notify-mem-util | Target utilisation (0–1 fraction) is at or above the given level. Requires PlanetScale telemetry. |
--notify-router-cpu-util | The routing layer in front of a sharded target (Neki's routers) is at or above the given CPU fraction. A separate machine from the database, with a separate remedy — a larger router tier. Inert where there is no routing layer. |
--notify-storage-growth-per-min | Storage is climbing at or above N fraction-of-capacity per minute — a pre-grow early warning. A value outside 0–1 refuses at start (since v0.124.0, same as the util fractions). Requires telemetry. |
notify-dead-tuple-ratio / notify-xid-age | Postgres-target autovacuum advisories (v0.99.288): the worst user table's dead-tuple ratio, and the database's age(datfrozenxid) wraparound headroom. Ungated — probed from the target's own catalog; Postgres targets only. |
All the util / control-plane-lag / growth rules need a PlanetScale telemetry provider (--planetscale-org plus the metrics-token flags); --notify-sync-lag-seconds and the two vacuum advisories work without it, needing only a sink. The telemetry-gated alerter set is also available on the standalone metrics-watch probe.
Next steps #
- sync start reference — the full per-sync flag surface each fleet entry is built from.
- Sync to PlanetScale / Vitess — the telemetry credentials the util-threshold alerts and
sluice_target_*gauges require. - Zero-downtime migration — the single-stream cutover flow each fleet sync runs internally.
