sluice

Continuous sync from a backup chain (the broker)

Replay a backup chain into a target as a long-running broker — no direct source↔target connectivity required.

The broker (sluice sync from-backup run) replicates by reading a backup chain instead of connecting to the source's CDC stream directly. One sluice process produces the chain from the source; another tails it and applies the changes to a target. The backup store — S3 / GCS / Azure Blob / local FS — is the message log between them. Reach for this when the source and target can't (or shouldn't) talk directly: an air-gapped target, cross-region DR where the chain already crosses the boundary, or fanning one chain out to several targets.

The broker trades latency and throughput for the decoupled-transport property. If your source and target can reach each other directly, sync start is lower-latency and higher-throughput — use it instead. The broker is for moderate volumes with decoupled transport.

1. Produce the chain #

On the source side, take a full backup to root the chain, then keep it fed with incrementals. On Postgres add --chain-slot to the full so it provisions the replication slot that anchors the chain (incrementals then chain with zero gap):

sluice backup full --source-driver postgres --source 'postgres://...source...' \
    --target s3://my-bucket/app-chain \
    --backup-endpoint https://<account>.r2.cloudflarestorage.com \
    --backup-region auto --backup-path-style \
    --chain-slot

Then feed it. Either run periodic incrementals from a scheduler:

sluice backup incremental --source-driver postgres --source 'postgres://...source...' \
    --target s3://my-bucket/app-chain \
    --backup-endpoint https://<account>.r2.cloudflarestorage.com \
    --backup-region auto --backup-path-style

…or run a continuous producer that commits rolling incrementals on a cadence (a long-lived process — run it under systemd / k8s). --rollover-window sets how often it commits an incremental; --retain-rotate-at-chain-length rotates into a fresh segment to keep segments compact for pruning:

sluice backup stream run --source-driver postgres --source 'postgres://...source...' \
    --target s3://my-bucket/app-chain \
    --backup-endpoint https://<account>.r2.cloudflarestorage.com \
    --backup-region auto --backup-path-style \
    --rollover-window 10s \
    --retain-rotate-at-chain-length 20
Rotation needs every table keyed (v0.157.0+). Restoring a rotated chain re-writes each later segment's full over rows already restored, which a table with no PRIMARY KEY and no NOT NULL UNIQUE index cannot take. With --retain-rotate-at*, a new chain therefore refuses to start while any table it backs up is keyless (SLUICE-E-BACKUP-ROTATED-KEYLESS-TABLE, exit 3), and an existing chain WARNs ROTATION-KEYLESS-TABLE and runs with rotation suspended. Key those tables on the source with a NOT NULL UNIQUE index, or drop the rotation flag. Details: rotated chains and keyless tables.
Stopping the producer under a supervisor (v0.156.12+). A window never ends inside a source transaction (on v0.156.12 through v0.159.2 a cancel could still commit one through a race, fixed in v0.159.3 — who should check), so stop the producer with sluice backup stream stop and wait for the process to exit — inside a transaction it reads on to the commit, for up to 60 s / 1,000,000 changes, before closing the rollover (or abandoning it if the budget runs out). Send SIGTERM only as the fallback: a signal inside a transaction abandons the in-flight window at once (exit 0, WARN BACKUP-WINDOW-ABANDONED-OPEN-TRANSACTION), and the next run re-reads it. One exception (v0.160.0+): a cancelled stream whose last flush fails on a schema-history refusal (such as KEY-COLLATION-SAME-ANCHOR) exits non-zero with that refusal instead; a store write that fails under the cancel keeps the WARN and exit 0. Under systemd, use ExecStop= running backup stream stop plus a wait on the process, with TimeoutStopSec above 60 s; under Kubernetes, a preStop hook doing the same, with terminationGracePeriodSeconds above 60 s. A supervisor that restarts the producer more often than the rollover window (default 5 minutes / 100,000 changes) means the chain never advances. Details: stopping a stream.

2. Replay it into the target #

On the consumer side, point the broker at the same chain. It reads the chain's catalog every --poll-interval, applies any incrementals newer than its persisted position in chain order, and persists progress in the target's sluice_cdc_state. The --stream-id is required so it can resume cleanly after a restart:

sluice sync from-backup run \
    --backup-target s3://my-bucket/app-chain \
    --backup-endpoint https://<account>.r2.cloudflarestorage.com \
    --backup-region auto --backup-path-style \
    --target-driver postgres --target 'postgres://...target...' \
    --stream-id app-broker \
    --apply-concurrency 4 \
    --poll-interval 10s
--apply-concurrency matters for cross-region targets. Each incremental's merged change stream is fanned across W in-order PK-hash lanes (same key → same lane → applied in source order), each committing concurrently on its own connection. Without it, a large incremental replayed into a high-latency target applies through a single RTT-bound stream and the broker falls behind. 0 (default) = auto:4; 1 = explicit serial; W>1 honored. Both modes persist a position inside the incremental at source-transaction boundaries (v0.157.0+); the lanes record only a boundary every lane has committed. An interrupted incremental resumes at the first source transaction not durably applied, exactly-once where the chain records apply identities (next section).

Replay safety: exactly-once resume, and when keyless tables are refused #

v0.157.0 — who should check. Upgrading does not repair rows already affected.
  • Anyone who re-ran an interrupted sync from-backup on v0.99.222 through v0.156.12 over a source that moves key values between rows (a transaction that frees a key and gives it to another row). The re-run re-applied the whole incremental, and could empty a table at exit 0. Compare the affected tables with the source (sluice verify --depth count or --depth sample); a count below the source's means a misapplied re-run. Rebuild with sync from-backup run --reset-target-data on this release.
  • Anyone who passed --at-chain-id to a broker whose target already held a position (v0.20.0 through v0.156.12), especially after re-restoring the target to an earlier link. The flag was ignored and the broker resumed from the recorded position, so incrementals between the asserted and the recorded link may be missing. Anyone who passed --reset-target-data over an existing position got a warm resume instead; treat that as the re-run above.
v0.156.11 — who should check. Upgrading stops new duplication and renumbering; it does not repair rows already affected.
  • sync from-backup brokers on v0.99.222 through v0.156.10 that were ever interrupted (an error, a crash, a supervisor restart, a SIGINT or SIGTERM, q/ctrl+c), over a chain carrying a table with no PRIMARY KEY and no NOT NULL UNIQUE index, or into a target table keyed only on a surrogate: the interrupted incremental was re-applied whole and its committed rows duplicated at exit 0. Run sluice verify --depth count against the backup's source database and the target; a target count above the source's on such a table is duplicated rows. After the upgrade the broker refuses those tables, so key them on the source and take a new full backup, or move them to sluice sync start — then rebuild or de-duplicate the target table.
  • Brokers on v0.20.0 through v0.99.221 that were ever interrupted mid-incremental: the interruption was recorded as applied and the rest of the incremental skipped, in any table. Run sluice verify --depth count and re-copy any table whose target count is below the source's.
  • Anyone who re-ran a restore onto the same target after a failed attempt, on any release through v0.156.10, where the backup carried a keyless table or the target table was keyed only on a surrogate. Compare those tables' row counts with the source.
  • Cold copies into a pre-created, surrogate-keyed target (MySQL family on v0.99.92+, Postgres on v0.99.111+) whose log contains hit a transient target error: the re-sent batch could land twice. Compare that table's row count with the source. Such a copy now refuses with SLUICE-E-COPY-RETRY-AMBIGUOUS-KEYLESS instead of retrying.
  • MySQL, MariaDB, Vitess and PlanetScale MySQL targets that received an AUTO_INCREMENT value of 0, on every release through v0.156.10 — a source row whose MySQL AUTO_INCREMENT column, or Postgres bigserial / identity column, held 0 landed under the next generated value at exit 0, on every write path into a MySQL-family target. On the source, list the rows with 0 in that column; if the target has no row with 0 there, find the row by its other columns and correct its key. A sharded keyspace whose column is filled by a vtgate sequence still replaces 0 after the upgrade, so avoid carrying 0 into one.

Resuming inside an incremental (v0.157.0+, ADR-0191). backup stream and backup incremental now record each change's apply identity (the source's transaction id and the change's ordinal in it) in the change chunk, and the incremental's manifest says so with apply_identity. The broker hands those identities to the same exactly-once apply marks sync start uses (sluice_cdc_apply_marks). Its position can also stand inside an incremental: in the same target transaction as the work, at each source-transaction boundary, it records the incremental's id, a digest of its chunk list and the event it has applied through. So when a run is interrupted partway through an incremental (an apply error, a chunk that failed to fetch, a crash, a SIGINT or SIGTERM, a supervisor restart), the next run re-reads that incremental, skips what is already applied and re-delivers from the first source transaction not durably applied. On serial apply that is the one in flight; on the lane apply mode it is the transactions whose lanes had not all committed. The marks then skip any re-delivered change that already landed, so the re-run is exactly-once, key changes and tables without a key included. Through v0.156.12 the broker re-applied the whole incremental instead.

Keyless tables. The broker judges each incremental before applying any of it, for the tables that incremental touches, and refuses with SLUICE-E-BROKER-KEYLESS-TABLE (exit 3) when a table has no key the replayed rows carry and the replay cannot be made exactly-once. The message says which of these applies: the incremental records no identities; a change to the table inside it carries none (BROKER-KEYLESS-NO-IDENTITY); or the target's apply marks cannot cover the table — unusable by the role (APPLY-MARKS-UNAVAILABLE), a PlanetScale Postgres (Neki) target, a target engine without apply marks such as SQLite, or a target keyed only on a column the rows do not carry. The check also runs at start for the incremental a broker is inside or about to start, so a restart learns of a refusal before anything is written, and on a --reset-target-data cold start before the drop. A mark table that becomes unusable after the check refuses the apply rather than letting it run without marks. After an upgrade from v0.156.12 or older mid-incremental, the next incremental may have been partly applied with no marks to skip it, so a keyless table in it refuses with BROKER-CLASSIC-RESUME in the reason (recovering from it); the incrementals after it are judged normally. The broker has no table filter, so every table in the chain is in scope. v0.156.11 and v0.156.12 refused every keyless table in the chain at start, whatever the incremental. Each table's key is judged twice, and the message says which judgment failed:

The remedy depends on the reason the message gives. For an incremental without identities, --reset-target-data restores the chain through its tail, and the broker then judges only the incrementals that follow — which an upgraded writer records with identities, so upgrade the producer (backup stream / backup incremental) as well as the broker. For the target's apply marks, make them usable (a role that may create and write sluice_cdc_apply_marks). Otherwise put a key on the source and take a new full backup, so the chain's recorded schema carries it (then start on the new chain with --reset-target-data, or --at-chain-id after restoring it yourself); for a table keyless only on the target, give the target table the source's key rather than a surrogate; or replicate those tables with sync start instead.

After an upgrade: BROKER-CLASSIC-RESUME (v0.157.1 hint). None of the remedies above applies to this reason: the chain may already be one this sluice wrote, and re-running the same command refuses again, because the old position is replaced only by applying the incremental it refuses. Recover with --reset-target-data. When the classic position is the only reason the incremental is refused, the hint also offers an operator's assertion: only if you know that no broker run started applying the named incremental after that position was written, delete the stream's row from sluice_cdc_state and run again with --at-chain-id=<the row's last_applied_backup_id>. A later run that refused the incremental — a v0.156.11 or v0.156.12 broker, say — is no evidence: an earlier, older run may have been interrupted inside it and left the same position. If the assertion is wrong, the rows that run committed are duplicated, silently; if you cannot vouch for it, use --reset-target-data. When the incremental also records no identities, carries a change without one, or the target's marks cannot cover the table, that route would only refuse again, and the hint does not offer it. A key added to the target table is not a recovery here: a table keyless on the source may hold duplicate rows, which the key would merge. (v0.157.0's hint for this reason was the general keyless one above, and did not mention --reset-target-data.)

Without identities, a key-changing transaction does not converge on a re-run. This applies only to an incremental that records no identities; on one that does, both shapes below converge. Re-applying the in-flight transaction converges for inserts, and for updates and deletes that keep each row's key. A transaction that changed a row's key value re-inserts a row the interrupted run had already moved, and the move then collides with the moved copy, so the re-run fails on a duplicate key (MySQL 1062 / Postgres 23505) on every attempt — in the lane apply mode, possibly after committing part of it. And when a key value was moved off one row and onto another inside that transaction, the re-run can apply a change to the wrong row with no error at all. If your source changes key values inside a transaction and the incremental records no identities, recover it with --reset-target-data, not by re-running.

Severed transactions in the chain (v0.156.12+) #

Separately from re-applying an interrupted incremental, a chain an older backup stream wrote can carry one source transaction across two incrementals — a window that ended inside a source transaction (shape A), or, on Postgres chains written before v0.138.0, a resume that re-delivered the previous window's last transaction (shape B) — or an incremental whose EndPosition is past its last stored change, the old cancel-drain loss (shape C). On every tick that brings new incrementals, before applying them, the broker runs the same severed-transaction check chain restore runs, over the whole chain:

Shape B is judged only when replaying into Postgres (it needs the source engine's position order). A current backup stream never writes a window that ends inside a source transaction — see who should check for chains written before v0.156.12, and this check for a stream cancelled on v0.156.12 through v0.159.2. Such a window is the case above: a broker that applied it while it was the chain's tip only WARNs CHAIN-APPLIED-SEVERED-TRANSACTION when the next link arrives, and applies the transaction again at exit 0 — a keyless row lands twice, and a key-reusing transaction is misapplied.

A window that changes a key column's collation is refused: KEY-COLLATION-HISTORY-UNORDERED (v0.159.2+). The broker replays a window's changes with no schema version between them, so it cannot tell which rows were written under which collation of a key column. Through v0.159.1 a window that changed a key's collation and changed it back, or loosened it (utf8mb4_bin → utf8mb4_0900_ai_ci), could replay with two source rows merged into one at exit 0 (v0.159.2 notice). The broker now refuses, before applying any of it, every window whose schema delta changes a key column's collation, whose recorded schema versions of a table disagree, or whose versions disagree with the segment's recorded schema. That includes a forward change it used to replay correctly, because it cannot tell a forward change from a reverted or loosened one. Since v0.160.0 it also refuses a window that records a Postgres key collation not established for its rows, which only a byte-for-byte comparison could guard and the broker's applier cannot make; the remedy is the same new full backup. The marker arrives inside a KEY-COLLATION-MERGE refusal and exits 1. Restarting refuses again, and changing the target key's collation does not help. Remedy: take a new backup full after the key change, then sluice sync from-backup run --reset-target-data --yes against the new chain (destructive). A VStream (Vitess/PlanetScale) window replays as before: a VStream schema version records no collation and is not read as a change.
Every incremental is checked for key-collation merges (v0.160.1+); on v0.159.0 through v0.160.0 none was (KEY-COLLATION-BROKER-UNARMED, HIGH, silent). The applier's KEY-COLLATION-MERGE check arms from the source's schema versions, and the broker replays incrementals as source events only, so through v0.160.0 its check stayed off for every incremental: a Postgres key replayed onto a MySQL-family _ci column, or a target key loosened by hand, could merge two source rows into one at broker exit 0 where live sync start refuses the same row. The broker now hands the applier each incremental's recorded schema before replaying it, in memory and never persisted; the refusal above makes that one shape sound for the whole window, because any window whose versions disagree with it on a key collation never reaches the applier. A broker that would have merged now refuses KEY-COLLATION-MERGE with the target untouched; give the target's key column a binary collation, as the message says, and restart. If you ran the broker on an affected release into such a target, check it with sluice verify on a quiesced pair; a plain --reset-target-data re-copy re-refuses until the target key has a binary collation (v0.160.1 notice).

3. Cold-start vs warm-resume #

On its first launch against a chain, the broker has no sluice_cdc_state row for the chosen --stream-id, so it doesn't know where in the chain to begin and refuses loudly. There are two ways past that, mutually exclusive:

Over an existing position (v0.157.0+). Both flags are now read even when the target already holds this stream's broker position; through v0.156.12 the warm resume ran first and both were ignored. --reset-target-data is honoured over any broker-owned position, whether or not it decodes (fully true since v0.157.1; see below): after its pre-drop checks it clears the stream's sluice_cdc_state row and its sluice_cdc_apply_marks rows, then drops and restores as on a fresh target, so a crash mid-reset leaves no position to resume onto a half-restored target. --at-chain-id asserts what a target with no position holds. Over an existing one it is accepted only when it names the link the position records as last fully applied, with nothing in progress; any other value refuses with BROKER-AT-CHAIN-ID-CONFLICT (exit 1) before anything is applied, and repeats on every restart until you drop the flag, use --reset-target-data, or clear the position. If you restored the target to an earlier link yourself, delete the stream's row from sluice_cdc_state and its rows from sluice_cdc_apply_marks first, then pass --at-chain-id. Who owns a row (v0.157.1+). A row is the broker's when its position token's _engine marker says so (backup-broker-v2, or the classic backup-broker), whether or not the rest of it decodes. A broker row damaged from outside — a hand edit, a bad restore of the control table; no broker writes one — refuses a plain run with BROKER-POSITION-CORRUPT (exit 1, uncoded), with nothing applied, naming --reset-target-data (which discards the row and rebuilds the target) and, if you know which link the target holds, deleting the stream's sluice_cdc_state row and running with --at-chain-id. Through v0.157.0 such a row was reported as owned by a non-broker writer, naming the target applier's engine rather than the row's, and --reset-target-data was refused over it — so v0.157.0's claim that the flag was honoured over a broker row "decodable or not" held only for a row that decoded but was semantically corrupt. A row with no broker marker belongs to another writer, such as a sync start stream, and is refused with or without either flag; the refusal now says what the token carries (its _engine marker, or that it has none). Cold starts and chain restore clear the stream's apply marks before applying anything, so a mark left by an earlier run under the same stream id cannot skip a change the fresh target never received.

Downgrading a broker is not in place (v0.157.0). The broker's position token is now backup-broker-v2. v0.156.12 and older read it as owned by a non-broker writer and refuse before they look at --at-chain-id. To downgrade, delete the stream's row from sluice_cdc_state and its rows from sluice_cdc_apply_marks (or choose a new --stream-id), then start the older binary with --at-chain-id=<the deleted row's last_applied_backup_id>. It re-applies any incremental the newer broker was inside whole, without marks, as it always did. Chains the new producer writes still restore on v0.156.12: the added aid chunk field and apply_identity manifest flag are ignored by older binaries.

The common case is the post-restore cold-start: bulk-copy the chain once with sluice restore, then launch the broker with --at-chain-id set to that restore's tail manifest. Pass the flag only on the first launch; every subsequent restart warm-resumes from sluice_cdc_state automatically and needs neither flag:

# first launch after a fresh restore
sluice sync from-backup run --backup-target s3://my-bucket/app-chain \
    --target-driver postgres --target 'postgres://...target...' \
    --stream-id app-broker --apply-concurrency 4 --poll-interval 10s \
    --at-chain-id 9b12b8ccdc3e7fa9725825ab032e6d6d41d3db09

# every restart after that — warm-resume, no recovery flag
sluice sync from-backup run --backup-target s3://my-bucket/app-chain \
    --target-driver postgres --target 'postgres://...target...' \
    --stream-id app-broker --apply-concurrency 4 --poll-interval 10s

4. Stopping cleanly #

Stop the broker by writing a stop signal to the chain destination — the running process observes it on its next tick and exits cleanly. Because the signal lives in the store, you can stop a broker from a different host without process access (both sides agree on the chain, not on the host):

sluice sync from-backup stop --backup-target s3://my-bucket/app-chain

Exit codes on stop and cancel (v0.156.11+). sluice sync from-backup stop is observed only between ticks, so it never interrupts an incremental and the broker exits 0; use it to stop a broker deliberately. A SIGINT or SIGTERM — or q/ctrl+c on the live panel — cancels immediately:

Through v0.156.10 both of the latter exited 0 and the panel printed "stopped." — supervisors that treat a cancel's exit status as success should expect the change.

Separately, a broker that reaches a severed-transaction finding on a link it has not applied yet exits 3 (v0.156.12+) with SLUICE-E-BACKUP-CHAIN-SEVERED-TRANSACTION or SLUICE-E-BACKUP-INCOMPLETE; restarting repeats it, so alert on it rather than counting on the retry. A finding on a link it already applied only WARNs CHAIN-APPLIED-SEVERED-TRANSACTION and the broker keeps running.

The broker follows segment-rotation seams automatically and resumes from its persisted position after a restart on either side, inside an interrupted incremental at the source transaction that was in flight (see replay safety). Two consumers must use distinct --stream-ids for distinct targets, or they'll race on position writes. To rest the chain encrypted, the broker accepts the same encryption flags as the rest of the backup family — see the backup reference.