sluice

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
Two data-corruption classes are refused at load, loudly. Two Postgres-source syncs that resolve to the same replication 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.

A failure that is not restarted: 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:
  1. Apply the same change to the target (the leg is already stopped).
  2. 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.
  3. Stop it with sluice sync stop --stream-id <id> … --wait.
  4. Restart the fleet process. On Linux and macOS a SIGHUP also works: a reload starts a failed leg that is still in syncs.yaml.
These refusals are not restarted either (five since v0.156.7, one since v0.156.8, two since v0.156.9, two since v0.156.10, two since v0.158.0, two since v0.159.0, one since v0.161.1, one since v0.162.0, the last since v0.163.0). A leg that stops on one of these is marked 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 (marker KEY-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-trigger sources): 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 in syncs.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-column with --no-coordinate-live-ddl): not restarted for the same reason. Apply the change on the target, then reload. A refusal that also carries AMBIGUOUS-SCHEMA-BOUNDARY is 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 (a RENAME COLUMN swap 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), run sync start --reset-target-data --yes for that leg outside the fleet.
  • DEFERRED-SCHEMA-CHANGE (v0.158.0+, MySQL-family binlog sources; under binlog_row_metadata=FULL, and since v0.159.2 under MINIMAL as 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 marked COLLATION-CHANGE-UNANNOUNCED, under MINIMAL or FULL, for a character column whose collation changed with no logged DDL of the table (for example an ALTER run with sql_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's ALTERs left a column name holding a different column (a DROP COLUMN and re-ADD of the name, or a RENAME of another column onto it). The refused DDL is never crossed, so a restart re-reads it and refuses again. Also raised as COLUMN-IDENTITY-UNREAD for such an ALTER sluice 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 as utf8mb4_bin for a Postgres-source key on a MySQL-family target, and the NO PAD utf8mb4_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. With KEY-COLLATION-GUARD-UNSUPPORTED in 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-CHANGED arrives inside RESUME-SCHEMA-DIVERGENCE or SCHEMA-CHANGE-REFUSED, already listed above.
  • KEY-COLLATION-HISTORY-UNORDERED (v0.159.2+, inside a KEY-COLLATION-MERGE refusal, 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 a sync start leg. Neither restarting nor changing the target key converges. The remedy is a new backup full after the key change, then sluice sync from-backup run --reset-target-data --yes against the new chain (destructive), run by hand. A KEY-COLLATION-MERGE from sluice restore whose 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 a KEY-COLLATION-MERGE from 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 with sync 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 source DROP INDEX the 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 standing accept-target-only-unique-key list (the value the refusal prints), then reload. A SOURCE-UNIQUE-KEY-RECHECK-FAILED stop (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: for LIVE-ADDED-TABLE-EXCLUDED (the leg's exclude-table names a table schema add-table brought in), narrow the exclusion in the spec or run sluice schema add-table --abandon <table> --yes, then reload; for TABLE-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>; for COLD-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, run sluice 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.
Not in the list: the Postgres reader's mid-stream refusal that carries 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:

  1. Remove the leg from syncs.yaml and reload the fleet (restart it, or SIGHUP on 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.
  2. Run that stream once with sync start, with the leg's own source, target and flags plus the remedy flag, for example --restart-from-scratch or --accept-slot-acked-past-position=<LSN>. Stop it with sluice sync stop --stream-id <id> … --wait once it is running cleanly.
  3. Put the leg back in syncs.yaml and 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')"
A bad reload never takes the fleet down. The reload runs the exact same validators as the initial load before building anything; if the new file fails to parse or validate (a slot collision, a duplicate stream-id, a missing field), the reload is refused loudly and the running fleet keeps going on the old config, unchanged. Each reload logs its outcome — the started / stopped / restarted stream-ids, or "no changes." SIGHUP is POSIX-only; on Windows, restart the process to change the fleet.
Changing a leg's table scope on reload is a warm start (v0.163.0). Editing a leg's 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:

GaugeWhat it tells you
sluice_sync_lag_secondsSeconds the target trails the source's latest applied commit (engine-neutral apply lag; 0 when caught up).
sluice_seconds_since_last_applyWall-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_knownConstant 1 per tracked stream; count(sluice_stream_known) gives a stream-count alert.
sluice_apply_batch_size_current / _p95_secondsThe 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
The dashboard binds when the fleet starts; if the address can't be bound the command fails loudly rather than running a fleet without the dashboard you asked for. The TUI keeps the last-known fleet on screen with an "unreachable" banner if a poll fails, instead of blanking.

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:

ThresholdFires when…
--notify-sync-lag-secondssluice'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-secondsThe target's control-plane replica lag is at or above N. Requires PlanetScale telemetry.
--notify-storage-util / --notify-cpu-util / --notify-mem-utilTarget utilisation (0–1 fraction) is at or above the given level. Requires PlanetScale telemetry.
--notify-router-cpu-utilThe 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-minStorage 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-agePostgres-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 #