Schema changes during a live sync
How sluice keeps a running sync online while the source schema evolves — what forwards automatically, what refuses loudly, and how to recover.
A source schema rarely stands still. Columns get added, types get widened, indexes come and go while a continuous sync is running. sluice does not manage those migrations for you — tools like Atlas, sqitch, Flyway, and liquibase do that — but it does keep the stream online through them. By default it forwards the operator's own committed DDL onto the target, so a routine ALTER TABLE no longer wedges the sync. This page covers what forwards automatically, the narrow set of changes that still refuse loudly, and the drained-migrate recovery when one does.
sync run leg restart or an automatic retry after a transient error it had seen nothing, so the table's first schema boundary — a change made while the stream was stopped, or after it restarted and before the table's next row — was cached as the new baseline and never applied to the target; the ADR-0091 seed guard skipped the same shape at the first boundary after a cold start. A type widen within its family left the target's narrow column in place, and every following value was rounded into it: DATETIME → DATETIME(6), DECIMAL(10,2) → DECIMAL(12,4), FLOAT → DOUBLE, or a Postgres timestamp or numeric precision. Measured on MySQL 8.0 in file/pos and GTID mode, MariaDB 11.4, Postgres 16 and vttestserver; a VARCHAR widen or an ADD COLUMN failed loudly at the next row instead. Separately, on a running Postgres-source stream, timestamp(0) → timestamp(6) (and the same for timestamptz, time and timetz) classified as no change and was never forwarded, so every later value was rounded to whole seconds (GC-44 F3). Each table's first boundary after a start is now checked against the target (RESUME-SCHEMA-DIVERGENCE), and streams that forward no DDL now check every boundary (SCHEMA-CHANGE-REFUSED). A drop (or a nullability, CHECK, index or rename change) that is the first schema change on a table after a cold start is not forwarded and is logged at WARN; a type change there is checked against the target and forwarded if genuine.
- Affected releases: v0.99.45 through v0.156.9 with forwarding on (the default); from v0.79.0 with the opt-in
--forward-schema-add-column; from v0.73.1 under--inject-shard-column(coordinated Shape A). The Postgres(0)→(6)case: v0.99.176 through v0.156.9. For streams that forward no DDL see below. - Who was exposed: a stream whose synced table had a column type changed while it was stopped, or between a restart and that table's next change (a
sync runleg restart and an automatic retry count), or right after its cold start. - Upgrading does not repair rows already rounded, and one check works only before the upgrade. Before upgrading, run
sluice schema diffand compare each target column's declared type with the source's; where the source's is wider, the rows written since the change hold rounded values, and the table needs a re-copy. If you already upgraded, the first start applied the difference itself, so a later diff is clean and proves nothing: grep that first post-upgrade start's log forforwarding the difference (target-witnessed, GC-44). Every hit whosedifference=names a type change marks a column whose earlier rows were rounded; re-copy that table. For the Postgres(0)→(6)case the target column is still(0): alter it, then re-copy the table or the affected rows.
backup verify (both depths) and a restore read a chain that is internally consistent, sluice verify --depth=sample reaches only a sample of rows (and before v0.158.1 hashed each side's columns in its own declared order, so it read a column swap as clean), and across engines verify compares row counts only (what verify cannot see). The fixes refuse where sluice cannot prove a shape, so some DDL that used to stream through now stops the stream: see COLUMN-ORDER-CHANGED, multi-column ALTERs and decoding rows behind the head.
- A MySQL/MariaDB binlog stream behind the head decoded earlier rows under the wrong column names (CRITICAL, Bug 301). The reader took a table's column list from
information_schema, which answers as of now, while decoding events recorded earlier. A change that kept the column count and every column's type family — a reorder of same-type columns, a column-name swap, a same-type drop-and-add, anENUMlabel reorder — decoded with values under the wrong names (or the other label). Affected: every release since v0.1.0, for everybackup incrementalandbackup streamwindow, every warm-resumedsync start, the CDC phase after a cold copy, and a live sync that lagged; at the head too, for a change issued from another default database (USE other; ALTER TABLE app.t …). From v0.113.0 cross-family and count-changing shapes refused loudly; same-family ones stayed silent through v0.157.2. What to do: setbinlog_row_metadata=FULLon the source first (MySQL:SET PERSIST binlog_row_metadata = 'FULL'; MariaDB:SET GLOBAL binlog_row_metadata = 'FULL'plusbinlog_row_metadata = FULLunder[mariadbd]), then re-snapshot each affected sync target (sync start --restart-from-scratch) and rebuild each affected chain from a newbackup full. - A MariaDB source on its default
binlog_row_metadata=NO_LOGstreamed UNSIGNED integers above the signed maximum as negative numbers (CRITICAL, MARIADB-NOLOG-UNSIGNED).NO_LOGrecords no signedness, and the decoder read such integers as signed:BIGINT UNSIGNED18446744073709551615 arrived as-1. A Postgres target stored the negative value; an UPDATE or DELETE keyed by such a value matched no row and was dropped. Affected: v0.99.271 through v0.157.2, onsync start's CDC phase,backup incremental,backup streamandsync from-backupapplying those chains. Not affected: MySQL 8.0/8.4, MariaDB underMINIMALorFULL, PlanetScale and Vitess, every cold copy andmigrate, andDECIMAL/FLOAT/DOUBLE/YEAR/BITcolumns. How to check a Postgres target:SELECT count(*) FROM t WHERE col < 0for each column the source declares UNSIGNED; any hit is proof. A lost update or delete leaves no trace on the target. What to do: re-snapshot the target (sync start --restart-from-scratch) and rebuild chains from a newbackup full. If you cannot upgrade yet,SET GLOBAL binlog_row_metadata = 'MINIMAL'(and in the option file) makes binlog written afterwards decode correctly on older releases; binlog already written is not fixed. - A column RENAME swap on the source left the target's existing rows under the wrong column names (CRITICAL, RENAME-SWAP-FORWARD).
RENAME COLUMN b TO c, RENAME COLUMN c TO b(or a rotation) reached the target as no change, measured on MySQL 8.0 and Postgres 16, so every pre-swap row the source never touched again stayed swapped. Affected: every release ofsync start(from v0.79.0 opt-in and v0.99.45 by default the forward path read the swap as no change),--inject-shard-columnsince v0.73.0, and chain restore andsync from-backupin every release that replays a schema delta; MySQL, MariaDB and Postgres sources alike. How to check: runsluice verify --depth=samplewith v0.158.1 or later: it compares columns by name, so a swapped target mismatches on every sampled row that predates the swap. v0.158.0 and older hashed each side's columns in its own declared order and read a swapped target as clean, so a clean result from them does not clear it. A sample samples: when the pre-swap rows are a small fraction of the table, raise--sample-rows-per-table, or compare the affected columns' values by primary key between source and target yourself. Re-copy the table where they differ. - Two or more columns changing type or nullability in one
ALTERwere forwarded as no change (HIGH, silent on a Postgres target). A Postgres target kept the old type and rounded every later value (numeric(10,2)kept for two columns widened tonumeric(12,4):1.2345landed as1.23); a MySQL target refused the first value that did not fit. Affected: the forward path from v0.99.45, and--inject-shard-columnsince v0.73.0. How to check: compare the target's column types with the source's for any table altered this way while a stream ran, and re-copy a table whose values were rounded.
COLUMN-IDENTITY-CHANGED and KEY-COLLATION-*.
- A MySQL/MariaDB schema change that left a column name holding a different column was invisible (CRITICAL, RENAME-SWAP-INVISIBLE).
DROP COLUMN c, ADD COLUMN cof the same type,RENAME COLUMN c TO tmp, ADD COLUMN c,DROP COLUMN b, RENAME COLUMN c TO b, or a rename swap whose order aMODIFY … AFTERrestored before the table's next row produced no schema boundary, so the target kept every existing row's old values under the reused name — at the head and on a warm resume, underMINIMALandFULLalike. Affected: not bisected; treat every release through v0.158.1 as affected, onsync start,backup incremental/backup streamchains andsync from-backup. How to check: runsluice verify --depth sampleon v0.159.0 (it compares columns by name since v0.158.1). A target that lacks a source column (thetmpshape) fails asCOLUMN-MISMATCHdeterministically; for the other shapes each sampled pre-change row whose reused column differs mismatches, so raise--sample-rows-per-tablewhere few rows predate the change. A clean sample is evidence, not proof. The default--depth countcannot see it. What to do: re-copy each affected table (sync start --reset-target-data --yes) and rebuild affected chains from a newbackup full. - A key column's collation or character-set change merged distinct source rows into one target row (HIGH, KEY-COLLATION-FIRST-BOUNDARY). After, for example,
MODIFY k … COLLATE utf8mb4_binon a key that wasutf8mb4_0900_ai_ci(or PAD SPACE → NO PAD, orutf8mb4→ascii_bin), a new row distinct only under the new collation ('A'beside'a','a 'beside'a') merged into the existing one: on a PRIMARY KEY the existing row vanished, on a UNIQUE column it was overwritten. It happened when the stream met the change as the first schema boundary of a run (a warm resume, or a live stream's first boundary after its cold start), on every--schema-changes=refusestream, in every chain restore, and on a Postgres source on every entry, becausepgoutputcarries no collation. Also with no schema change at all: a MySQL 8 → MariaDB stream whoseutf8mb4_0900_*key holds values differing only by trailing spaces, and a Postgres → MySQL-family stream with a UNIQUE key (beside the primary key) on the database default collation holding values differing only in case. Affected: not bisected; treat every release through v0.158.1 as affected. How to check: in the measured cases each merge left the target one row short, sosluice verifyon a quiesced stream reports a count mismatch. To find candidate collisions, group each key column on the source under the target's collation: on MySQL,SELECT k COLLATE utf8mb4_0900_ai_ci AS kk, COUNT(*) FROM t GROUP BY kk HAVING COUNT(*) > 1; on Postgres,GROUP BY k COLLATE "<target collation>", orlower(k)as an approximation for a case-insensitive MySQL target. What to do: re-copy each table that mismatches. - A
FULL-metadata row held to an earlier shape was not compared on collation (HIGH, DEFERRED-KEY-COLLATION). Underbinlog_row_metadata=FULL, a key's collation change folded behind a later retype or drop let a row merge on the target before the stream refused withRESUME-SCHEMA-DIVERGENCE, and that refusal's remedy left the merge in place. Affected: v0.158.0 and v0.158.1. What to do: check and re-copy as in the previous item.
KEY-COLLATION-*. One HIGH issue in chain restore was still open in this release (below); v0.159.2 fixed it for MySQL and MariaDB binlog sources (v0.159.2 notice).
- A copy that upserts merged two source rows whose keys the target's collation equates (HIGH, KEY-COLLATION-ROWWRITER). v0.159.0 guarded the CDC appliers, but the copy phase's idempotent writer still upserted unchecked. That writer serves
schema add-table, a resumed or chunk-resumed copy,--force-cold-start, the VStream COPY and restore of a rotated chain. Where the target key's collation equates two values the source keeps apart ('A'and'a'on a_cikey from a_binsource, or a Postgres default-collation key on theutf8mb4_0900_ai_cicolumn sluice itself creates on MySQL), the second row replaced the first at exit 0, on MySQL-family and Postgres targets alike. The retry arms of MySQL's two plain write cores, after a reparent or storage-grow transient, dropped such a row the same way. A fresh first-pass copy uses the plain bulk path and was already loud. Affected: the idempotent writer from v0.4.0, the batched retry arm from v0.99.92 and the LOAD DATA replay from v0.113.0, all through v0.159.0. - Two kinds of Postgres key went unchecked on a MySQL-family target (HIGH). A key on a nondeterministic (ICU) collation merged at CDC apply and on every copy that upserts. Every named Postgres key of a backup chain written before v0.159.0 went unchecked on a cross-engine restore into MySQL. Affected: through v0.159.0.
- A prefix key (
UNIQUE KEY uk (k(5))) was compared on the whole value (MEDIUM). The server matches such a key on the prefix, so'Apple-1'overwrote'apple-2'on a_citarget key from a_binsource. Affected: v0.159.0's guard. - A backup segment whose window changed a key column's collation restored with rows merged on an older binary (HIGH on a downgrade, Bug 304). Such a segment is now stamped
FormatVersion13, so every older binary refuses it. The fix is not retroactive: a chain v0.159.0 captured across a key-collation change carries no stamp. Affected: chains v0.159.0 captured that were restored with v0.158.1 or older; re-restore them with v0.159.1. - MySQL 8 → MariaDB created PAD SPACE collations, so a
KEY-COLLATION-MERGEre-copy failed (MEDIUM, loud, Bug 305). See the MariaDB guide.
How to check the silent items above: the target is a row short for every merge, so sluice verify (row counts) on a quiesced pair reports a count mismatch. To find candidate collisions, group each key column on the source under the target's collation, as in the v0.159.0 item (for a prefix key, group on LEFT(k, N)). What to do: re-copy each table that mismatches.
Open in v0.159.1:
- Chain restore can merge rows from a window that changed a key's collation and changed it back (HIGH, silent, KEY-COLLATION-REVERTED-WINDOW-RESTORE; fixed in v0.159.2 for MySQL and MariaDB binlog sources, see the v0.159.2 notice). Restore arms the guard from the window's last recorded shape, which equals the target's again, so a row written under the intermediate collation can land merged at exit 0. Measured on MySQL 8.0, for a live
backup streamwindow and for abackup incrementalrun made after the DDL. Asyncstream resumed across such a window is plausibly exposed too; that is unmeasured. Affected: v0.159.0 and v0.159.1. Until it is fixed: after any such DDL sequence on a key, take a freshbackup full, and verify any restore spanning that window against the source (sluice verifyrow counts, or the collision query above). - A drained
backup streamwindow that changed a key's collation cannot be restored (MEDIUM, loud, KEY-COLLATION-DRAIN-NO-DELTA). Abackup streamstopped mid-window writes no schema delta on its drain commit, so the collation change reaches the target only with the next segment. The drain segment's rows replay onto the old key, and every restore refusesKEY-COLLATION-MERGEat that segment. Nothing merges. The same lag affects any DDL in a drained window. - A VStream segment is never stamped 13 (LOW to MEDIUM, silent on a downgrade only, KEY-COLLATION-VSTREAM-UNSTAMPED). VStream (Vitess/PlanetScale) records no collation, so a VStream window that changed a key's collation and back stays readable by older binaries, which can merge. This release does not guard it either: no key-collation check arms for a VStream source.
ALTER in the window (Bug 306, fixed in v0.159.3), and these fixes protect only chains that recorded the evidence, not a backup incremental chain written by v0.159.1 or earlier across a window that changed a key's collation and changed it back (Bug 307). The fixes add refusals that change behaviour: see KEY-COLLATION-HISTORY-UNORDERED, COLLATION-CHANGE-UNANNOUNCED and COLLATION-NAMES-UNAVAILABLE.
- A key column whose collation was changed and changed back inside one backup window restored with two source rows merged (HIGH, silent, KEY-COLLATION-REVERTED-WINDOW-RESTORE). A key
utf8mb4_0900_ai_cichanged toutf8mb4_bin, a row'A'written beside an existing'a','A'moved out of the way, and the key changed back: the window ends with its original shape and carries no schema delta. Measured on MySQL 8.0 (MINIMALandFULLrow metadata, UNIQUE and primary keys): the target held'A'where the source holds'a', or with the key as the primary key row 1 was gone, at exit 0. Three paths reached it, each fixed differently. Chain restore (point-in-time restore included) now releases each recorded schema version just before the first change recorded at or after it, in the source engine's own position order, so the rows written under_binare guarded under_binand the restore refusesKEY-COLLATION-MERGEwith nothing of the colliding row applied.sync from-backupplaces no schema version between a window's changes, so it now refuses the window (KEY-COLLATION-HISTORY-UNORDERED). Abackup incrementalor a warm-resumedsync startreading the window after the revert used to record only the catalog's current_cishape; the MySQL and MariaDB binlog reader now decodes each row under the collation its row event recorded, so the schema boundary at the change carries_bin. Affected: v0.159.0 and v0.159.1, by code-reading at both tags; earlier releases have no key-collation guard and merge the same way. Corrected in v0.159.3 (Bug 307): the restore fix reaches only chains that recorded the intermediate shape. Abackup incrementalwritten by v0.159.1 or earlier that read the window after the revert recorded only the reverted shape and no schema delta, so v0.159.2 restores it, and replays it throughsync from-backup, with the rows merged at exit 0; no release can detect it (details). - A key column whose collation was loosened inside one backup window restored or replayed with two source rows merged (HIGH, silent, KEY-COLLATION-LOOSENED-WINDOW). A key
utf8mb4_binholding'a', a row'A'written beside it, moved out of the way, and the key loosened toutf8mb4_0900_ai_ci: the window's schema delta loosened the target key before any of its rows replayed, and nothing guarded the rows written before the change. Measured on MySQL 8.0 (MINIMALandFULL, UNIQUE and primary keys), at exit 0, by chain restore of abackup streamrollover window and of abackup incremental, bysync from-backupover a rollover window, and by a warm-resumedsync start. Chain restore now releases, ahead of such a window, the shape the previous link recorded for the table, so the restore refusesKEY-COLLATION-MERGE; its message says that restarting re-applies the loosening ahead of the same rows, and names the remedy, a fresh full backup taken after the key change.sync from-backuprefuses the window (KEY-COLLATION-HISTORY-UNORDERED). A warm-resumedsync startnow replays exactly. Affected: v0.159.0 and v0.159.1, and earlier releases, which have no key-collation guard. On v0.159.2 the chain-restore half armed only when a row of the table followed the loosening inside the window; with none it still restored merged at exit 0, withbackup verifypassing (Bug 306, fixed in v0.159.3). - Two binlog readers opened by one sluice process could register the same replica server id (loud, BINLOG-SERVER-ID-COLLISION). On MariaDB the second registration failed with
ERROR 4052 (HY000): A slave with the same server_uuid/server_id is already connected, ending that stream loudly, for example one leg of several opened together against one MariaDB source. On MySQL 8 the collision was harmless, measured: both readers kept receiving every row. No silent stall was observed on either engine. The id is now a per-process base plus a process-wide sequence, at or above 65536, and checked at stream start against the source's own@@server_id, taking a fresh one on a match; every binlog stream start runs that oneSELECT @@server_id. A sibling replica's id is not visible to sluice and is not checked. There is no server-id option to configure. Affected: the id scheme dates from v0.1.0 and ran through v0.159.1; the MariaDB failure is reachable from v0.99.271, the first release with MariaDB CDC. The collision was measured on Windows only; on other platforms a finer clock makes it less likely, unmeasured. Nothing to repair: the failure was loud.
The recorded-collation fix needs a row event that records collations: binlog_row_metadata MINIMAL (MySQL's default) or FULL. MariaDB's default NO_LOG records none, so on such a source nothing is stamped: chain restore's version ordering and window-start seeding and the sync from-backup refusal still apply, but a backup incremental or warm-resumed sync start reading a reverted window behind the head has no recorded collation to carry. That case is not measured; set binlog_row_metadata=MINIMAL or FULL on a MariaDB source to get the fix. A MySQL source restored onto Postgres lands byte-exact, the Postgres key being case-sensitive.
How to check a target restored, replayed by sync from-backup, or warm-resumed across such a window: sluice verify (row counts) on a quiesced pair reports a mismatch for every merge; to find candidates directly, group the key on the source under the collation the target key uses, SELECT k COLLATE utf8mb4_0900_ai_ci AS kk, COUNT(*) FROM t GROUP BY kk HAVING COUNT(*) > 1. What to do: re-copy each table that mismatches. For a chain that spans such a window, take a fresh backup full after the key change and restore from it.
Open in v0.159.2:
- A MySQL or MariaDB window that loosened a key, with no row of the table after the
ALTER, still restored merged (HIGH, silent, Bug 306; fixed in v0.159.3). See the v0.159.3 notice. - A Postgres window observed live can still restore merged (HIGH, silent, timing-dependent, KEY-COLLATION-PG-SAME-ANCHOR). A Postgres window observed live by
backup stream, with a row of the table written before a key-collation change, can still restore merged. Every Postgres schema version is anchored at the same position, so a window keeps only the shape its first Relation carried; when the reader keeps up that is the pre-change shape, the restore arms nothing for the rows written under the stricter collation, and with the key as the primary key target row 1 is gone at exit 0 (measured on Postgres 16, 1 of 3 runs; when the reader lags, the restore refuses instead). Fixed in v0.160.0: see the v0.160.0 notice. - The Postgres twin of both fixes, read after the change (HIGH, silent, KEY-COLLATION-PG-LOOSENED-REPLAY). A Postgres key tightened (
ci→"C"), given a row distinct only under the tighter collation, that row moved or deleted, and the key loosened back, replayed by abackup incrementalor a lagging stream started after the change, replays under the loose collation, becausepgoutputcarries no collation and the reader stamps the catalog's current one. Measured on Postgres 16 restored onto Postgres with the primary key on the column: target row 1 gone, exit 0. Regraded from MEDIUM. No cheap fix exists: it needs collation history the stream does not carry. Fixed in v0.160.0 from the column's catalog row instead: see the v0.160.0 notice. - VStream, trigger-CDC and Postgres change streams record no collation. VStream events carry none, so no key-collation check arms for a VStream source and a VStream window that changes a key's collation is not guarded on restore. The trigger-CDC sources (pgtrigger, sqlite-trigger, d1-trigger) record no schema versions, so a key collation changed and changed back inside one of their windows leaves nothing to arm the restore guard from (unmeasured). Wider than this said: with no schema versions the guard is unarmed for every change, not only across such a window (KEY-COLLATION-TRIGGER-CDC-NO-HISTORY, still open in v0.160.1; fixed for
pgtriggerin v0.161.0, whilesqlite-triggerandd1-triggerremain open as KEY-COLLATION-SQLITE-SOURCE-UNSTAMPED). Postgres is covered by the two items above. - KEY-COLLATION-DRAIN-NO-DELTA (MEDIUM, loud; unchanged). A
backup streamstopped mid-window whose window changed a key's collation produces a correct chain that chain restore refuses (KEY-COLLATION-MERGE) at that segment on every run (details). Nothing merges.sync from-backuprefuses it withKEY-COLLATION-HISTORY-UNORDERED. - KEY-COLLATION-VSTREAM-UNSTAMPED (LOW to MEDIUM, silent on a downgrade only; unchanged). A VStream segment is never stamped from its window's history, because VStream records no collation, so a VStream window that changed a key's collation and back stays readable by older binaries, which can merge (details).
Postgres-source backups, until fixed: after any change to a key column's collation, take a fresh backup full, and verify any restore spanning that window against the source (sluice verify row counts, or the collision query above with GROUP BY lower(k) as an approximation).
backup stream could commit a window ending inside a source transaction, and v0.159.2's loosened-window fix missed a window with no trailing row (both HIGH, silent). Upgrading stops new severed windows and new merges, but does not repair a chain already written or a target already restored or replayed from one. The fixes change behaviour: see KEY-COLLATION-VERSION-NAMESPACE and the rewritten chain-restore KEY-COLLATION-MERGE remedy.
- A cancelled
backup streamcould commit a window that ends inside a source transaction (HIGH, silent). A cancel (Ctrl-C, SIGTERM) ends the window's context and, through the same context, closes the CDC reader's change channel, and the capture loop could observe either first. If it saw the closed channel, it tried to store the window's last change chunk under the already-cancelled context: the store refused the write, nothing marked the window abandoned, and the drain committed every chunk stored before the cancel. Change chunks roll on a change count (--chunk-size, 100,000 by default) or 64 MiB, not at transaction boundaries, so when the window's last transaction spanned a chunk roll, the committed incremental held that transaction's head and ended inside it. The pgtrigger and sqlite-trigger readers, and a failed Postgres standby-status send, record the cancel as their own error, which reached the same commit by a second path. The next window re-delivers the whole transaction. On a chain already written,sluice restorerefuses before applying anything withSLUICE-E-BACKUP-CHAIN-SEVERED-TRANSACTIONonce a later incremental exists. If the severed incremental is the chain's last link, the restore only logsCHAIN-TAIL-OPEN-TRANSACTIONand applies the transaction's head, a state the source never had. Async from-backupbroker that applied the severed incremental 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. When the window's last transaction did not span a roll, the same race committed nothing of the window instead, and the next run re-read it, so nothing was lost. By code-reading, the sources that can produce a severed transaction are those whose change stream carries transaction boundaries: MySQL and MariaDB binlog, Vitess VStream and Postgres. The trigger-CDC sources (pgtrigger, sqlite-trigger, d1-trigger) carry no transaction markers and cannot. Now both arms take the same drain, and a backstop abandons the window whenever the capture ends on a context-class error with changes it read still unstored; the abandoned window is logged at WARN asBACKUP-WINDOW-ABANDONED-OPEN-TRANSACTION, and the next run re-reads it from the parent's end. Since v0.160.0 a last flush that fails on a schema-history refusal is not abandoned this way: the stream exits non-zero with the refusal (v0.160.0 notice). A window cut inside a transaction is still abandoned, as before. Affected: v0.156.12 through v0.159.2 (v0.156.12 introduced the drain-commit this arm reaches). Severed windows written by v0.19.0 through v0.156.11 are the earlier class (who should check). How to check, if you ranbackup streamon those releases on a MySQL, MariaDB, Vitess or Postgres source and stopped it by cancelling it (Ctrl-C, SIGTERM, a supervisor stop): run a chain restore with v0.159.3 and look forSLUICE-E-BACKUP-CHAIN-SEVERED-TRANSACTION(a refusal) orCHAIN-TAIL-OPEN-TRANSACTION(a WARN on the final link) in its output. For async from-backupbroker fed by such a chain, grep its log forCHAIN-APPLIED-SEVERED-TRANSACTIONand compare its target with the source usingsluice verify --depth sample; a keyless table holding more rows than the source is the duplicated transaction. What to do: a chain that refuses or warns needs a freshbackup full; re-copy any broker or restore target that differs from the source (details). - A key column loosened inside one backup window still restored with two source rows merged when no row of the table followed the loosening in that window (HIGH, silent, Bug 306). v0.159.2's KEY-COLLATION-LOOSENED-WINDOW fix released, ahead of the window, the key shape the previous link recorded (
utf8mb4_bin), so that the rows written under it would be guarded. A MySQL or MariaDB full manifest records its tables with no namespace, while every row the binlog recorded names its database, and the window-start version borrowed the database name only from a version the window itself recorded of the same table. A window whose last event for the table is the looseningALTERrecords none, so the version was filed underkdwhile the rows looked up<db>.kd, the guard never armed, and'A'replayed onto'a'through the loosened key. Measured on MySQL 8.4 underMINIMALandFULLrow metadata, with PRIMARY and UNIQUE keys, restoring both abackup incrementalwindow and abackup streamrollover window:restoreexited 0 andbackup verifypassed. A version that names no namespace now takes the namespace the window's first row of the table names, and a contradicting row refusesKEY-COLLATION-VERSION-NAMESPACE. Such a window now refusesKEY-COLLATION-MERGEwhere v0.159.2 merged; restarting refuses again, and the remedy is a fresh full backup taken after the key change.sync from-backupwas not affected, because it refuses such a window (KEY-COLLATION-HISTORY-UNORDERED) from the delta alone. Postgres-source chains are not affected (a Postgres manifest namespublic). Affected: v0.159.2 (measured); v0.159.0 and v0.159.1 merge the same window for the reason the v0.159.2 notice gave, and earlier releases have no key-collation guard at all. How to check: on a quiesced pair,sluice verify(row counts) reports a mismatch for every merge; to find candidates directly,SELECT k COLLATE utf8mb4_0900_ai_ci AS kk, COUNT(*) FROM t GROUP BY kk HAVING COUNT(*) > 1on the source, using the collation the target key uses. What to do: re-copy each table that mismatches; for a chain that spans such a window, take a freshbackup fullafter the key change and restore from it. - A chain restore's
KEY-COLLATION-MERGErefusal now leads with a remedyrestorecan run (LOW, loud, Bug 308). It used to reuse the stream applier's remedy (give the target key the source's collation and restart, or re-copy with--restart-from-scratch), butrestorehas no such flag and does not resume; a re-run after altering the key starts over from the full backup and fails withLOAD-DATA-ROWS-SKIPPED(measured on MySQL 8.4). The remedy now depends on the command and the key (the four cases). The applier's own remedy, whichsync startandsync from-backup runstill show, now names the re-copy for each:sluice sync start --restart-from-scratch, orsluice sync from-backup run --reset-target-data --yesfor a backup replay. Anything matching on the old remedy wording sees different text; the marker and error code are unchanged. Affected: v0.159.0 through v0.159.2, by code-reading. Nothing was lost: the refusal was loud. - Correction: the v0.159.2 notes overstated which existing chains v0.159.2 protects (Bug 307). They said a chain spanning a key-collation window now refuses
KEY-COLLATION-MERGErather than merging; that holds only for chains that recorded the evidence. For a MySQL or MariaDB source, by lane: a window that changed a key's collation and changed it back, captured bybackup incrementalon v0.159.1 or earlier, is not detected by any release — the incremental read the window after the revert and recorded only the catalog's reverted shape, with no schema delta, so v0.159.2 restores it, and replays it throughsync from-backup, with the rows merged at exit 0 (measured on MySQL 8.4,MINIMALandFULL, PRIMARY and UNIQUE key; by code-reading, the same holds for abackup streamwindow whose reader was behind the source when it read past the revert, and for a collation change made withsql_log_bin=0in any chain written before v0.159.2). The same window captured live bybackup stream, with the reader keeping up, is detected: its schema history recorded the intermediate shape, and v0.159.2 refuses. A window that loosened a key, written by v0.159.0 or v0.159.1 on either lane, is detected from its schema delta — by v0.159.2 only when a row of the table follows the loosening (Bug 306), by v0.159.3 either way. Such a window written by v0.158.x or earlier records no collation change at all; by code-reading its restore keeps the target key at the stricter collation, so nothing merges, but the target's key then compares values differently from the source's. Remedy for a chain that cannot be checked: take a freshbackup fulland use it as the base for every restore from now on; check any table already restored from such a chain against the source and re-copy it where they differ, withsluice verifyon a quiesced pair or the collision querySELECT k COLLATE <the target key's collation> AS kk, COUNT(*) FROM t GROUP BY kk HAVING COUNT(*) > 1on the source. - A MySQL CDC stream no longer dies on a busy source with
look-ahead SHOW BINLOG EVENTS … Error 1220 … binlog truncated in the middle of event; consider out of disk(loud, transient, nothing lost). The schema look-ahead runsSHOW BINLOG EVENTS … LIMIT 5000when the reader is behind the head and has to prove a table's catalog shape. On the file the server is still writing, the row-count limit carries the read past the position it needs into the live tail, where a transaction being flushed can be only partly on disk, and MySQL fails the whole statement with error 1220. Measured on mysql:8.4 and 8.0 under eight concurrent writers: about one read in a hundred tore. The look-ahead now reads a page that tore at the active file's tail as one that ended where the server stopped; it is complete when it covers the position it needed and is re-read like any short page when it does not, so it never marks a byte it did not read as checked. It still refusesSLUICE-E-CDC-BINLOG-LOOKAHEAD-SHORTwhen it cannot get past the tear, now carrying the server's 1220. MariaDB (whole events only) and Vitess (VStream, no look-ahead) were not affected. Affected: v0.158.0 through v0.159.2 (sync start,backup stream,backup incremental). If your stream stopped this way, upgrade and restart it.
Open in v0.159.3:
- KEY-COLLATION-PG-SAME-ANCHOR (HIGH, silent, timing-dependent; fixed in v0.160.0). A Postgres window observed live by
backup stream, in which a row of the table was written before a key-collation change, can still restore merged. See the v0.160.0 notice. - KEY-COLLATION-PG-LOOSENED-REPLAY (HIGH, silent; fixed in v0.160.0). A Postgres key tightened and loosened back, read after the change by a
backup incrementalor a lagging stream, replays under the loose collation. See the v0.160.0 notice. - KEY-COLLATION-ROLLOVER-SPLIT-DELTA-DUP (LOW to MEDIUM, loud, timing-dependent). When a
backup streamrollover splits a reverted key-collation window so that the revert lands in a later window than the row it made legal, chain restore applies that window's delta before its rows, and the target's ownALTERfails withError 1062 Duplicate entry. Nothing merges, but the failure carries no named refusal and no remedy. Seen once in four runs under load; by code-reading, v0.159.2 does the same. Since v0.160.2 the failure is reported asKEY-COLLATION-MERGE, markedKEY-COLLATION-DELTA-FAILED, keeping the target’s error and naming a remedy; applying such a change at the end of the incremental instead, so the restore converges, is still open. - VStream, trigger-CDC and Postgres change streams record no collation. No key-collation check arms for a VStream source, and the trigger-CDC sources record no schema versions (unmeasured), so a
pgtriggerlive sync or chain restore into a MySQL-family target applies every change unchecked (KEY-COLLATION-TRIGGER-CDC-NO-HISTORY, still open in v0.160.1; fixed forpgtriggerin v0.161.0, see the v0.161.0 notice); Postgres is covered by the first two entries, both fixed in v0.160.0. - KEY-COLLATION-DRAIN-NO-DELTA (MEDIUM, loud; unchanged). A
backup streamstopped mid-window, in a window that changed a key's collation, produces a correct chain that chain restore refuses (KEY-COLLATION-MERGE) at that segment on every run. Nothing merges (details). - KEY-COLLATION-VSTREAM-UNSTAMPED (LOW to MEDIUM, silent on a downgrade only; unchanged). A VStream window that changed a key's collation and changed it back stays readable by older binaries, which can merge (details).
Postgres-source backups, until fixed: after any change to a key column's collation, take a fresh backup full, and verify any restore spanning that window against the source (sluice verify row counts, or the collision query with GROUP BY lower(k) as an approximation).
KEY-COLLATION-MERGE and the new capture-time marker KEY-COLLATION-SAME-ANCHOR. A segment that records a key collation not established for its rows is stamped FormatVersion 14, so v0.159.x refuses it instead of merging; restoring a chain that contains one needs v0.160.0 or later.
- A Postgres key whose collation was tightened and loosened back no longer restores or applies with two source rows merged (HIGH, silent, KEY-COLLATION-PG-LOOSENED-REPLAY). pgoutput carries no collation, so the reader reads a column's collation from the catalog when it meets the table's relation message, which gives the collation in force now. Take a nondeterministic key, tighten it (
ci→"C"), write'A'beside'a', move or delete it, and loosen the key back toci: a reader replaying that sequence after the loosening readcifor every row of it, so the guard took'A'for a re-delivery of'a', the upsert overwrote'a', and the replayed UPDATE or DELETE took the merged row away. Measured on Postgres 16 through abackup incrementalstarted after the revert and chain-restored onto Postgres: row 1 was gone at exit 0. This is wider than first filed: when the tighten, the write and the loosen shared one transaction, a live Postgres → Postgressync startmerged too, at every entry (cold start, live, warm resume and--schema-changes=refuse), because pgoutput decodes a transaction at its commit, when the catalog already saysci. The fix uses the column'spg_attributerow, which altering the column rewrites: when that row'sxminis newer than, or equal to, the transaction being decoded and the collation is nondeterministic, the reader records it as not established, and both appliers compare that key byte for byte, so a merge refusesKEY-COLLATION-MERGEwith nothing applied. The Postgres applier's guard used to stay unarmed whenever the source's collation name matched the target's; it now compares a not-established collation as bytes, as the MySQL applier's guard already did. In a backup window, a later not-established version of a table weakens the version the window keeps, so the whole window compares that key as bytes, andsync from-backuprefuses such a window withKEY-COLLATION-HISTORY-UNORDERED. Affected: v0.159.0 through v0.159.3, every release with the Postgres key-collation guard. - A Postgres
backup streamorbackup incrementalwindow that sees a key column's collation change after a row of the table was written is now refused at capture instead of restoring merged (HIGH, silent, timing-dependent, KEY-COLLATION-PG-SAME-ANCHOR). A Postgres change stream anchors every schema version at LSN 0/0, and a backup keeps one version per table and position, so a window that wrote a row of the table, changed the key's collation (ci→"C"), wrote'A'beside'a'and changed it back kept only the first shape,ci. The restore then armed nothing for the rows written under"C", and row 1 was gone at exit 0 (measured on Postgres 16 in 1 run of 3, when the reader kept up; when it lagged, the restore refused). The capture now refuses withKEY-COLLATION-SAME-ANCHORwhen two shapes of a table recorded at one position disagree on a key column's collation (12 of 12 runs). The remedy is a fresh full backup taken after the change, with the chain continued from it. The cost: a forward-only key collation change, in a window that already holds a row of the table, is refused too, where it used to restore correctly. Affected: v0.159.0 through v0.159.3. - A cancelled
backup streamno longer turns a schema-history refusal into an exit-0 WARN. When a stream was cancelled, the drain turned any failure of the window's last flush into aBACKUP-WINDOW-ABANDONED-OPEN-TRANSACTIONWARN and exited 0. That is right for a store write that fails under a cancelled context, which the next run's re-read may not repeat, but it hid a refusal behind a clean exit and a misleading headline. A refusal from the window's schema history (such asKEY-COLLATION-SAME-ANCHOR) is a verdict on what the window recorded, not a transport failure, so the stream now exits non-zero with that refusal. Store-write failures under the cancel keep the WARN and exit 0. - A MySQL or MariaDB stream behind the binlog head no longer refuses every table because of an unrelated statement that starts with a session
SET(LOW, loud). The schema look-ahead treated a statement it could not parse as reaching every table, and a binlog text such asset foreign_key_checks=0; CREATE TABLE IF NOT EXISTS ...is two statements, so one such statement anywhere on the server made every stream behind the head refuseSLUICE-E-CDC-SCHEMA-REPLAY-MISMATCHon every retry (measured on MariaDB 11.4, where a sync target sharing the source's server created sluice's own control table that way). A leading run of plain session assignments, with no quotes, parentheses or comments before its;, is now skipped and the statement after it classified. Affected: v0.158.0 through v0.159.3. Nothing was lost; upgrade and restart. - A serial-apply refusal no longer logs a remedy the running command cannot carry out (LOW, loud, Bug 309). With
--apply-concurrency=1, the batch-rollback WARN (batch rollback on position-write errororbatch rollback on error) carried the applier's raw error, so for a chain restore'sKEY-COLLATION-MERGEit repeated the stream remedy ahead of the correct onerestorethen printed. The WARN now keeps a transient error's text, which is retried and has no other record, and saysa terminal refusal, reported by the callerin place of a terminal refusal's text. The returned refusal was right on every path, and the default lane path never logged the WARN. Anything matching on that WARN's error text will see less. Affected: v0.159.3.
Who needs to act:
- Postgres-source backup operators whose primary-key or UNIQUE-key columns use a nondeterministic collation, and who changed such a key's collation (especially tightened it and later loosened it back) on v0.159.0 through v0.159.3: a restore through a window spanning that change may hold one row where the source holds two, at exit 0. Check: on a quiesced pair, run
sluice verify(row counts); every merge shows as a mismatch. To find candidates directly, run the collision query on the source, grouping the key under the collation the target key uses (GROUP BY lower(k)is an approximation for a case-insensitive key). Action: re-copy any table that differs, and take a freshbackup fullafter the key change as the base for future restores. - Postgres → Postgres
sync startoperators who changed a key's collation and changed it back inside one transaction on v0.159.0 through v0.159.3: the live target may have merged rows. Check withsluice verifyas above and re-copy (sync start --restart-from-scratch) any table that mismatches. - Anyone running more than one sluice version over the same backup chain (for example a v0.160.0 writer with a v0.159.x restore host): once a 14 segment exists, v0.159.x refuses the chain. Upgrade every binary that reads the chain to v0.160.0 or later.
- Postgres
backup streamandbackup incrementaloperators who change key collations on live tables: expectKEY-COLLATION-SAME-ANCHORat capture, and take a freshbackup fullafter the change when it fires.
Still open in v0.160.0: KEY-COLLATION-ROLLOVER-SPLIT-DELTA-DUP (loud: a rollover-split reverted window can fail chain restore with the target's own Error 1062 Duplicate entry; nothing merges), KEY-COLLATION-DRAIN-NO-DELTA and KEY-COLLATION-VSTREAM-UNSTAMPED, unchanged from the v0.159.3 notice. VStream and trigger-CDC sources still record no collation (for trigger-CDC that leaves the guard unarmed on every change, see KEY-COLLATION-TRIGGER-CDC-NO-HISTORY; fixed for postgres-trigger in v0.161.0, see the v0.161.0 notice), and sync from-backup applied every incremental unguarded until v0.160.1 (v0.160.1 notice). A Postgres DDL whose xid was assigned before, but which committed after, the replayed transaction, and a table or key column renamed or dropped after the rows, stay outside the not-established check (not covered). A chain written before v0.160.0 kept no trace of either case — a dropped same-anchor shape, or a key loosened back before the capture read its rows — so its restore on v0.160.0 cannot refuse and still merges at exit 0 (measured by the v0.160.0 regression cycle): take a fresh backup full after such a change and check targets already restored with sluice verify.
sync from-backup applied every incremental with the key-collation guard unarmed (HIGH, silent, KEY-COLLATION-BROKER-UNARMED). Upgrading stops new merges, but does not repair a target the broker already merged. The applier's KEY-COLLATION-MERGE check arms from the source's schema, which reaches it as schema versions in the change stream. A live sync start and a chain restore deliver those versions; the broker replays a chain's incrementals as source events only, so its check had nothing to arm from and stayed off for every incremental, not only one that changed a collation (the backlog had filed it as a narrow window case). Measured on Postgres 16 → MySQL 8.0, with a UNIQUE key beside the primary key on the Postgres default collation (case-sensitive), which MySQL creates under utf8mb4_0900_ai_ci: an incremental inserting 'A' beside an existing 'a' overwrote row 1's key and dropped the new row, 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, held in memory and never persisted. That shape is sound for the whole window because the broker already refuses any window whose recorded versions disagree with it on a key collation (KEY-COLLATION-HISTORY-UNORDERED). A MySQL-family backup records its tables without a database name while its rows name one, so a seeded shape with no database name answers for its table under any (the Bug 306 trap). 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 the broker. Affected: v0.159.0 through v0.160.0. v0.159.0 introduced the key-collation guard; earlier releases have no guard on any path. Not affected: live sync start and chain restore (they already armed the guard), and broker pairings whose target key compares as the source's does.
Who needs to act:
- Anyone who ran
sync from-backupon v0.159.0 through v0.160.0 into a MySQL-family target from a Postgres source, or into a target whose key columns were loosened by hand. The target may hold one row where the source holds two, at broker exit 0. Check: on a quiesced pair, runsluice verify(row counts); every merge shows as a mismatch. Action: upgrading does not repair rows that already merged. A merged table needs a target key whose collation keeps the two values apart (a binary collation such asutf8mb4_bin, which the refusal message names) before it can hold both rows again, and then its rows copied from the source. A plain--reset-target-datare-copy is not enough on its own: it recreates the key under the target's default collation, so the copy refusesKEY-COLLATION-MERGEagain rather than merge. pgtrigger-source operators syncing or chain-restoring into a MySQL-family target: still unguarded in the same way (KEY-COLLATION-TRIGGER-CDC-NO-HISTORY, open in v0.160.1; fixed in v0.161.0, see the v0.161.0 notice). Check such targets withsluice verify.
both, leading or trailing inside TRIM(…) changes meaning on a Postgres target (HIGH, silent, Bug 311; fixed in v0.160.2). v0.160.1's GitHub #356 fix kept those three words bare anywhere inside TRIM, so where a MySQL or MariaDB source used such a column as TRIM's remove-string (MySQL stores trim(`both` from `code`)), the Postgres target got TRIM(BOTH FROM code), which trims spaces. A generated column computed different values (measured: bbabb and bab where the source computed a and bab ) through migrate, sync start and later CDC rows, and a CHECK over the same shape became a different constraint, all at exit 0. Affected: v0.160.1 only; MySQL or MariaDB source → Postgres target; a generated column or CHECK that uses a column named both, leading or trailing inside TRIM. Check: compare the target's generated values with the source's; sluice verify --depth count does not see this, because the row counts match. Action: upgrade to v0.160.2, then re-copy the affected tables (--reset-target-data --yes for a sync start stream); a stored generated column recomputes only when its row is written.KEY-COLLATION-PRE0159-2-UNVERIFIABLE(WARN, MEDIUM; Bug 307). A MySQL or MariaDB incremental captured before v0.159.2 recorded a key's collation only as of its window's end, so a key changed and changed back inside the window left no evidence, and every release restores it with the two source rows merged at exit 0. No release can refuse it, because nothing in the chain says it happened. Chain restore,sync from-backupandbackup verifynow log this WARN for every such incremental that records a character column in a primary or unique key, naming the table and its key columns. It is a warning; nothing refuses. Action: runsluice verifyagainst the source for those tables, and take a fresh full backup with v0.159.2 or later.KEY-COLLATION-DELTA-FAILED(LOW to MEDIUM, loud). Chain restore applies an incremental's schema changes ahead of its rows, so a later incremental that loosens a key back can reach the target while two values the source had already reconciled are both still there, and MySQL refuses theALTERwithError 1062 Duplicate entry(an hourlybackup incrementalsplits a change from its revert this way by construction; KEY-COLLATION-ROLLOVER-SPLIT-DELTA-DUP). Nothing merges. The raw error is nowKEY-COLLATION-MERGE, markedKEY-COLLATION-DELTA-FAILED, keeping the target's error and giving the remedy the other refusals of such a window give: take a fresh full backup after the key change and restore it into an empty target.- A Postgres-target CDC
KEY-COLLATION-MERGEnames the table and a remedy that can work (LOW, loud). On the pipelined batch apply path, the default, the refusal named no table; on the serial path it said to give the target's key the source's collation, which cannot converge for a key compared byte for byte because its collation was not established for the replayed rows. Both paths now name the table and the collation the key was compared under ("k" COLLATE "C"), with theALTER TABLEand the re-copy forsync startandsync from-backup; where the target already has the source's collation, the remedy offers the re-copy alone.
- A
pgtriggersource now arms the key-collation guard on a MySQL-family target (HIGH, silent, KEY-COLLATION-TRIGGER-CDC-NO-HISTORY). The guard learns the source key's collation from the schema version the change stream carries; the pgoutput reader emits one at every table's first change of a stream, and the trigger-CDC reader emitted none. A Postgres UNIQUE key beside the primary key on a case-sensitive collation (the database default, or"C") is created on MySQL underutf8mb4_0900_ai_ci, and an insert of'A'beside'a'became anON DUPLICATE KEY UPDATEonto the'a'row, with no DDL anywhere (measured on Postgres 16 → MySQL 8 for livesync start, cold start and warm resume, and for a chain restore of abackup incrementaland of abackup streamwindow). The reader now precedes each table's first change of a session with a schema version read from the source catalog, stamping each column's collation as the pgoutput reader does; it flows into the target's schema history and a backup's schema history like a native Postgres version (see the first-touch boundary for what that changes).sync from-backupwas already guarded, arming from each segment's recorded schema since v0.160.1 (KEY-COLLATION-BROKER-UNARMED). Cost: one schema read per schema per session plus one collation query per table (measured: 100 first touches beside 500 tables, views and sequences took 1.36 s in all).backup incremental --max-changesnow counts each table's version as a change, as it already did for native Postgres, so a window can close one row earlier. - A MySQL target's functional unique key no longer lets one source row overwrite another (MEDIUM, silent, KEY-COLLATION-FUNCTIONAL-KEY-PART; HIGH, silent, FUNCTIONAL-CAST-HIDDEN-COLLATION). The guard skipped every functional key part, so a Postgres
UNIQUE (lower(email)), created on MySQL underutf8mb4_0900_ai_ciwherelower('e')andlower('é')are one key, merged at CDC apply and on every idempotent copy path (measured on MySQL 8.4; a freshmigrateor sync cold start was already loud). And a MySQL → MySQL functional key part compares under a hidden column's collation that its expression text does not record, so a key over a charset-lessCAST, a string literal, or a column altered after the key was created was rebuilt under the target default and merged rows the source kept apart (released versions are exposed too). See functional key parts. - Backup
FormatVersion15. A backup of a vanilla-MySQL table with a collated functional key part records that hidden collation and is stamped 15, so v0.160.4 and older refuse it at their ceiling rather than rebuild the key from its text and restore with a row gone. Existing backups are unchanged.
Who needs to act:
- Anyone streaming or backing up a
pgtriggersource into a MySQL-family target, where a table has a UNIQUE key beside its primary key on a case-sensitive collation. Check: compare the target's rows against the source's for such tables (sluice verifyrow counts on a quiesced pair shows every merge as a mismatch). Action: where the source holds values that differ only by case, the target key cannot hold both, so a plain re-copy does not repair the table:sync start --restart-from-scratchrecreates the table with the same key and failsError 1062at the index build (measured). What converges (measured): create the target table yourself with the key comparing byte for byte (COLLATE utf8mb4_0900_bin), then runsync start --restart-from-scratch --schema-already-applied. - Anyone with a MySQL-family target whose tables carry functional unique keys, especially from a Postgres
UNIQUE (lower(…))source or a MySQL → MySQL key over aCAST, a string literal, or a column altered after the key was created. Check: compare row counts and keys against the source. Action: re-copy the affected tables on v0.161.0; aKEY-COLLATION-MERGErefusal names the key to recreate. - Anyone restoring backups across versions: restore any chain containing a version-15 manifest with v0.161.0 or later.
- Anyone downgrading a
pgtriggerstream past v0.161.0: see the downgrade note.
Still open: an sqlite-trigger or d1-trigger source records no collation, so nothing arms for it (KEY-COLLATION-SQLITE-SOURCE-UNSTAMPED, HIGH, silent when reached; see not covered). Since fixed: a source DROP INDEX on a plain unique index, followed by a row duplicating the dropped key, merged at exit 0 (HIGH, silent, measured on v0.161.0; SOURCE-DROPPED-UNIQUE-KEY-MERGE). The target kept the key, so the source's next duplicate of it arrived as an upsert onto a different row. v0.161.1 added the terminal refusal UNIQUE-KEY-CONFLICT-OTHER-ROW, which refuses most such merges at the first colliding row. v0.162.0 adds SOURCE-UNIQUE-KEY-ABSENT, which compares the target's unique keys with the source's at every start and at schema changes, and every 15 seconds on postgres-trigger, sqlite-trigger, d1-trigger, PlanetScale and Vitess sources. What the two still do not catch is listed with the check.
sync start applied a table's changes onto a target copy the stream never made or stopped keeping up, at exit 0 (Bug 321, HIGH, silent); and a table schema add-table brought in dropped out of the stream at the next restart. Upgrading stops new loss, but does not repair a target that already lost rows.
- A warm resume whose scope held a table the stream never brought in, or stopped keeping up (Bug 321, HIGH, silent). A warm resume copies nothing, so these shapes streamed onto an empty, partial or wrong target copy, or not at all: a table widened into
--include-tableon a restart (on a Postgres pgoutput source it was neither copied nor streamed; elsewhere, or through a publication that delivers it anyway, it was streamed onto a copy missing everything before the persisted position); a table narrowed out of scope and later re-admitted (its changes for the narrowed window were discarded); a failedschema add-table, which had already added its table to the publication; and a table dropped and re-created, or swapped with another, while the stream was stopped (a swap applied each table's changes onto the other's copy). Measured on v0.161.1 and v0.162.0; by code reading the widened-include shape on a Postgres source is present from v0.5.0 and the failed-add-table shape from v0.15.0, and every release through v0.162.0 should be treated as affected. These now refuse withINCLUDE-TABLE-SCOPE-DRIFT. - A table added with
schema add-tablestopped receiving changes at the next restart (silent). On a Postgres source, a restart with the stream's original--include-table, or any restart after a drained add on any source, dropped every change to the added table at the dispatch filter. By code reading present from v0.15.0 through v0.162.0. Since v0.163.0 the table stays in scope on every source. Also fixed: a MySQL--no-drainadd of a table the stream's--exclude-tablenamed silently won over the exclusion; it now refuses withLIVE-ADDED-TABLE-EXCLUDED.
Who needs to act:
- Anyone who, on any release through v0.162.0, restarted a stream with a wider
--include-table(or a glob or no filter that newly admitted a table), re-admitted a table after narrowing it out, restarted after a failedschema add-table, or restarted after re-creating or swapping tables on the source while the stream was stopped. Check: compare each such table's target rows against the source's (a row-count shortfall or an empty table is the tell). Action: with the stream stopped, empty the target table and runsluice schema add-table <table>, then the samesync start; or re-copy withsync start --restart-from-scratch(on a Postgres source aftersluice slot drop). On a multi-database stream, re-copy. - Anyone who ran
schema add-tableon a Postgres source and then restarted with the original--include-table, or restarted after a drained add on any source. Compare the added table against the source and repair it as above. - Anyone who ran a MySQL
--no-drainschema add-tablefor a table the stream's--exclude-tablenamed. Decide which you meant: keep the exclusion and--abandonthe add, or remove the exclusion. - Streams cold-started before v0.163.0 have no record and are judged only by their publication, or not at all (
INCLUDE-TABLE-SCOPE-UNRECORDED). For full protection, run one--restart-from-scratchafter upgrading. - A PlanetScale safe-migrations branch needs one ALTER for the new record columns.
Some configurations that started on v0.162.0 now refuse at their next start, including one shape that used to work by luck on a MySQL-family source: a table created on the source and pre-created on the target while stopped, then named in --include-table on the restart. A table added with schema add-table is now in scope until you take it out with sluice schema add-table --abandon <table> --yes.
Still open: a Postgres table dropped and re-created while an interrupted cold start was stopped, before it resumed (RESUMED-COLD-START-CERTIFIES-RECREATED-TABLE, HIGH, silent). Measured on Postgres 16 under FOR ALL TABLES and FOR TABLE; present since v0.149.0, where the stopped-cold-start resume shipped; not fixed in v0.163.0. The resume (COLD-START-RESUMED) tracks copy progress by table name, so it does not re-copy the new table, and the ids it records are read after the stop, so they certify the new table. Under a FOR TABLE publication the resumed run applies none of the new table's changes until the next start refuses. Under FOR ALL TABLES or FOR TABLES IN SCHEMA, the new table's changes are applied onto the old table's copy and nothing refuses. Who: anyone on v0.149.0 or later whose Postgres cold start was interrupted, resumed, and had a table dropped and re-created on the source in between; that table's target copy may hold the old table's rows with the new table's changes applied on top, at exit 0. Action: compare it against the source and re-copy it with --restart-from-scratch (remedy not measured). Other stated residuals are listed under what the check still cannot see.
The control: --schema-changes #
A single tristate flag on sync start (and per-sync in a sync run fleet spec) governs the behavior, introduced in ADR-0091:
| Mode | Behavior |
|---|---|
--schema-changes=forward (default) | Apply every unambiguous source schema change on the target automatically — ADD/DROP COLUMN, ALTER COLUMN TYPE, and, on a MySQL source, ALTER NULLABILITY — logging each applied DDL at INFO. The sync stays online through routine schema evolution. CREATE/DROP INDEX and ADD/DROP/MODIFY CHECK are not among them on any source: see the matrix below. Nor are constraint, row-level-security, policy or DEFAULT changes — since v0.156.0 those stop the stream on Postgres and MySQL/MariaDB binlog sources. Since v0.158.0 a type or nullability change is forwarded one column per boundary (two or more in one ALTER refuse), and a change to a table's column order refuses with COLUMN-ORDER-CHANGED until the target holds it. Since v0.159.0 a key column's collation or character-set change met at a table's first boundary is judged against the source's earlier shape and forwarded (KEY-COLLATION-CHANGED), and on a MySQL/MariaDB binlog source a change that leaves a column name holding a different column refuses with COLUMN-IDENTITY-CHANGED in every mode. |
--schema-changes=refuse | Forward nothing, for operators who gate DDL through a separate change-management process. Since v0.156.10 every schema boundary is checked against the target table instead: a change the target cannot hold (a source column the target lacks, a type its column cannot take — DATETIME precision, DECIMAL scale, a longer VARCHAR) stops the stream with SCHEMA-CHANGE-REFUSED before any row after it is applied; a target column changed ahead of the source that holds every value passes with a WARN; a source narrowing or DROP COLUMN the stream's own earlier shape proves refuses too, and on a Postgres source one it cannot place refuses as AMBIGUOUS-SCHEMA-BOUNDARY. Apply the change on the target and restart (the drained model); an ADD COLUMN you apply there is not backfilled. Through v0.156.9 this mode refused nothing the target could not hold, and type widens were rounded into the target (GC-44 F5). Since v0.158.0 a change to a table's column order is judged on the order rather than column by column, and refuses as SCHEMA-CHANGE-REFUSED carrying COLUMN-ORDER-CHANGED until the target holds it. Since v0.159.0 a key column's collation or character-set change refuses as SCHEMA-CHANGE-REFUSED carrying KEY-COLLATION-CHANGED. |
--forward-schema-add-column was set, and --schema-changes=refuse had none until v0.156.10's SCHEMA-CHANGE-REFUSED check — a column type widen was rounded into the target's old column at exit 0. (A Postgres source's reader did refuse a type change, drop or rename on a table it had already seen in the same session.) Note also that --schema-changes is a no-op under coordinated Shape A (--inject-shard-column): the multi-shard boundary router already forwards every shape via its lease. With --no-coordinate-live-ddl, Shape A forwards nothing and gets the refuse check. A pgtrigger source under --inject-shard-column must run with --no-coordinate-live-ddl since v0.161.0: it refuses live coordination at start with TRIGGER-SOURCE-LIVE-DDL-COORDINATION. The older --forward-schema-add-column boolean is deprecated — forwarding is on by default and covers every shape, so the flag is subsumed; setting it logs a deprecation warning and forwards.What forwards, by source engine #
Under forward, the intercept can emit any shape's DDL, but a change only reaches the target if the source's CDC stream actually carries its detail on the wire. Postgres logical replication (pgoutput) carries less than MySQL's information_schema re-read, so the honest matrix differs by source engine. This is the ground-truth table from ADR-0091 §1d — do not assume a shape forwards without checking it:
| Shape | MySQL / MariaDB source | Postgres source |
|---|---|---|
| ADD COLUMN | forwards, with its DEFAULT; pre-existing target rows backfilled from the source7 | forwards, with its DEFAULT; pre-existing target rows backfilled from the source7 |
| DROP COLUMN | forwards | forwards |
| ALTER COLUMN TYPE (same- or cross-engine) | forwards5 — one column per ALTER; two or more refuse (v0.158.0+) | forwards5 — one column per ALTER; two or more refuse (v0.158.0+) |
| ALTER NULLABILITY | forwards — one column per ALTER, as for a type change | not forwarded — stops the stream (UNFORWARDED-SCHEMA-CHANGE)1,6 |
| Column REORDER, or a RENAME COLUMN swap or rotation | refuses (COLUMN-ORDER-CHANGED) until the target holds the new order2 | refuses (COLUMN-ORDER-CHANGED) until the target holds the new order — a Postgres “reorder” is only ever a swap2 |
| CREATE / DROP plain (non-unique) INDEX | no boundary — never reaches the target and is not detected; mirror manually3 | never signaled on the wire — cannot forward and is not detected; mirror manually1 |
| ADD / DROP / MODIFY CHECK | not forwarded — stops the stream (UNFORWARDED-SCHEMA-CHANGE)3,6 | not forwarded — stops the stream (UNFORWARDED-SCHEMA-CHANGE)1,6 |
| PRIMARY KEY / UNIQUE / FOREIGN KEY (add, drop, change) | not forwarded — stops the stream6 (a UNIQUE index counts, including a prefix-length change) | not forwarded — stops the stream6 (constraints, EXCLUDE included; a bare CREATE UNIQUE INDEX with no constraint is a plain index here) |
DEFAULT / identity / generated change on an existing column | not forwarded — stops the stream6 (DEFAULT, EXTRA such as AUTO_INCREMENT / ON UPDATE, generation expression) | not forwarded — stops the stream6 (SET/DROP DEFAULT, identity, generated column) |
Row-level security / CREATE/ALTER/DROP POLICY | — | not forwarded — stops the stream6 |
| RENAME COLUMN | refuses (§rename) | forwards via attnum4; a swap or rotation refuses (row above) |
| RENAME TABLE / multi-shape combo | refuses | refuses |
1 pgoutput's relation message carries only column name + type + the replica-identity key flag — no nullability flag, no secondary-index or CHECK metadata. The wire never signals these on a Postgres source, so they produce no boundary to forward. Nullability and CHECK changes are caught by the v0.156.0 door instead (6); a plain index change is not caught by anything.
2 v0.158.0+ (ADR-0091 §4, amended). A reorder (MODIFY c … AFTER id) and a rename swap or rotation (RENAME COLUMN b TO c, RENAME COLUMN c TO b) produce the same boundary: every column keeps its name and type, and only the declared order changes. The old argument for a no-op — sluice decodes rows by column name, so a reorder needs no DDL — holds for a reorder and not for a swap, which leaves every row the target already holds under the wrong column names. So both refuse until the target's own column order has moved from its recorded order to the source's: run the same MODIFY … AFTER or RENAMEs on a target that can reorder in place (MySQL, PlanetScale, Vitess and MariaDB targets), or re-copy a Postgres target. From v0.99.45 through v0.157.2 both were forwarded as a no-op (see the v0.158.0 notice).
3 MySQL's CDC projection reads only {schema, name, columns, primary key} on a DDL boundary; it does not project secondary indexes or CHECK constraints. An index-only or CHECK-only DDL therefore produces no boundary at all and is never forwarded. For a plain index that is still the whole story — it is not refused and nothing is logged about it; the change simply never reaches the target, and you add it there out-of-band. A CHECK change, since v0.156.0, is caught by the door in 6 on a binlog source. Forwarding either would need a new catalog projection (perf-only for indexes; cross-engine expression-translation-hazardous for checks), so both are deferred.
4 A Postgres RENAME is proven via the stable pg_attribute.attnum — see RENAME COLUMN.
5 With two carve-outs, both below: a cast to or from a session-normalised timestamp always refuses (session-normalised timestamp), and on a Postgres source a change your projected type cannot express refuses under both modes (projection-invisible changes).
6 v0.156.0+, Postgres and MySQL/MariaDB binlog sources. Neither change stream describes these objects, so they never forward; through v0.155.1 they were dropped with no log line and the target stayed weaker than the source. Now the stream stops at the next write to that table with UNFORWARDED-SCHEMA-CHANGE, the refusal is recorded, and every later start refuses again until acknowledged — see below. Not covered: PlanetScale / Vitess (VStream) sources, where these changes are still silent, and the trigger-CDC engines (postgres-trigger, sqlite-trigger, d1-trigger), whose own readers this door does not reach (for postgres-trigger's DDL handling see below).
7 v0.156.1+. The column's DEFAULT is carried on the target's ADD COLUMN: a MySQL/MariaDB binlog source carries it in-band, and on a Postgres or PlanetScale/Vitess source — whose change streams have no field for it — the forward reads it through the source schema reader, the same value a cold-start migrate emits, and refuses if that read fails (through v0.156.0 it was dropped, so the target filled its pre-existing rows with NULL, NOT NULL columns included). The DEFAULT is read when the boundary reaches sluice, though, so it can differ from what the source filled its own rows with — hence the backfill, on by default. A non-constant DEFAULT the source still declares at that point refuses. The same applies under Shape A (--inject-shard-column). Toward a MySQL-family target a TEXT/BLOB/JSON/GEOMETRY DEFAULT that cannot be carried refuses the ADD COLUMN (LOB-DEFAULT-NOT-CARRIED).
Every forwarded DDL is logged at INFO as it lands, so the applied change is visible in the sync's log stream. Cross-engine type ALTERs are retargeted through the same translation path a cold-start CREATE TABLE uses; a widening ALTER forwards cleanly, while a narrowing or incompatible one is rejected by the target engine and surfaces as a loud, retryable refuse (position not advanced).
What always refuses, even under forward #
These shapes never auto-apply, because forwarding them would silently change or lose stored data:
A cast to or from a session-normalised timestamp #
An ALTER COLUMN TYPE where either side is a session-normalised timestamp — MySQL TIMESTAMP, Postgres timestamptz — refuses, in either direction and at any precision. Those types store UTC, so the server has to resolve them through the executing session's zone to render them as anything else (and to read anything else into them), and nothing on the binlog or the pgoutput wire carries which zone that was. The source operator's ALTER ran under their own session — MySQL's shipped default is time_zone=SYSTEM, the host zone — while sluice pins UTC on every connection it opens, so forwarding the same statement re-casts the target's pre-existing rows against a different zone. Row counts stay equal, every row applied after the ALTER is correct, and the sync exits 0: exactly the silent shape the refusals on this page exist for.
The original refusal was the TIMESTAMP ↔ DATETIME swap alone. Since v0.139.0 it covers the wider measured class. On MySQL 8.0.46 and PostgreSQL 16, a value stored at 2026-06-15 20:00:00 UTC and altered under a +09:00 session read back as 2026-06-16 05:00:00 through VARCHAR, 2026-06-16 through DATE (across midnight), 05:00:00 through TIME and 20260616050000 through BIGINT; the reverse casts into TIMESTAMP shifted the stored instant by the same nine hours, and PG timestamptz to text / date behaved identically. All of them forwarded unrefused before. The time and timetz pair is MEASURED asymmetric, and since v0.142.0 the refusal matches the measurement instead of covering both halves. time to timetz is session-dependent and still refuses: an offset is invented, and Postgres takes it from the executing session. timetz to time is not, and now forwards — timetz stores its offset alongside each value, so dropping it consults no session zone, measured byte-identical under UTC and Asia/Tokyo across negative and fractional offsets, 1-D arrays with NULL elements, and 2-D arrays checked with array_dims. Before v0.142.0 that direction was refused too, halting a stream you would expect to forward. The array shapes follow the scalars (time[] to timetz[] refuses, timetz[] to time[] forwards), and the timestamp family is unchanged and still refuses both ways, because timestamptz is stored normalised to UTC and re-renders through the session zone whichever way the cast runs. A precision-only change within one type (DATETIME(3) → DATETIME(6)) and a cast with no zoned side at all (DATETIME → VARCHAR) carry no zone conversion and keep forwarding.
Scope, stated precisely. The widened class is enforced at the pipeline door, which is reached when a boundary is forwarded — the mode that re-casts the target's existing rows, and so where the widening does its work. The reader-side checks that refuse at a table's first boundary after a cold start or a warm resume still carry only the original TIMESTAMP/DATETIME pair. Either way the remedy is the drained model below: stop with --wait, run the same ALTER on source and target from your own client so both casts happen under a session zone you chose, then restart with the same --stream-id.
RENAME COLUMN #
A column rename and a DROP x + ADD y of the same type are indistinguishable from the replication stream alone — both present as exactly one dropped column and one added column. Guessing RENAME when the truth is drop+add keeps stale data under the new name; guessing drop+add when the truth is RENAME drops the column's data on the target. The only safe disambiguation is a stable column identity that survives a rename:
- Postgres has one —
pg_attribute.attnumis stable across a rename. The PG CDC reader carries it as the column's stable id; the intercept forwards a rename only when the before and after columns share the same non-zero attnum (proven rename, data preserved) and refuses otherwise. Because the proof is definitive, a bug here can only ever refuse safely, never mis-forward. - MySQL has no equivalent —
ORDINAL_POSITIONchanges on reorder and there is no creation id, so a MySQL-source rename is fundamentally unprovable from catalog state. It refuses, permanently. Drain and rename on both ends explicitly.
ADD COLUMN with a computed / volatile DEFAULT #
An ADD COLUMN whose DEFAULT is a non-deterministic function is refused, because evaluating it in the target's session diverges from the per-row values the source already inserted (ADR-0058 §2a). The refused functions include NOW() / CURRENT_TIMESTAMP / clock_timestamp(), nextval(), gen_random_uuid(), random(), and MySQL's UUID() / RAND() — matched schema-qualified or bare, and detected even when wrapped (e.g. COALESCE(NULL, NOW())). A constant DEFAULT forwards normally. If the probe of a column's default can't be read at all, sluice refuses on uncertainty rather than risk a wrong value.
The refusal looks at the DEFAULT the source still declares when the ADD COLUMN reaches sluice. A volatile DEFAULT that was dropped or changed before then — Django's AddField drops its default in the very next statement — is not refused: the forward carries whatever the source declares at that point, and the added-column backfill (on by default since v0.156.1) then overwrites the target's pre-existing rows with the values the source actually filled them with. Through v0.156.0, without the opt-in backfill, those rows kept the target's own fill. Since v0.156.1 the Shape A router (--inject-shard-column) applies the same refusal; before, on a MySQL-family source, it forwarded such a DEFAULT and every pre-existing row got the target's own evaluation of it.
A change your projected type cannot express (Postgres source) #
A consumer of pgoutput holds two representations of a column's type — the raw wire pair (type OID, typmod) and the projected IR type sluice maps engines into — and they can disagree about whether anything changed. interval precision/field restrictions and array-element modifiers are visible to the raw compare and vanish in projection, so the change classifies and there is no projected boundary to forward, while the source has already rewritten every stored value underneath it. Since v0.132.1 a detected change whose projected type is unchanged refuses under both modes, per column and keyed on column name (a middle-column DROP shifts every later ordinal, and pgoutput coalesces DML-quiet back-to-back DDL into one relation message of final state), with a catalog-derived enumeration gate holding the class closed. The refusal text is shape-aware: the two catalog-only shapes PostgreSQL applies without a rewrite — an unbounded varchar↔text swap, and an interval precision widening with the same range bits — say so instead of warning about divergence that did not happen.
More than one column's type or nullability in one ALTER #
v0.158.0+. Forward mode forwards one column's type or nullability change per boundary. Two or more columns changing in one statement — ALTER TABLE t ALTER b TYPE numeric(12,4), ALTER c TYPE numeric(12,4), or two MODIFYs in one MySQL statement — now refuse, naming every column. That includes two widens within one family, such as two VARCHAR widens. Through v0.157.2 the classifier read such a change as no change at all: a Postgres target kept numeric(10,2) and rounded every later value (1.2345 landed as 1.23, at exit 0, measured), while a MySQL target refused the first value that did not fit with DECIMAL-SCALE-EXCEEDED. Against the cold-start seed the target decides, as it does for one column, and at the first boundary after a cold start several nullability-only changes are skipped with a WARN, as a single one is. Recover with the drained model, applying every change on the target, or issue the changes as separate ALTER statements.
A change to a table's column order: COLUMN-ORDER-CHANGED #
v0.158.0+, every schema-changes mode. A column swap (RENAME COLUMN b TO c, RENAME COLUMN c TO b), a rotation of three or more names, and a MySQL/MariaDB reorder (MODIFY c … AFTER id / FIRST) produce the same schema boundary: every column keeps its name and its type, and only the order the source declares them in changes. A reorder needs nothing on the target. A swap leaves every row the target already holds under the wrong column names. A boundary across which two of a table's columns changed order now stops the stream with COLUMN-ORDER-CHANGED, naming those columns, before the boundary is recorded or any row after it is applied. On a Postgres source, DROP COLUMN b, RENAME COLUMN c TO b also refuses now, rather than reading as a plain drop.
The boundary is accepted once the target's own column order has moved from the order sluice recorded for that table to the source's new one, which is what applying the same change there does. A target that merely already declares the new order proves nothing. The record lives in the control table sluice_cdc_column_order. The check runs on the live forward path, at each table's first boundary after a restart (so a restart after the refusal refuses again), under --schema-changes=refuse and on multi-database streams (where the message is SCHEMA-CHANGE-REFUSED and also carries COLUMN-ORDER-CHANGED), and under --inject-shard-column. The refusal is codeless and exits 1; a sync run leg that hits it is marked failed rather than restarted (fleet). The message names one remedy:
- Apply the same change on the target, then restart with the same
--stream-id: the sameRENAME COLUMNstatements for a swap or rotation, or the sameMODIFY … AFTER/FIRSTfor a reorder on a target that can reorder in place (MySQL, PlanetScale, Vitess and MariaDB targets; MySQL rebuilds the table to do it). Do not update values instead: the check reads the target's column order. - Re-copy:
sluice sync start --reset-target-data --yeswith the stream's other flags unchanged, which drops every table of the stream on the target and copies it again from a fresh snapshot (on a Postgres source, drop the stream's slot withsluice slot dropfirst). This is the remedy for a reorder on a target that cannot reorder in place (Postgres), and the only one offered when sluice cannot prove the target moved: the table has no record — as on a stream started before v0.158.0 that has not yet accepted a boundary changing that table's column list — or its recorded target order already disagreed with the source.schema add-tableis deliberately not offered: it keeps the stream's position, so rows written before the change would be replayed onto the re-copied table under the old names.
COLUMN-ORDER-RECORD-UNAVAILABLE is a WARN at start: the target cannot keep the records, typically a PlanetScale safe-migrations branch or a Postgres role that cannot create tables. The stream runs, but every column-order change on it refuses with the re-copy remedy, even after you apply it on the target. To enable the records, have a role that can create tables run the sluice_cdc_column_order statement sluice control-tables ddl --engine <target engine> prints (on a safe-migrations branch, ship it with deploy-ddl).
The cost. A plain MySQL/MariaDB column reorder, which forwarded as a no-op since v0.99.45, now stops the stream until the target is reordered too, or re-copied on a Postgres target. Backup chains follow the same rule: chain restore, backup verify and sync from-backup refuse an incremental whose schema delta changed a table's column order (SLUICE-E-BACKUP-SCHEMA-DELTA-UNSUPPORTED), before applying anything of it; an ENUM label reorder is not a column-order change.
Which DDL halts the stream (postgres-trigger source) #
A Postgres event trigger is database-wide. There is no such thing as a schema-scoped one. Through v0.139.0 the DDL tier filtered by command tag alone, so it recorded a marker for an ALTER TABLE, CREATE TABLE or CREATE INDEX anywhere in the database — and a marker halts the stream with the restart-from-scratch remedy. A colleague creating an unrelated table, in a schema sluice never touches, stopped your sync.
Since v0.140.0 the tier asks the question the drop arm has asked since v0.136.0: does the command's relation carry this install's row-capture trigger? What that resolves to was measured on Postgres 16.15 rather than assumed, because it differs by shape and one shape could have failed quietly — an ALTER reports the table directly, ADD CONSTRAINT included, while a CREATE INDEX reports the index and has to resolve through it to the table.
- Still halts:
ALTER TABLEon a captured table (ADD COLUMN,ALTER COLUMN TYPE,DROP COLUMN,ADD CONSTRAINT,RENAME COLUMN) andCREATE INDEXon one. - No longer halts: anything on a table this install does not capture — another schema entirely, an uncaptured neighbour in the same schema, or a brand-new table. A table with no capture trigger emits no change rows, so it cannot make the applier write a wrong one;
sluice schema add-tableis how it joins the stream.
STALE-CAPTURE-FUNCTION until sluice trigger setup is re-run once. The re-run is non-destructive: the change log, the watermark and the consumer registry are all preserved.A DROP of a synced table (postgres-trigger source) #
Dropping a table a postgres-trigger install captures — directly, or through DROP SCHEMA … CASCADE / DROP OWNED BY — refuses the stream at the next poll since v0.136.0, naming the relation and carrying a drop-specific remedy (the usual one would be useless: sluice migrate reads the source schema and cannot land a drop). Through v0.135.x nothing recorded it at all — the ddl_command_end event trigger named DROP TABLE in its tag filter, and pg_event_trigger_ddl_commands() returns zero rows for a drop — so the stream carried on at exit 0 with the target holding the table's last-synced rows forever. An install created before v0.136.0 has no sql_drop arm and warns DROP-CAPTURE-ABSENT at every CDC open until sluice trigger setup is re-run. Two deliberate non-events: a DROP INDEX records nothing (sluice never forwards index DDL, so an index drop cannot change any row the applier writes), and a drop elsewhere in the database records nothing — the capture is scoped to tables carrying this install's own sluice_capture trigger.
Multi-shape combos (more than one structural change in a single boundary) also refuse — the IR delta can't be unambiguously ordered — as does a target DDL apply that fails on lock contention, permissions, or an unrecognized type. Every one of these leaves the CDC position un-advanced, so a retry replays the boundary once you've reconciled by hand. One exception since v0.156.1: a target ALTER that fails for a forwarded ADD COLUMN — even transiently — stops the stream with ADD-COLUMN-BACKFILL-INCOMPLETE unless the backfill is switched off, because the owed backfill was recorded before the ALTER ran; the automatic retry and every restart refuse until you check the column and acknowledge.
After a forwarded ADD COLUMN: the backfill from the source #
v0.156.1+, on by default. When sync forwards a source ADD COLUMN, the target fills every row it already holds with the column's DEFAULT as the forward carried it, and that DEFAULT is read when the boundary reaches sluice. It is not what the source filled its own rows with whenever the DEFAULT was dropped or changed in between (Django emits ADD COLUMN c … NOT NULL DEFAULT 'v' then ALTER COLUMN c DROP DEFAULT for every field with a default), or when a non-constant DEFAULT was dropped or changed before the ADD COLUMN reached sluice. So after the ALTER lands, sluice pages the table on the source by primary key, reading only the key and the added columns, and writes each row's value to the target, ahead of every change that follows the ALTER — a row the source updates after the ALTER still ends at its updated value. It covers Postgres, MySQL, MariaDB and PlanetScale/Vitess sources, single-stream and Shape A (each shard's stream fills only its own shard's rows, from its own source). A --where filter applies to its read. The trigger-CDC sources forward no ADD COLUMN, and a multi-database stream does not forward DDL, so neither has anything to backfill.
- The stream waits for it. The cost is one pass over the table on the source plus one
UPDATEper pre-existing row on the target. On a Postgres target the updates are pipelined; on a MySQL, MariaDB or PlanetScale target each is its own round trip, so a large table over a slow link can put the stream far behind. It logsforward-add-column: backfilling the added columns …when it starts,forward-add-column: backfill in progresswith the row count every 30 seconds, andforward-add-column: backfill completewithrows_backfilledat the end. - It needs a primary key, read from the source catalog rather than the change stream (under Postgres
REPLICA IDENTITY FULLthe stream's key is every column; a VStream field event carries no key). Without one it refuses. It also refuses if the added column, or a primary-key column, has been renamed or dropped on the source by the time it reads; a later change to any other column does not affect it. - Opting out:
--no-backfill-added-column(fleet keyno-backfill-added-column: true). The pre-existing rows then keep the target's fill, and a WARN (forward-add-column: backfill suppressed) names each column.--backfill-added-column, the old opt-in, is a deprecated no-op.
--backfill-added-column on an earlier release against a Postgres table with REPLICA IDENTITY FULL — which every filtered --where sync requires — that backfill keyed its UPDATEs on every column including the one being filled, matched no row, and left the pre-existing rows NULL at exit 0. Compare the column against the source.An interrupted backfill stops the stream: ADD-COLUMN-BACKFILL-INCOMPLETE #
A backfill does not resume: a restarted stream never sees the ADD COLUMN again, so the rows it had not reached would keep the target's fill with nothing to report it. So a run that ends before the backfill provably reached the target — a sync stop or Ctrl-C during it, a failed source read, an apply error, or a target ALTER that failed, even transiently — ends with ADD-COLUMN-BACKFILL-INCOMPLETE, naming the table and columns. It is an UNFORWARDED-SCHEMA-CHANGE refusal (the message begins UNFORWARDED-SCHEMA-CHANGE: ADD-COLUMN-BACKFILL-INCOMPLETE: …), recorded on the stream's sluice_cdc_state row the same way, so every restart refuses; like it, it exits 1 with no error code, and a fleet supervisor does not restart the leg. A process killed without running its exit path (SIGKILL, an OOM kill, power loss) is covered too: the owed backfill is written to that record before the ALTER is applied, and cleared (compare-and-clear, so it never removes a different refusal) only once the backfill is confirmed on the target — checked every 10 seconds while the stream runs and again at stop. If the record cannot be written, the ADD COLUMN is refused rather than forwarded without it.
The remedy differs from a plain UNFORWARDED-SCHEMA-CHANGE, where you apply a DDL to the target. Here the column is already there; its pre-existing rows may be wrong. Since v0.156.2 the refused restart and the fleet supervisor's ERROR line both lead with this repair; on v0.156.1 they printed the generic "apply the same change to the target" step, with the right one only inside the recorded text (Bug 289):
- Copy the added column's values from the source to the target for the rows that predate the
ADD COLUMN, keyed by primary key. - Start once with
--accept-unforwarded-schema-change=<fingerprint>, using the fingerprint the refused start prints — or, instead of step 1, re-copy by passing--restart-from-scratchon that same acknowledged start.
The record is deliberately conservative, so it can also appear when nothing was lost: after a kill in the seconds between a backfill completing and being confirmed, after a kill between the record being written and the ALTER, or after an ALTER that failed. Check the column against the source before you acknowledge. On a PlanetScale/Vitess source the confirmation arrives with the next change whose position has advanced, so on an idle source a stop before one arrives refuses. Not covered: on the Shape A fan-in, a hard kill after a peer shard's ALTER has filled this shard's rows and before this stream has seen its own ADD COLUMN; and a hard kill before sluice reaches the ADD COLUMN at all. After a crash that follows a source schema change, compare the added column against the source (sluice verify compares real rows). Plan schema changes so a stream is not stopped mid-backfill: wait for the backfill complete line.
ADD-COLUMN-BACKFILL-INCOMPLETE after applying only a target change, compare the added column against the source for the rows that predate the ADD COLUMN and repair them; nothing further will report them.ADD-COLUMN-BACKFILL-INCOMPLETE like any other recorded refusal; a binary older than v0.156.0 ignores the record and resumes past the owed backfill silently. Repair and acknowledge it before downgrading that far.A change the stream cannot carry stops it: UNFORWARDED-SCHEMA-CHANGE #
v0.156.0+, Postgres and MySQL/MariaDB binlog sources. Some source DDL cannot reach the target through any change stream, because neither stream describes the object: pgoutput's relation message carries column names, type OIDs and key flags only, and after a DDL the binlog reader rebuilds a table from information_schema as columns plus primary key only. Through v0.155.1 these changes were dropped with no log line, so the target stayed weaker than the source — for a policy, that is a security boundary. The sharpest shape was a single statement, ALTER TABLE t ADD COLUMN x int, ADD CONSTRAINT fk FOREIGN KEY (x) REFERENCES p(id), which forwarded the column and silently dropped the foreign key, on both engines.
- Postgres:
ADD/DROP CONSTRAINT(primary key,UNIQUE, foreign key,EXCLUDE,CHECK),ENABLE/FORCE ROW LEVEL SECURITY,CREATE/ALTER/DROP POLICY,SET/DROP NOT NULL,SET/DROP DEFAULT, and identity or generated-column changes. - MySQL and MariaDB: primary key and
UNIQUEchanges (a prefix-length change included), foreign key changes (columns, referenced table and columns,ON UPDATE/ON DELETE),CHECKchanges (NOT ENFORCEDincluded, on MySQL), andDEFAULT,EXTRA(AUTO_INCREMENT,ON UPDATE) or generation-expression changes on an existing column. Nullability is not compared there, because the column forward already carries it.
When the stream starts, sluice records these objects for every table in scope (the baseline). At the next write to a table after such a DDL it re-reads that table's catalog and compares; on any difference sync and backup stream end with UNFORWARDED-SCHEMA-CHANGE, naming each change, before the post-DDL row lands. It is never retried automatically, and an automatic retry after some other transient error keeps the baseline it started with, so a change made just before a dropped connection is still refused. What does not refuse, because the pipeline already handles it: the attributes of a column added in the same statement (but a constraint added with a new column does refuse — that is the foreign-key shape above), a constraint that disappears because its column was dropped, and ADD … NOT VALID followed by VALIDATE (validity is not compared).
Expression text is compared by value on MySQL and MariaDB (v0.156.2). MySQL re-spells a CHECK's string literals on any later ALTER TABLE — a CHECK created from a latin1 client session, even a pure-ASCII one, reads _latin1'…' before an unrelated ADD COLUMN and _utf8mb4'…' after — so through v0.156.1 the stream could stop with UNFORWARDED-SCHEMA-CHANGE on a change nobody made, and the refusal displayed a non-ASCII clause as mojibake ('xé' for 'xé'), which following step 1 below would have copied onto the target. The door now compares and displays each expression's decoded, value-level text (introducers and identifier quoting stripped; see type mapping), falling back to the raw text only where the decode refuses — still an exact comparison. On MariaDB, whose catalog writes ? for a character beyond the Basic Multilingual Plane, the door reads the same recovered text, so a change between two such expressions is no longer invisible; its start-time baseline reads base tables only (a VIEW over such a table broke every sync start on the server) and only the stream's own databases.
It is recorded, and every restart refuses again #
A restarted reader would take its baseline from a catalog that already holds the change, so without a record a systemd Restart=on-failure, a pod restart or a supervisor would accept it silently, forever. So the refusal is persisted: sync writes it on the stream's row of the target's sluice_cdc_state table (column unforwarded_refusal), and backup stream writes it in the destination's stream_state.json (key unforwarded_schema_change_refusal). Every later start reads it before any change stream opens and refuses again — warm resume, multi-database resume, --restart-from-scratch and --reset-target-data included — replaying the recorded refusal with its fingerprint <12 hex> (the first 12 hex digits of the SHA-256 of the recorded text) and the exact flag to pass.
SLUICE-E-* code yet, so it exits 1, not 3, and a JSON log line or envelope has no code or hint to branch on. Alert on the marker UNFORWARDED-SCHEMA-CHANGE in the log, not on the exit status — automation that treats a codeless exit 1 as "retry" will just see it refuse again. Since v0.156.1 the same marker also leads an interrupted added-column backfill (UNFORWARDED-SCHEMA-CHANGE: ADD-COLUMN-BACKFILL-INCOMPLETE: …), which is recorded and replayed the same way but has a different remedy.Recovering: apply it to the target, then acknowledge once #
The drained-migrate runbook below does not clear this refusal — a plain restart with the same --stream-id just replays it. Instead:
- Apply the same change to the target yourself. For
backup stream, take a new full backup instead: a chain restored from the old full would lack the change. - Start the stream without the flag once if you do not have the fingerprint yet — the replayed refusal prints it.
- Start once with the same
--stream-idand--accept-unforwarded-schema-change=<fingerprint>(the same flag exists onbackup stream run). It clears that record, logs a WARN naming what was accepted, and takes a fresh baseline.
# the replayed refusal names the change and prints: ... fingerprint 3f9a0c1b7e22
sluice sync start \
--stream-id app-prod \
--source-driver postgres --source 'postgres://...source...' \
--target-driver postgres --target 'postgres://...target...' \
--accept-unforwarded-schema-change=3f9a0c1b7e22
The acknowledgement is bound to one refusal. Only the fingerprint of the refusal recorded now clears it; a value naming any other refusal is refused ("names a different refusal than the one recorded"), and an empty value keeps refusing. The recorded text is stamped with the moment it was recorded, so the same change recurring later gets a new fingerprint. On sync, once the flag has cleared the record it is spent for the rest of the process. That is what makes a copy left in a systemd ExecStart line or a wrapper script harmless — it cannot accept the next, different refusal on the next automatic restart — but take it out anyway: it belongs on a single manual start. It is deliberately not a syncs.yaml key, because standing config would pre-accept refusals. Fleet legs have their own procedure: acknowledging a fleet leg.
To avoid the refusal altogether, make such changes through the drained model: sluice sync stop --wait, apply the change on source and target, restart. A change made while the stream is stopped is part of the next start's baseline, so it does not refuse.
What it does not catch #
- A change made while the stream was not watching — while it was stopped, during the cold-start copy, a DDL a lagging stream had not reached when it stopped, or anything before you upgraded to v0.156.0. It is already in the baseline. Upgrading does not find an old gap: if a Postgres or MySQL/MariaDB binlog stream ran through such DDL on an earlier release, audit those tables by hand (on Postgres compare
pg_constraint,pg_policy,pg_class.relrowsecurity/relforcerowsecurity,pg_attribute.attnotnullandpg_attrdef; on MySQL/MariaDBinformation_schema.table_constraints,check_constraints,referential_constraintsandcolumns.COLUMN_DEFAULT/EXTRA), and forbackup streamtake a new full. - A table nobody writes to again — detection needs a later write to the same table. A change reverted before that read is missed (net no change).
- On MySQL, a DDL run under an out-of-scope default database (
USE other; ALTER TABLE db.t …), which does not clear the reader's schema cache. - Plain (non-constraint) indexes, a non-key Postgres column's collation, and policies or RLS on a partitioned Postgres root. A key column's collation is a different check since v0.159.0: it is judged at the first schema boundary (
KEY-COLLATION-CHANGED) and on every upsert (KEY-COLLATION-MERGE). backup incremental, which opens a fresh reader every run, so a change between two incrementals is in the next run's baseline.- PlanetScale / Vitess (VStream) sources, and the
postgres-trigger,sqlite-triggerandd1-triggerengines, which this door does not reach. On VStream the whole gap is still silent — audit the target by hand after such DDL.
Known false refusals, each costing one acknowledgement: on MySQL, a DROP COLUMN b shrinks a composite UNIQUE (a, b) to UNIQUE (a) while Postgres drops the whole index, and the reader cannot see which the target did; on Postgres, a constraint rename refuses as a drop plus an add.
sluice_cdc_state.unforwarded_refusal (TEXT NULL), added automatically on the next start. Two setups cannot add it themselves and must do it by hand before restarting streams: a PlanetScale safe-migrations target (ship ALTER TABLE `sluice_cdc_state` ADD COLUMN `unforwarded_refusal` TEXT NULL through sluice deploy-ddl), and a Postgres role that does not own an existing control table (the owner runs ALTER TABLE sluice_cdc_state ADD COLUMN unforwarded_refusal TEXT NULL). Every start checks for the column and refuses to stream if it is missing and cannot be added, because a stream that could not record a refusal would have it accepted silently by the next restart. A downgrade to an older binary ignores a recorded refusal and accepts the change on restart, so apply any recorded change to the target before downgrading.A target unique key the source does not enforce refuses: SOURCE-UNIQUE-KEY-ABSENT #
v0.162.0+, every sync source. Every insert reaches a sluice target as an upsert (INSERT … ON DUPLICATE KEY UPDATE on a MySQL-family target, INSERT … ON CONFLICT … DO UPDATE on Postgres). On MySQL the upsert resolves a conflict through any unique key the table has; on Postgres through the table's ON CONFLICT key only (a conflict through another key raises an error). A target unique key the source does not enforce can therefore turn the source's next duplicate of it into an overwrite of a different row. The usual cause is a source DROP INDEX no stream saw: made while the stream was stopped (the door above baselines at start, so the drop is in its baseline), or on a postgres-trigger, sqlite-trigger, d1-trigger, PlanetScale or Vitess source, whose change streams carry no DDL at all.
The apply-time check: UNIQUE-KEY-CONFLICT-OTHER-ROW (v0.161.1+). Every guarded upsert checks, inside the statement, that the row it matched carries the incoming row's primary key; on a table without one that has two or more unique keys, the check is agreement on every unique key's columns, compared NULL-safely. A mismatch refuses before anything is written, naming the target key. The refusal is terminal, carries no SLUICE-E-* code (exit 1), and a sync run leg that hits it is marked failed rather than restarted. Its remedy is to drop that unique key on the target and start again, or a re-copy: --restart-from-scratch for sync start (from a Postgres pgoutput source, after dropping the stream's replication slot), --reset-target-data --yes for sync from-backup run, which has no --restart-from-scratch. Both are destructive. It refuses most such merges at the first colliding row, but two shapes look to it exactly like a re-delivery of the same row: a table with no primary key and one unique key, and two rows that differ only in a NULL or partial-index column. Those are why the check below exists.
When it runs #
- At every start, for each table the stream applies to: the target's unique keys (its primary key included) are compared with the source's current ones, read from both catalogs after the change stream opens and before the first change is applied. That covers cold start, warm resume,
--restart-from-scratch,--reset-target-data, each namespace of a multi-database stream, and everysync runleg start. - At a schema change that moves a table's columns, or loses a unique key with a column, for that table, on every source, whether or not the change is forwarded. A Postgres source drops an index outright with a column it covers; a forwarded
DROP COLUMNnarrows a MySQL-family key that included the column (UNIQUE (a, b)becomesUNIQUE (a)); and aDROP COLUMNat the first boundary after a cold start is not forwarded at all, so the target keeps the column and the key on it. Nothing after the change is applied before the check, and a check that cannot read a catalog stops the stream at once withSOURCE-UNIQUE-KEY-RECHECK-FAILEDrather than let the change through unjudged. - Every 15 seconds on a
postgres-trigger,sqlite-trigger,d1-trigger, PlanetScale or Vitess source, over the tables whose target has a unique key besides the primary key. When a re-check is due, the next transaction is held until it writes one of those tables (the re-check runs first) or commits without writing one (it goes on unchecked). An idle stream, or one that only writes other tables, costs nothing. Postgres (pgoutput) and MySQL-family binlog sources are not re-checked this way, because the door above already refuses a liveDROP INDEXbefore the next row of the table. - In
schema add-table, for a pre-created target table, before it is copied. It refuses if it cannot read either catalog, and takes the same acceptance flag.
A target key passes when a source key implies it: the same columns, in any order, or a subset of them (rows distinct on (a) are distinct on (a, b)); a prefix c(10) implies c(20) and the whole column; the same expression or partial-index predicate in the same SQL dialect; and, across dialects, a key of the same name as the target spells it (a Postgres target prefixes an index name with its table: uniq_email becomes users_uniq_email) and the same shape. A key column whose target collation equates values the source's keeps apart (case-, accent- or trailing-space-insensitive where the source's is not) does not count as implied, and the refusal names that column. Keys sluice adds itself pass: a key copied from the source, the --inject-shard-column composite, and a surrogate the target generates on every insert (AUTO_INCREMENT, an identity column, MySQL's invisible primary key). The full rules, including which engines judge collation direction, are in CDC streaming.
The refusal and its three remedies #
The refusal names each key under its target table name (the renamed namespace on a multi-database stream, --target-schema when set). It carries no SLUICE-E-* code, so the process exits 1; match on the marker. It is not retried, and a sync run leg that hits it is marked failed rather than restarted, because every start reads the same two catalogs and nothing is recorded.
- Drop the key on the target and start again, when the key was dropped while no stream was reading, or the target was built with it by mistake. On a Postgres or MySQL-family source the message says nothing written after the drop has been applied. Drop that key on the target (
DROP INDEX, orALTER TABLE … DROP CONSTRAINTfor a constraint, which the message names) and start again with the same--stream-id. The same applies to a refusal at a schema change. - Re-copy, when rows may already have merged. That is the case when the mid-stream re-check found it; when a source whose change stream carries no DDL dropped the key while a stream was running; when sluice v0.161.0 or earlier was streaming from a Postgres source when its plain unique index was dropped; or when sluice v0.161.1 or earlier was streaming across a
DROP COLUMNof a key column (no release through v0.161.1 checked the keys at a schema change). Dropping the key alone keeps the merged rows, so re-copy from a clean target:sluice sync start --restart-from-scratchor--reset-target-data --yes, with the same--stream-id. Both drop and re-create the stream's target tables from the source, the key and any merged row with them (destructive). From a Postgres (pgoutput) source, drop the stream's replication slot first (sluice slot drop <slot> --source-driver=postgres --source <DSN> --yes, with the full namesluice slot listprints): the re-copy's fresh snapshot refuses to inherit it. Under--inject-shard-columndo not use--reset-target-data: the target table is shared by every shard, and it drops that table with every shard's rows. Drop the key on the target and re-copy this shard with--restart-from-scratch, which there re-copies onto the shared table without clearing it (so it cannot remove a row a merge left behind; unmeasured). - Accept the key by its fingerprint, when the target is meant to have it because it was built with it on purpose. Pass
--accept-target-only-unique-key <table>.<key>=<fingerprint>with the value the refusal prints beside the key, on every start (repeatable;<database>.<table>.<key>=<fingerprint>on a multi-database stream, by the source or the target database name spelled exactly, and<schema>.<table>.<key>=<fingerprint>with the--target-schemathe refusal prints). The fleet spec keyaccept-target-only-unique-keytakes the same list; since the key stays on the target, the acceptance is standing, not one-shot. The fingerprint hashes the key's definition as the target engine itself reports it together with the definition of every column the key depends on, and nothing is normalised, so any change to an input refuses until the new printed value is passed — aDROP COLUMNorMODIFY … COLLATEthat reshapes the key, but also a size-only widening, a rename, a MySQL column comment, or a server upgrade that respells a type or collation. An acceptance is honoured only while the key still hashes to its fingerprint, at every check (the start, a schema change, the periodic re-check,schema add-table). A functional key part on a PlanetScale or Vitess target cannot be fingerprinted, so such a key cannot be accepted: drop it. On a multi-database stream, a<table>.<key>acceptance that matches the key in more than one database refuses at the start; name the database. The apply-time check still refuses a merge through an accepted key wherever it can tell the rows apart.
# a key the target was built with on purpose: accept it on every start
sluice sync start \
--stream-id app-prod \
--source-driver postgres --source 'postgres://...source...' \
--target-driver mysql --target 'mysql://...target...' \
--accept-target-only-unique-key 'users.uniq_email=<fingerprint the refusal prints>'
SOURCE-UNIQUE-KEY-RECHECK-FAILED is not the refusal. A periodic re-check that cannot read a catalog logs a WARN containing it, and the next due check tries again; four failures in a row stop the stream with that marker, because on the sources that carry no DDL the re-check is the only mid-stream protection. A failed read at a schema change stops the stream at once with the same marker. Either way it is an ordinary failure: a restart retries it, and a sync run leg is restarted under its failure cap.
What this does not catch #
- Mid-stream on the sources that carry no DDL, the transactions applied between the source's drop and the next due re-check (up to 15 seconds of them). For the two shapes the apply-time check cannot see, a merge in that window is applied; the re-check then refuses and names a re-copy. The trigger engines refuse a table without a primary key at setup, so on them those shapes cannot occur; a PlanetScale or Vitess source can carry one.
- A mid-stream primary-key change on those sources: only tables with a unique key besides the primary key are re-checked. The next start catches it.
- A source key dropped, a duplicate written and removed, and the key re-created, all between the stream's last position and its start: the key looks unchanged at start. The apply-time check refuses the replayed duplicate where it is armed; on the two shapes above it is silent.
- A cross-dialect functional or partial key redefined under its old name passes (pinned as a known miss). One renamed on either side refuses; accept it by name.
- Column collation direction from a Postgres or SQLite source into a MySQL-family target: a Postgres text key is byte-exact and the column sluice creates for it takes the target's case-insensitive default, so judging it would refuse every such stream;
KEY-COLLATION-MERGErefuses that merge at apply time for a unique key beside a primary key. A Postgres nondeterministic collation counts as folding both case and accents (which one it folds is not recorded), so a MySQL_as_cikey against a Postgres ICUks-level2column refuses although only case folds there; accept it by name. Two collations with the same axes but different folding tables (MySQLutf8mb4_general_ciandutf8mb4_0900_ai_ci, which disagree on characters such asß) count as equal; only an operator-built target can hold such a pair, since sluice copies or maps the source collation. On a PlanetScale or Vitess source the collation is judged at each start and, for a table whose target has a unique key besides its primary key, at each 15-second re-check, so a key column moved to a stricter collation mid-stream can merge up to one re-check interval of rows first (KEY-COLLATION-MERGEis unarmed on those sources). - The source catalog is read when a check runs, so it can be ahead of the stream, and the check refuses any target key the source catalog does not enforce at that moment, at every start and every schema change. That includes a key the source will lose only through a
DROP COLUMNthe stream has not reached yet, which the check cannot tell apart from a key the source dropped on its own, earlier, with duplicates written before the column went (a real merge). Two cases refuse although replaying the stream would have forwarded theDROP COLUMNand dropped the key: aDROP COLUMNof a unique-key column made while the stream was stopped, refused at the start; and one the source has already made while the stream lags behind it, refused at the earlier schema change. A plain restart refuses again at the start, because the stream never gets past the change it stopped before. Drop the named key on the target and start again with the same--stream-id. That converges, and any duplicate the source wrote then lands as its own row (measured for both cases, into Postgres and MySQL). Re-copying works too. - On a PlanetScale or Vitess source, whose change stream carries column changes but not index changes, a due 15-second re-check can read the target while a forwarded
DROP COLUMNis still being applied there, and refuse a key thatDROPis about to remove. It is loud, a restart clears it, and it needs a due re-check to coincide with such aDROP. The trigger engines forward no schema change at all, so it cannot occur on them. - Tables added with
schema add-table --no-drainwhile the stream runs join the re-check at the next start. - The check needs the target tables to exist, so on a cold start into a pre-created target an extra key refuses after the copy, not before it (the copy itself cannot merge through such a key).
sync from-backup runandsluice restoredo not run this check. A backup chain records a sourceDROP INDEXas a schema change of the window it falls in, and both apply it to the target before that window's rows, so the duplicate lands as its own row (measured for a MySQL source). A target pre-created with an extra unique key is not judged there.
A table the stream never brought in refuses a warm resume: INCLUDE-TABLE-SCOPE-DRIFT #
Since v0.163.0, every sync source. A warm resume copies nothing: it continues the change stream from the persisted position. So a table that joins the stream's scope on a restart is never copied. On a Postgres pgoutput source a table outside the publication is not even streamed; a table the publication delivers anyway, or any table on a MySQL-family, VStream or trigger-CDC source whose target table exists, is streamed from the persisted position onto a target copy missing everything before it. Through v0.162.0 the stream came back green in every one of those shapes (Bug 321; see the v0.163.0 note). A table joins a running stream through sluice schema add-table, never through the flags of a restart.
The record each stream keeps #
Since v0.163.0 each stream keeps three records on its sluice_cdc_state row:
established_tables: the tables each cold start actually copied (every lane; each namespace of a multi-database stream;--restart-from-scratchrewrites it), plus everyschema add-tablethat completed, each with its engine-stable id. A cold start leaves it untouched until its point of no return — immediately before the target drop of--restart-from-scratchor--reset-target-data, or, when nothing is dropped, immediately before the copy's first write to the target — and there marks it cold start in progress; the copied set replaces the mark when the cold start finishes. (--reset-target-datathen deletes the control row, mark and all, so its anchor records the copy as a first cold start's does.) So a cold start refused before that point (the "slot already exists" refusal, a schema preflight) leaves the record as it was, even though it may already have widened the publication, and one that stopped after it leaves a mark every warm resume refuses (COLD-START-INCOMPLETE, below). A stream that has a record never goes back to having none.dropped_tables: established tables a later restart's scope left out. Their changes are discarded from then on — on pgoutput the table stays in the publication and the stream drops its changes — so the target copy goes stale.pending_added_tables: eachschema add-tablein flight. It is written before the add changes anything, under a token unique to that add-table run; its heartbeat is refreshed every 30 seconds while it copies, and once the copy is handed to the stream the entry is removed and the table recorded as established in one transaction. Every write after the first compares the token, so an add whose entry was marked abandoned by--abandon, or taken over by another add-table of the same table, stops withPENDING-ADD-SUPERSEDED(a heartbeat that finds this cancels the copy) and records nothing; the target table may then hold a partial copy. A failed add marks its entry no longer running and, on a Postgres source, takes back the publication add it made. Heartbeats are stamped and compared in the control-table server's clock, never a host's; one more than a minute ahead of the server's clock counts as stale and is named in a WARN.
A stream cold-started by an earlier release has no record until its next cold start; sluice does not seed one from the current scope, which would record a never-copied table as copied. Until then the check falls back to the stream's publication on a pgoutput source, for literal includes, and only when no other sluice replication slot exists on the same database (a slot on another database of the server cannot read the publication, so it does not count). Otherwise every resume logs INCLUDE-TABLE-SCOPE-UNRECORDED and there is no evidence; to get full protection, run one --restart-from-scratch after upgrading. A first cold start that cannot bring the record's columns up to date (missing, or of a type sluice refuses, on a control table this role or branch cannot ALTER — see the safe-migrations ALTER) logs COLD-START-ANCHOR-UNRECORDED at its anchor and INCLUDE-TABLE-SCOPE-UNRECORDED at its record write, and runs without the record. Those are the only ways to have no record.
What refuses #
On every start — each sync start, and each start of a sync run leg, restarts included — before the reader opens, a warm resume refuses with a codeless exit-1 error containing INCLUDE-TABLE-SCOPE-DRIFT, naming each table and why, for:
- a table an
--include-tableentry names literally that the stream never copied. A literal entry has no*,?,[or\(a backslash makes an entry a glob), is compared case-sensitively, and a schema-qualified entry such asapp.tnames no table (it admits none either); - a dropped table that any scope re-admits — literal, glob or unfiltered;
- a never-copied table admitted by a glob or an unfiltered stream whose changes would be applied: its publication delivers it (shared with another stream,
FOR ALL TABLES, a multi-schema stream's spanning publication, or a missing publication the reader would recreateFOR ALL TABLES), or, on a source with no publication, its target table exists; - an established table that a
FOR TABLEpublication no longer lists — dropped and recreated on the source, or removed from it — shared or not; - an established table that is no longer the same table on the source (
TABLE-IDENTITY-CHANGED, below); - an in-scope table whose add-table entry has gone stale (heartbeat older than five minutes: the add died);
- any stream whose record reads cold start in progress: a cold start passed its point of no return and did not finish, so the persisted position no longer describes the target. The message carries
COLD-START-INCOMPLETE; re-run the cold start (--restart-from-scratch, or--reset-target-data, whichever was interrupted).
Nothing has been applied. Every restart repeats it and sync run does not restart the leg. A table whose add-table is still running (fresh heartbeat) is judged by that add alone: being never copied, or re-admitted after a drop, is exactly what the add's completion cures. When every refused table is such a table, the resume defers with INCLUDE-TABLE-ADD-IN-PROGRESS, which the fleet supervisor restarts, and once the add completes the stream resumes; a conviction on any other table — or an established table gone from the publication, which an add does not cure — keeps the terminal refusal.
A never-copied table whose changes are provably not applied — outside the publication, or with no target table (the applier already skips those loudly, sluice_cdc_skipped_tables, sync health) — does not refuse: it stays out until schema add-table brings it in, and every resume names such tables in an INCLUDE-TABLE-NOT-ESTABLISHED WARN. That is the whole cost of the rule: those tables are not replicated until you add them. A pending add for a table since dropped or renamed on the source is ignored with a WARN. Remedies, per named table:
- With the stream stopped, empty the target table and run
sluice schema add-table <table>with the stream's--source-driver,--source,--target-driver,--targetand--stream-id, then the samesync startagain. (Not on a multi-database stream: add-table cannot record a table there, and the refusal names only the re-copy.) - Take the table out of scope (
--include-table/--exclude-table). For a tableschema add-tablebrought in, neither flag does it —--include-tabledoes not remove it and an--exclude-tablenaming it is refused — so runsluice schema add-table --abandon <table> --yes, then restart. - Re-copy every table with
--restart-from-scratch. On a Postgres source that run first refuses because the stream's replication slot exists; drop it withsluice slot drop, then re-run. The re-copy rewrites the records and clears the pending entries of the tables it copied.
A table schema add-table brought in stays in scope #
Since v0.163.0 the add records the table in the stream's live-added set (live_added_tables on the control row), on every source and path — the mechanism MySQL's --no-drain path has used since v0.27.0. Every later start reads the set and admits its tables whatever --include-table says, so the staged-wave flow (an --include-table 'orders,order_items' stream, sluice schema add-table of users, then the same sync start command again) keeps streaming users. Through v0.162.0 only the MySQL --no-drain path recorded it, so on a Postgres source, or after any drained add, that restart dropped every change to users at the dispatch filter, silently. One thing takes an added table back out: sluice schema add-table --abandon <table> --yes, which for a completed add takes it out of the live-added set; the next start narrows it, below. A cold start — --restart-from-scratch, an interrupted copy's resume, or the re-snapshot after an invalid position — copies the live-added tables along with the ones the flags name: the copy covers exactly what the stream will apply. --reset-target-data is the exception, because it deletes the control row the set lives on: its copy and its stream cover only the flags' scope, and a WARN names each live-added table discarded, whose target table is left as it was and receives no further changes. Name such a table in --include-table, or schema add-table it again, to keep replicating it.
On a MySQL-family target, schema add-table refuses at its preflight, before it changes anything, a table whose name contains a comma or begins or ends with whitespace (a codeless exit 1): the live-added set there is a comma list, trimmed on read, so the name would read back as other tables. To stream such a table, name it in --include-table with a glob that matches it (? for each comma or edge space, for example foo?bar) and re-copy with --restart-from-scratch, or rename the table. A warm start that finds a live-added entry the source catalog does not hold logs LIVE-ADDED-TABLE-NOT-IN-CATALOG naming the entries. Either the table was dropped on the source, or, on a MySQL target, the entries are fragments of a comma name an older release recorded, which no longer admit that table. Check each, and sluice schema add-table <table> --abandon --yes (with the stream's flags) any entry that should not be there. A SQLite or D1 target keeps no live-added set or established-table record: schema add-table onto one WARNs that the table must be named in --include-table on every restart, and a restart that does not name it stops applying its changes with nothing at that restart saying so.
sync health reports a pending add-table that is still running as in progress with exit 0, so a cron probe does not page through every normal --no-drain add, and one whose heartbeat went stale as ADD-TABLE INCOMPLETE with exit 1; the text sync status lists both (fields and exit codes). For an entry no add-table can clear — its table was dropped on the source, or taken out of the stream's scope — run sluice schema add-table --abandon <table> --yes with the stream's driver, DSN and --stream-id flags. It refuses while the add is still running. Otherwise it takes the table out of a Postgres publication unless the stream established it or another sluice slot exists on the same database, drops the dead add's snapshot slot (sluice_addtable_<table>, or the --slot-name you pass) when it is inactive, and leaves the target table as it is, which may hold a partial copy. It does not remove the entry: it marks it abandoned, because a running stream may still be applying the table's changes onto that partial copy (the publication may still deliver it), and only a stream start judges the table. Until then sync health exits 1 with ADD-TABLE ABANDONED: restart the stream, and --abandon says the same. Restart the stream. A start that finds the table out of the stream's scope, gone from the source, or with changes provably not applied clears the entry; any other start refuses INCLUDE-TABLE-SCOPE-DRIFT naming the abandoned add, and the remedies above apply. A completed re-run of schema add-table for the table, or a cold start that copies it, also clears it.
An --exclude-table that names an added table refuses: LIVE-ADDED-TABLE-EXCLUDED #
Since v0.163.0. The add said the table is in the stream's scope, and the exclusion would apply none of its changes, so the target copy would go stale while the stream reported healthy. Every start refuses it before copying, dropping or applying anything (a codeless exit 1 carrying INCLUDE-TABLE-SCOPE-DRIFT, which the fleet supervisor does not restart), and so does a running stream: schema add-table cannot know the stream's flags, so a --no-drain add of a table the running stream excludes completes (or, if the stream sees it first, stops with PENDING-ADD-SUPERSEDED), and the stream stops when its live-added poll reads the add. Changes to the table that reached it between the add and that poll (at most one poll interval, 5 seconds — the window every live add has, ADR-0034) were dropped as excluded while other tables' changes moved the persisted position past them, so before stopping the stream writes that to its control row in one locked transaction: the table's add-table entry is marked abandoned (a still-running add stops and records nothing), the table moves to the dropped set, and it leaves the live-added set. From then on the table comes back only with a fresh copy — narrow --exclude-table, empty the target table and schema add-table it again, or --restart-from-scratch — and a restart that only narrows the exclusion refuses (INCLUDE-TABLE-SCOPE-DRIFT, dropped). Keeping the exclusion leaves the table out and the next start passes. If that control-row write fails — or commits but cannot record the table as dropped, because the stream has no established-table record (cold-started by an earlier release), keys a multi-database record, or is mid cold start — the stream still stops and logs it at ERROR; then nothing on record stops a restart that only narrows the exclusion, and the refusal says so — with the exclusion still in place every start keeps refusing. At a start (nothing streamed yet), decide which you meant: to keep the table, narrow --exclude-table so no pattern matches it; to take it out, --abandon it as above. Through v0.162.0 a MySQL --no-drain add silently won over the exclusion instead (ADR-0034's additive rule).
A table renamed while the stream was stopped is followed, not refused #
Since v0.163.0 the record keeps each established table's engine-stable id beside its name (Postgres and postgres-trigger sources: pg_class.oid; MySQL and MariaDB: InnoDB TABLE_ID). On a warm start, an established table whose name is gone from the source, whose id now belongs to a new in-scope name, is that table renamed: the record takes the new name and keeps the id, and nothing refuses — provided the target was renamed too: it holds the new name and no longer holds the old one, as the drained-model recovery leaves it. Otherwise the start refuses saying the target was not renamed — including when the target holds a table under the new name beside the stream's copy under the old one: that new-named table is some other table, and following the rename would apply onto it and strand the real copy. Drop or rename such a table away first. A table dropped and re-created under a new name has a new id and refuses as never copied. Where no id proves a rename — a PlanetScale or Vitess source (vtgate does not expose the InnoDB dictionary), a table name InnoDB stores in its filename encoding, every table of a database whose name is so encoded (for example my-db, which leaves the whole stream without ids), a partitioned table, or a record written without ids — the refusal names --accept-table-rename <old>=<new> on sync start: a human's statement that the new table is the old one renamed, after renaming the target too; the record then follows it, under the same target rule (new name present, old name gone). sluice cannot check what the target table under the new name holds, so with the flag it is trusted as-is. A pair that matches no rename only WARNs, so the flag does nothing once the rename is followed. It has no syncs.yaml key: take the leg out of the fleet for the one start that needs it.
A table that is no longer the same table: TABLE-IDENTITY-CHANGED #
Since v0.163.0 an established table whose engine id no longer matches the record refuses inside INCLUDE-TABLE-SCOPE-DRIFT. On a Postgres or postgres-trigger source any change of pg_class.oid under the same name counts — VACUUM FULL, CLUSTER, TRUNCATE and rewriting ALTERs keep the oid (only relfilenode changes), so a changed one means the table was dropped and re-created, or swapped with another, while the stream was stopped. On MySQL and MariaDB InnoDB gives a table a new TABLE_ID on OPTIMIZE TABLE, a copying or rebuilding ALTER and TRUNCATE, so a changed id alone is not refused there: the record takes the new id, and a WARN names the old and new id; a DROP and re-CREATE under the same name looks the same there (TABLE-RECREATED-WHILE-STOPPED, below). Both engines refuse a swap (RENAME TABLE t8 TO tmp, t9 TO t8, tmp TO t9, or the same in three ALTER … RENAMEs), proved by one name's recorded id now belonging to another, and the message says which table each name now holds. The remedy is a re-copy of the table: empty its target table and schema add-table it, or --restart-from-scratch.
A narrowing is logged and recorded, or the start refuses: INCLUDE-TABLE-NARROWING-UNRECORDED #
A start whose scope no longer holds an established table logs a WARN naming it — its changes are no longer applied, and bringing it back later refuses until it is copied again — and moves it to dropped_tables in one locked write. If that write cannot be made (the dropped_tables column missing on a control table this role cannot ALTER, a lost connection), the start refuses with INCLUDE-TABLE-NARROWING-UNRECORDED instead of going on, because a narrowing nobody recorded would let a later re-widen pass a table whose changes were discarded: restore the scope the stream had, or fix what the error names and start again. The three record columns are added together, in one first write, and a refusal for a missing one names the statement that brings them up to date, built from the columns the table has (on MySQL that includes live_added_tables, added if missing or widened to LONGTEXT CHARACTER SET utf8mb4); sluice control-tables ddl prints them (and live_added_tables) with the rest of the control table. A pre-existing record column of any type but LONGTEXT (MySQL family) or TEXT (Postgres) is refused with the ALTER that fixes it: a shorter type would cut a long record where nothing could tell.
A record that does not decode (hand-edited, truncated, corrupted) is never read as some other set. A start refuses INCLUDE-TABLE-SCOPE-DRIFT naming the column and the recovery. For established_tables or dropped_tables the recovery is a re-copy (--restart-from-scratch): the cold start overwrites the record without reading it (an ERROR line names the discarded value's length). The dropped set cannot be rebuilt from an unreadable value, so the cold start records as dropped every table the source catalog holds that the copy did not cover; a later start then refuses each of those if a scope re-admits it, while a table created after the re-copy is judged like any never-copied one. Only when the cold start cannot read the catalog does it record the catch-all "every table this copy did not cover counts as dropped", and the next cold start that can read the catalog replaces it. A pending add-table entry that does not decode is reported rather than failing the command: sync health exits 1 and prints its raw text, sync status lists it, and a start refuses the table it names; sluice schema add-table --abandon <table> --yes retires it. An entry with no readable table name, or a pending column that does not decode at all, is cleared with the SQL the message gives (UPDATE sluice_cdc_state SET pending_added_tables = NULL WHERE stream_id = '…'), after which the tables it named need schema add-table or a re-copy.
What the check still cannot see #
- A running stream. The check runs when a stream starts, and apart from
LIVE-ADDED-TABLE-EXCLUDED(above) nothing re-checks a stream that is already running. Everything else in this section that arises while a stream runs — an add-table killed mid-copy, an abandon of an add whose table the stream is still applying, the window below between a cold start's anchor write and its record write — is caught at the next start, not before.sync healthis the running-stream signal (ADD-TABLE INCOMPLETE,ADD-TABLE ABANDONED); alert on it. - A process killed in the middle of
schema add-tablecannot take back its publication add, so a running stream keeps applying into the partial copy untilsync healthreports the entry stale or the stream restarts and refuses. The add's snapshot slot is persistent and survives the kill too, until--abandon(orsluice slot drop) removes it. - A table dropped and re-created under the same name while the stream was stopped (
TABLE-RECREATED-WHILE-STOPPED), where the record carries no id for it: on Postgres that is a record written before ids were recorded (until the stream's next cold start orschema add-tableof the table), under aFOR ALL TABLESorFOR TABLES IN SCHEMApublication — aFOR TABLEpublication drops the table onDROP TABLE, which the check catches without an id. On MySQL and MariaDB it is every such table, because a changedTABLE_IDthere is as likely a rebuild (above); the binlog the resume replays carries theDROP/CREATE, and whether anything in that replay catches it has not been measured. A swap is caught on both engines wherever ids are recorded. On MySQL that excludes the tables listed above as having no id (filename-encoded table or database names, partitioned tables), so a swap made there while the stream was stopped is not seen. - A Postgres table dropped and re-created while an interrupted cold start was stopped, before it resumed (RESUMED-COLD-START-CERTIFIES-RECREATED-TABLE, HIGH, silent; present since v0.149.0, not fixed in v0.163.0). The stopped-cold-start resume (
COLD-START-RESUMED) tracks copy progress by table name and reads the ids it records after the stop, so it certifies the new table without re-copying it. See the v0.163.0 note. - A process killed between a later cold start's anchor write and its record write (
--restart-from-scratch, an interrupted copy's resume, the re-snapshot after an invalid position) leaves the in-progress mark, so the next warm resume refusesCOLD-START-INCOMPLETEalthough the cold start had finished — loud, and a re-copy clears it. A stream's first cold start has no control row until that anchor, and neither has a--reset-target-datacold start, which deletes the row (its in-progress mark with it) before copying, so the anchor write itself records the copied tables with their engine ids, in the same transaction, and a kill after it warm-resumes on that record. Where the control table's record columns cannot be brought up to date (missing, or a type sluice refuses, such as a MySQLlive_added_tablesthat isTEXTor notutf8mb4and cannot be widened), the anchor is written alone, as before, and a WARN carryingCOLD-START-ANCHOR-UNRECORDEDnames the error. - On a multi-database stream, each database's cold-start refusals (a populated target, a pre-existing foreign key, the target-side preflights) run when that database's turn comes, after earlier databases were already copied — and, under
--restart-from-scratchor--reset-target-data, dropped. A refusal there is loud and every re-run repeats it after re-copying the earlier databases; fix what it names. On a single-database stream every deterministic refusal runs before the record is marked and before anything is dropped. - A
LIVE-ADDED-TABLE-EXCLUDEDstop that cannot record the table as dropped (above) leaves a restart that only narrows the exclusion unprotected, and its refusal says so. sync status --format jsondoes not carry the pending record yet (sync health --format jsondoes).- An in-scope table with no capture trigger on a trigger-CDC source captures nothing, at cold start as well, which is a separate class.
After a restart, the first boundary is checked against the target: RESUME-SCHEMA-DIVERGENCE #
v0.156.10+, under --schema-changes=forward. When sync starts on a stream that already ran — a warm resume, a restart, a sync run leg restart, an automatic retry after a transient error — it has no record of a schema change made since: while it was stopped, or after it restarted and before the table's next row. So each table's first schema boundary on such a start is compared with the target table itself, column names and types. A column named by --type-override is compared by presence only (its type is yours), so an override changed between runs is not seen here. Postgres and PlanetScale/Vitess sources produce that boundary at each table's first change; MySQL and MariaDB binlog sources do too since v0.156.10, in every schema-changes mode.
- They match: nothing happens.
- The source has columns the target lacks: they are added and backfilled from the source, with the same
ADD-COLUMN-BACKFILL-INCOMPLETEguarantee as a liveADD COLUMN. - Exactly one column the source widened within its family (a temporal precision, a decimal's integer digits or scale, a
CHAR/VARCHARlength,FLOAT→DOUBLE, an integer width of the same sign): the change is applied, logged at INFO asforwarding the difference (target-witnessed, GC-44)with adifference=attribute. An array column whose element widened refuses instead. - Exactly one column the source narrowed, with proof: applied only when the stream's retained schema history proves it. That proof is possible only on a MySQL or MariaDB binlog source, where the boundary must lie strictly after that history version in the source's own position order; a narrowing back to a shape the table held before also counts. The INFO line's
difference=saysnarrowed from. - A column the target already holds wider, with no such proof: kept, with a WARN (
the target column is WIDER than the source's), because it cannot be told apart from a target someone widened on purpose. Wider means within the family, or across families where the target's type holds every source value:TEXTforVARCHAR, a wide-enoughNUMERICwith a non-negative scale for an integer, a wider signed integer for an unsigned one, or anENUM/SETlabel superset (aSET's in the same order).CHARacross families andjsonbforjsonnever count as wider. If the source really did narrow the column, the target's existing rows kept values the source'sALTERconverted: narrow the target or re-copy the table. - Columns only the target has: kept, with a WARN naming them (
target_only_columns). - A key column's collation or character set changed (v0.159.0+): judged against the source's own earlier shape of the table and forwarded; with no earlier shape on record, a key whose collation differs from the target's stops the stream with
RESUME-SCHEMA-DIVERGENCEcarryingKEY-COLLATION-CHANGED. A key-collation change together with any other difference in the same boundary also refuses. - Anything else — columns added and removed together (a possible rename), more than one changed column, a cross-family change the target's type does not hold (a source
VARCHAR→TEXTmade while stopped), a decimal widened on one axis and narrowed on the other: the stream stops withRESUME-SCHEMA-DIVERGENCE, naming each column with both types, before anything after the boundary is applied or recorded.
The refusal carries no SLUICE-E-* code, so the process exits 1; alert on the marker. It is not retried automatically, and a sync run leg that hits it is marked failed rather than restarted (fleet). The check records nothing, so the next start checks again: make the target table match the source (the drained model), then start with the same --stream-id. Under --inject-shard-column (Shape A) a difference the check forwards goes through the shard-consolidation lease, so a change made on every shard's source while the fleet was stopped is applied once, and a column a peer shard added is backfilled on every shard's rows from that shard's own source.
Streams that forward no DDL: SCHEMA-CHANGE-REFUSED #
v0.156.10+. Four kinds of stream forward no source DDL: --schema-changes=refuse; a MySQL multi-database stream (--all-databases, --include-database, --map-database); a Postgres multi-schema stream (--all-schemas, --include-schema); and --inject-shard-column with --no-coordinate-live-ddl (Shape A's drained model). Since v0.161.0 a pgtrigger source gets the same check whatever --schema-changes says (below). Through v0.156.9 none of them checked schema changes either: a boundary went straight to the applier, which recorded it as history and carried on, so a DATETIME → DATETIME(6) or numeric(10,2) → numeric(10,4) was rounded into the target at exit 0 — measured on MySQL 8 and Postgres 16 under refuse, and on a MySQL 8 multi-database stream. A Postgres multi-schema stream produced boundaries only for the schema the source DSN names, so in every other selected schema even a change made while the stream ran went unchecked; it now produces them for every selected schema. Now every boundary — a DDL while the stream runs, and each table's first change after a start, which covers a change made while it was stopped — is compared with the target table, column names and types:
- They match: the boundary passes.
- The target's column holds every value the source can send — wider within its family, or across families by the rule above — and nothing says the source narrowed it: passes with a WARN. This is the drained model: the target was changed ahead of the source.
- A column only the target has, with no earlier shape on record showing the source had it: passes with a WARN (
target_only_columns); it receives no values from this stream. - A source column the target lacks, or a type the target's column cannot hold —
DATETIME→DATETIME(6)against atimestamp(0)target, a larger decimal scale, a longerVARCHAR, a sign change at the same width:SCHEMA-CHANGE-REFUSED, naming each column with both types, before the boundary is recorded or any row after it is applied. - The source narrowed a column, as the stream's own earlier shape of the table shows (its last boundary in this run, or its retained schema history):
SCHEMA-CHANGE-REFUSED, the type markednarrowed from …. The source'sALTERconverted the rows it stores; the target's copies never were. On a binlog source a boundary that lies after that earlier shape in position order is judged whatever it shows, a narrowing back to an earlier shape included; one that lies before it is a replay and never counts. - A
DROP COLUMNthe target still holds, by the same evidence:SCHEMA-CHANGE-REFUSED, named as the likelyRENAME COLUMNwhen a column the target lacks comes with it. - A Postgres column replaced under the same name (its
attnumchanged between two boundaries of this run):SCHEMA-CHANGE-REFUSED. Drop and re-add it on the target, copy the source's values into it, and restart. On a MySQL/MariaDB binlog source the same replacement refusesCOLUMN-IDENTITY-CHANGEDinstead, at theALTERitself and when a resume replays it (v0.159.0+). - A key column's collation or character-set change (v0.159.0+):
SCHEMA-CHANGE-REFUSEDcarryingKEY-COLLATION-CHANGED. Apply the same change on the target and restart.
The refusal is codeless and exits 1, and a sync run leg that hits it is marked failed rather than restarted. Nothing is recorded, so — except in the AMBIGUOUS case — nothing needs acknowledging: apply the same change to the target table (sluice sync stop --wait first if the stream is still running), then start again with the same --stream-id. A single-database stream under refuse can instead switch to --schema-changes=forward; a multi-database or multi-schema stream and --inject-shard-column have no forward path. The drained model does not backfill: an ADD COLUMN you run on the target fills the rows it already holds with that ALTER's DEFAULT (or NULL), and only rows changed after the restart carry the source's values — copy the column's existing values across yourself if they matter (--schema-changes=forward does backfill a forwarded ADD COLUMN). A --type-override column is judged on width: a source type that outgrows the override refuses, and so does a source narrowing; the override's own deliberate narrowing passes while the source's type is unchanged since the table's last accepted shape or the cold start. On a Postgres source the reader's own gate still refuses first, with incompatible schema change mid-stream, an ALTER COLUMN TYPE, DROP COLUMN or RENAME COLUMN on a table it has already seen in the session; this check judges what that gate lets through.
A pgtrigger source: the first-touch boundary and TRIGGER-SOURCE-LIVE-DDL-COORDINATION (v0.161.0+) #
Since the fix for KEY-COLLATION-TRIGGER-CDC-NO-HISTORY, a postgres-trigger stream reads each table's shape from the source catalog at the table's first change of every session and hands it to the target ahead of that change. That is what lets a MySQL-family target refuse (KEY-COLLATION-MERGE) a row the source keeps apart from another only by case. The shape is also recorded in the target's sluice_cdc_schema_history and in a backup's manifest, as a native Postgres stream's is. On the event-trigger tier the table's columns and keys are read once per schema per session and each table's collations at its own first change; on the trigger setup --allow-polled-fingerprint tier each table is read at its own first change, and a change made after that is not seen until the next session.
Because the shape is read as the catalog stands, a schema change made while the stream was stopped can show in it before the stream reaches that change's own DDL marker. A trigger-CDC source therefore runs the same boundary check as --schema-changes=refuse, whatever that flag says: a table the target cannot hold stops the stream with SCHEMA-CHANGE-REFUSED. The remedy, which the reader's own observed-DDL refusal now gives too (it used to say to run sluice migrate first, which cannot run on a populated target): drain the stream, then re-run sluice sync start --restart-from-scratch, which drops and recreates the stream's tables on the target from the source's current schema and re-copies them. Under --inject-shard-column the consolidated table is shared and the restart does not drop it: apply the same change to it and delete this shard's rows from it first. Under --schema-already-applied the restart drops and recreates nothing either: apply the change on the target and empty the stream's tables first. With the DDL event triggers installed (the default tier), applying the change on the target and restarting at the same position is not enough, because the reader reaches the change's DDL marker and refuses again.
TRIGGER-SOURCE-LIVE-DDL-COORDINATION. Under --inject-shard-column, live cross-shard coordination would forward that first-touch shape to the consolidated target, so a postgres-trigger source refuses it at start. Pass --no-coordinate-live-ddl. sqlite-trigger and d1-trigger emit no such shape and are not refused.
Downgrading past v0.161.0. An older binary refuses to warm-resume a postgres-trigger stream whose target holds postgres-trigger rows in sluice_cdc_schema_history, because it cannot order them. Delete those rows (DELETE FROM sluice_cdc_schema_history WHERE source_engine = 'postgres-trigger', adding AND stream_id = '<stream-id>' to downgrade one stream) before starting the older binary, or restart from scratch.
A source DROP INDEX on a unique index. A postgres-trigger change stream carries no index DDL, so the target keeps a key the source dropped. Through v0.161.0 the source's next duplicate of that key merged into a different target row at exit 0 (HIGH, silent; SOURCE-DROPPED-UNIQUE-KEY-MERGE). v0.161.1's UNIQUE-KEY-CONFLICT-OTHER-ROW refuses most such merges at the first colliding row, and since v0.162.0 SOURCE-UNIQUE-KEY-ABSENT catches the dropped key itself at every start and, mid-stream, at the next due 15-second re-check. Not covered: the transactions applied between the source's drop and that re-check (up to 15 seconds of them). The two merge shapes the apply-time check cannot see need a table without a primary key, which the trigger engines refuse at setup, so on a postgres-trigger source those cannot occur. The full list is with the check.
On a Postgres source: AMBIGUOUS-SCHEMA-BOUNDARY #
Postgres schema boundaries are anchored at LSN 0/0, and their schema history keeps one shape per table (GC-44 F23), so position order cannot place a boundary after the stream's earlier shape. A narrowing or DROP COLUMN whose only evidence is that earlier shape is then one of two things: a replay — a transaction that wrote rows, ran ADD COLUMN (or a widen) and wrote again, re-delivered from its start after a restart or an automatic retry, presenting the table as it was before a change the target already holds — or a change made on the source that this stream has not applied. Accepting the second would be silent loss, so the stream still stops with SCHEMA-CHANGE-REFUSED; the refusal carries AMBIGUOUS-SCHEMA-BOUNDARY, names both readings, and offers no remedy that destroys data. Do not change the target yet:
- Compare the source's current table definition (
\din psql) with the target's. If the source's column is narrower than the target's, or gone, it is a source change: apply it to the target and restart. - A match is consistent with a replay but does not prove one: a column narrowed and widened back, or dropped and re-added, while the stream was stopped matches too. So also confirm that nobody ran DDL on that table while the stream was stopped.
- If both hold, change nothing on the target and start once with
--accept-unforwarded-schema-change=<fingerprint>, the fingerprint the refusal prints. If you cannot rule the DDL out, re-copy the table (or the column's values) from the source instead, which is safe either way.
The fingerprint covers the table, the refused columns and the position the stream resumed from, so it accepts only the replay re-delivered from that position: a later boundary of the same shape — a genuine narrowing after the stream applied past the replay, say, with the flag left in a unit file — gets a different fingerprint and refuses. Within one process the value is dropped as soon as a retry resumes from a later position, and nothing is recorded for it. If the stream persisted progress before reaching the boundary, the next refusal prints a new fingerprint; acknowledge that one. Under sync run the fleet spec has no acknowledgement key, so start an AMBIGUOUS leg once outside the fleet. A boundary that also names a column the target lacks (a replayed RENAME COLUMN) cannot be acknowledged: its recovery is the pre-ALTER replay wedge's.
MySQL-family binlog sources: decoding rows behind the head (DEFERRED-SCHEMA-CHANGE) #
v0.158.0+. A binlog reader that is behind the head reads events recorded under an older shape of a table. That covers every backup incremental window, every backup stream start, a warm-resumed or lagging sync start, and the CDC phase after a cold copy. Each row event is now decoded by the shape it was recorded under, or refused before any row of the table is decoded (through v0.157.2 it was decoded by the table's current definition — Bug 301, see the v0.158.0 notice). What that means depends on the source's binlog_row_metadata:
FULL(recommended). Every event carries its column names and sluice decodes by them: add, drop, reorder, rename, anENUMlabel reorder and two changes close together decode exactly. Set it withSET PERSIST binlog_row_metadata = 'FULL'on MySQL. MariaDB has noSET PERSIST: useSET GLOBAL binlog_row_metadata = 'FULL'and putbinlog_row_metadata = FULLunder[mariadbd]in its option file so a restart keeps it. Binlog written before the setting cannot be decoded by name, so take a newbackup fullor re-snapshot the sync (--restart-from-scratch) after setting it.FULLstill refuses (SLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH) for a column the binlog cannot describe on its own — a dropped signedTINYINT, a spatial column, a binary column on MariaDB — and for a schema change between the stream and the head on a table with a GENERATED column or a MariaDBUUID/INET4/INET6column. A row whose recorded shape includes a column dropped or retyped later never becomes a schema boundary, so sluice holds it to the table's previous shape: it refuses withDEFERRED-SCHEMA-CHANGEif a shared column's type or collation changed (or the stream has no typed shape of the table to compare with), and withCOLUMN-ORDER-CHANGEDif two shared columns reversed order.DEFERRED-SCHEMA-CHANGEis codeless, exits1, is not restarted undersync run, and its remedy is a re-copy. The same type change on its own still forwards. Since v0.159.0 a character column's collation is compared too: the binlog records it as an ID, which sluice names from the source's own collation table (MariaDB'sFULL_COLLATION_NAME, otherwiseinformation_schema.COLLATIONS; never a static table, because MySQL and MariaDB give the same high IDs to different collations). An ID the source's table cannot name refuses too. Through v0.158.1 the collation was named by character set only and then cleared, so a key's collation change folded behind a later retype could merge a row on the target before the stream refused (DEFERRED-KEY-COLLATION).MINIMAL(MySQL's default) orNO_LOG(MariaDB's default). sluice proves the catalog shape holds at the stream's position by reading the schema changes logged between the two withSHOW BINLOG EVENTS, and refuses a table one of them reshapes. Behind the head, these now refuse where they used to decode: any schema change of a streamed table other than an index, a table option, aTRUNCATEor an in-place redefinition. That includes anENUM/SETlabel append viaMODIFY, theRENAME TABLEcutover of gh-ost, pt-online-schema-change and LHM (even for an index-only migration), a no-opCREATE TABLE IF NOT EXISTSof a streamed table, andCONVERT TO CHARACTER SETon a table withENUM/SETcolumns. An in-placeMODIFYof aYEARcolumn refuses underMINIMAL, and on MariaDB'sNO_LOGany in-place redefinition refuses (aVARCHARwiden included), because that format records no charset or signedness. The remedy for each isFULL. The refusal names the statement for its source (the MySQLSET PERSIST, or MariaDB'sSET GLOBALplus the[mariadbd]option-file line) onsync start, and since v0.158.1 onbackup incrementalandbackup streamtoo; on v0.158.0 a refused backup said only to setbinlog_row_metadata=FULL, without the MariaDB option-file line that keeps it across a restart (Bug 302). Since v0.160.0 the look-ahead skips a leading run of plain sessionSETassignments (no quotes, parentheses or comments before its;) and classifies the statement after it, so a binlog text such asset foreign_key_checks=0; CREATE TABLE IF NOT EXISTS ...no longer reads as reaching every table; through v0.159.3 one such statement anywhere on the server made every stream behind the head refuseSLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH(v0.160.0 notice).
Recorded collations (v0.159.2+, MINIMAL or FULL). Every row event's TABLE_MAP records the collation each character column was written under, under MySQL's default MINIMAL as well as FULL. The reader now decodes and reports each row under that recorded collation and character set, so a stream that reads a collation change after the fact (a backup incremental, a warm-resumed sync start) emits the schema boundary at the change with the shape the rows were written under: a sync forwards it ahead of them (or refuses it under --schema-changes=refuse), and a backup records it for chain restore to guard them under (v0.159.2 notice). MariaDB's default NO_LOG records no collation, so nothing is stamped there. That rests on a collation change being a logged DDL, and two refusals check it:
DEFERRED-SCHEMA-CHANGEmarkedCOLLATION-CHANGE-UNANNOUNCED. A row whose recorded collation differs from the shape this stream last decoded the table with, when no DDL that can reach that table was logged in between, for example anALTERrun withsql_log_bin=0, which changes the catalog and the row images and writes nothing a boundary could follow. It fires underMINIMALorFULL, before any row of it is decoded, and is scoped per table: a loggedALTERof another table does not silence it. Like everyDEFERRED-SCHEMA-CHANGEit is codeless, exits1, repeats on a restart and is not restarted undersync run. The remedy is destructive:sync start --reset-target-data --yesfor a sync, a freshbackup fullfor a backup chain; run such DDL with binary logging on. A stream that starts from a seeded shape (a backup's parent manifest, a sync's starting schema) whose first row of a table differs from that shape records a schema boundary at the stream's start instead of refusing.
Grants. The look-ahead's SHOW BINLOG EVENTS needs REPLICATION SLAVE on MySQL, which a CDC account already holds, and BINLOG MONITOR on MariaDB 10.5+, which REPLICATION CLIENT includes there. An account without it is refused with SLUICE-E-CDC-REPLICATION-PERMISSION, naming GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO <account>. Whether managed MySQL providers (RDS, Aurora, Cloud SQL, Azure) permit SHOW BINLOG EVENTS to an account that can stream is not verified: it is expected, but no measurement is recorded. If one does not, the reader stops rather than decode an unproven shape.
SLUICE-E-CDC-BINLOG-LOOKAHEAD-SHORT. The look-ahead could not read the binlog up to the position the source reported with its catalog read, for about ten seconds. It never treats binlog it did not read as checked. Retry; if it recurs, check the source's sync_binlog and binlog_group_commit_sync_delay. Since v0.159.3 a page that tore at the active binlog's tail (MySQL error 1220, binlog truncated in the middle of event, measured on about one read in a hundred under eight concurrent writers) is read as ending where the server stopped rather than stopping the stream; when the look-ahead still cannot get past such a tear, this refusal carries the server's 1220 and no longer suggests sync_binlog. A tear in a file the server has already rotated away from still fails as before (v0.159.3 notice). Cost: the look-ahead reads the binlog between the stream and the head at most once per window, and nothing for a stream at the head.
Not covered: PlanetScale and Vitess (VStream) sources are unverified for Bug 301. Their lanes were not changed, on the premise that each row is decoded against the field list Vitess emits for it, and that premise is not measured. A VStream row event carries no collation, so the key-collation checks do not arm there either.
MySQL-family binlog sources: a column name that now holds another column (COLUMN-IDENTITY-CHANGED) #
v0.159.0+, every schema-changes mode, any binlog_row_metadata setting, at the head or behind it. Some ALTER sequences end with the same column names and order they started with, and no comparison of the table's shape can see them, because a MySQL column has no stable id — not even under FULL, whose per-event names are the same before and after:
DROP COLUMN c, ADD COLUMN cof the same type, in oneALTERor two;RENAME COLUMN c TO tmp, ADD COLUMN c;DROP COLUMN b, RENAME COLUMN c TO b, in oneALTERor two;- a rename swap (
RENAME COLUMN b TO c, RENAME COLUMN c TO b, or theCHANGEspelling) followed, before the table's next row, by aMODIFY … AFTERthat restores the old order; - a swap spelled as three renames through a temporary name (
RENAME COLUMN b TO tmp; RENAME COLUMN c TO b; RENAME COLUMN tmp TO c), which refuses at its second statement, where it cannot be told fromDROP b; RENAME c TO b.
Through v0.158.1 each produced no schema boundary, and the target kept every existing row's old values under the reused name at exit 0 (notice above). sluice now reads the ALTER TABLE statements in the binlog, which reach the reader in binlog order whatever the metadata setting or lag, and keeps a per-table ledger of what each did to column identity. When a name now holds another column, the stream refuses with COLUMN-IDENTITY-CHANGED at the ALTER itself, so a position persisted past it followed by a restart cannot skip it, and again at the table's next row. A plain swap or rotation in one statement is still left to COLUMN-ORDER-CHANGED, whose remedy is to apply the same renames on the target. A no-op CREATE TABLE IF NOT EXISTS or a DROP TEMPORARY TABLE does not reset the ledger.
The refusal reaches every lane that reads the binlog: sync start (live and warm), backup incremental, backup stream and the broker. It is codeless, exits 1, and repeats on every restart; a sync run leg that hits it is marked failed rather than restarted (fleet). There is no acknowledgement flag. The remedy is a re-copy: sluice sync start --reset-target-data --yes with the stream's other flags unchanged, or a fresh backup full for a chain.
MariaDB. IF EXISTS / IF NOT EXISTS on a column clause is read as if the clause took effect, so a no-op DROP COLUMN IF EXISTS c, ADD COLUMN c refuses too. A statement sluice cannot parse (for example one adding a MariaDB UUID column) refuses, marked COLUMN-IDENTITY-UNREAD, when it names an in-scope table and either spells DROP, CHANGE or RENAME or follows a statement that vacated a name of that table. Otherwise it logs a WARN with that marker and the stream continues, so a routine ADD COLUMN u UUID keeps flowing. COLUMN-IDENTITY-UNREAD is handled exactly like COLUMN-IDENTITY-CHANGED.
Not covered: PlanetScale and Vitess (VStream) sources, whose reader does not read the DDL text (unmeasured), and Postgres sources, where a column replaced under the same name is caught by attnum only within one run (F22). Under lower_case_table_names 1 or 2, an ALTER that spells an in-scope table in another case is judged out of scope at the DDL, so the refusal that holds across a restart does not fire for it; the row path still judges the table (not measured). Table identity — a RENAME TABLE swap, or DROP + CREATE of one name — is not covered, nor is a pure permutation followed by a persisted position, a restart and a MODIFY … AFTER.
A key column's collation: KEY-COLLATION-CHANGED, KEY-COLLATION-MERGE #
v0.159.0+. A collation decides which key values are equal. When a primary-key or unique column's collation on the target equates two values the source keeps apart ('A' and 'a' under _ci; 'a ' and 'a' under PAD SPACE), an upsert of the second row lands on the first: on a PRIMARY KEY the first row vanishes, on a UNIQUE column it is overwritten. Through v0.158.1 that happened at exit 0 whenever a stream did not forward a key's collation change (notice above). It is now handled in four layers:
- At the schema boundary:
KEY-COLLATION-CHANGED. A key column's collation or character-set change met as a table's first schema boundary after a start, or on a--schema-changes=refusestream, is judged against the source's own earlier shape of the table (the cold-start read, or the stream's retained schema history). Underforwardit is forwarded to the target column, as the same change seen live always was. Underrefuseit stops the stream withSCHEMA-CHANGE-REFUSED; with no earlier shape on record, a key whose collation differs from the target's stops the stream withRESUME-SCHEMA-DIVERGENCE. Each refusal carriesKEY-COLLATION-CHANGEDand names the column and both collations; apply the same change on the target and restart. Underforward, a key-collation change together with any other difference in the same boundary refuses. A stream between two engines of the same name whose target key was deliberately created with a different collation now refuses its first boundary for that table when no earlier shape is on record. - In the applier:
KEY-COLLATION-MERGE. The backstop for whatever no boundary shows. Where the source key's current collation distinguishes values the target's collation equates, every upsert carries a check inside the statement, which adds no round trip. An upsert whose key matched an existing target row that is equal under the target's collation but different under the source's refuses withKEY-COLLATION-MERGEbefore anything is written. A table whose source and target key collations agree carries no check, and a_cisource re-delivering a case-only change still converges. It covers MySQL, MariaDB, PlanetScale/Vitess and Postgres targets on every change-apply path (serial, batched and lanes), for livesync startand chain restore, which deliver the source's schema versions it arms from, and since v0.160.1 for the broker (sync from-backup), which seeds it from each incremental's recorded schema. On v0.159.0 through v0.160.0 the broker applied every incremental with this check unarmed (v0.160.1 notice). A trigger-CDC source records no schema versions, so apgtriggerlive sync or chain restore into a MySQL-family target is still not checked (not covered). The remedy is to give the target key column the collation the message names and restart; where the message says the key that identifies the row is itself compared differently, or the table has no primary key, it leads with a re-copy, because a re-delivered case-only change refuses on every start there. Since v0.159.1 the message names, per armed column, the collation the key needs: 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 (not the source's 0900 name, which MariaDB 10.11 lacks). A key armed to compare bytes (a Postgres source collation on a MySQL-family target) gets no re-copy offer: the message says a re-copy would not converge, because it recreates the key under the target's default collation, which equates the values. - In the copy phase (v0.159.1+):
KEY-COLLATION-MERGEagain. The copy phase's idempotent writer, the copy that tolerates a row already on the target, carries the same in-statement check, armed from the copied table's source collation, on MySQL-family and Postgres targets. It serves a resumed or chunk-resumedmigrateor sync cold start,--force-cold-start,schema add-table, the VStream COPY (serial and fanned out) and restore of a rotated chain. A merge refuses withKEY-COLLATION-MERGEand the statement is rolled back; a re-sent row with the same source key, such as a resumed chunk, still converges. Unlike the applier, the copy also arms a Postgres default-collation or nondeterministic key inside the primary key. A plain fresh copy is not guarded this way: its first pass uses the plain bulk path, where the same pair fails as a loud duplicate-key error. A copy resumed over a live source whose key value changed case or accent between attempts can also refuse; the refusal names both readings (if the source equates the two values, re-copy the table from scratch; if it keeps them apart, the target key needs the collation the message names). On MySQL's plain bulk load, a retry after a transient (the reparent and storage-grow ride-out) on a table whose key is compared differently on the two sides is handled per core. The batched core re-sends a retried batch that meets a duplicate key as the guarded upsert, so its own lost-ack rows converge and only a real merge refuses. LOAD DATA has no upsert form, so a replayed segment that skipped rows (rows inserted short of the segment, or a duplicate-key warning) refuses withKEY-COLLATION-REPLAY-AMBIGUOUS, including a genuine committed-but-unacknowledged transient that used to ride through, because LOAD DATA cannot tell its own re-sent row from a second source row. That refusal is MySQL-family only (LOAD DATA runs on a vanilla MySQL or MariaDB target; PlanetScale/Vitess and any target without LOAD DATA use the batched path). Nothing is wrong with the data yet: run the copy again, and a genuine collision then surfaces on the first attempt as a plain duplicate key; or give the target key the collation the message names. A replay whose only warnings are something else, such as a clamp under--mysql-sql-mode='', skipped nothing and passes. - In chain restore.
backup incrementalrecords a collation-only change to a key column as acolumn-collationdelta, and chain restore and the broker apply it to the target before the window's rows. An older binary reads that delta as no change, so since v0.159.1 such a segment is stampedFormatVersion13 and every older binary refuses it. Chains v0.159.0 captured carry no stamp: restore them with v0.159.0 or later. Since v0.159.2 chain restore releases each schema version a window recorded just before the first change recorded at or after it, in the source engine's position order, and, ahead of a window that changes a table's key collation, the shape the previous link recorded for that table, so rows are guarded under the collation they were written under. A window that changed a key's collation and changed it back, or loosened it, now refusesKEY-COLLATION-MERGEinstead of merging (v0.159.2 notice); restarting refuses again. On v0.159.2 a loosened window armed nothing when no row of the table followed the loosening inside it, and merged at exit 0 (Bug 306, fixed in v0.159.3). A chain records only what its capture saw: a key changed and changed back inside a window thatbackup incrementalon v0.159.1 or earlier read after the revert left no trace of the intermediate collation, so no release can refuse it (Bug 307); check such a restore against the source and take a fresh full backup. Postgres windows (v0.160.0+): a Postgres change stream anchors every schema version at one position, so a window keeps one version per table; a later version whose key collation is not established weakens the kept one, so the whole window compares that key byte for byte, and a window whose two shapes of a table disagree on a key column's collation is refused at capture withKEY-COLLATION-SAME-ANCHORrather than recorded. Where the versions cannot be ordered, seeKEY-COLLATION-HISTORY-UNORDEREDbelow. - On a functional unique-key part (v0.161.0+): KEY-COLLATION-FUNCTIONAL-KEY-PART. On a MySQL-family target a functional key part (
UNIQUE ((lower(k)), x)) compares under the collation of the index's hidden column, which the expression text does not record. Through v0.160.4 the guard skipped every functional part, so a PostgresUNIQUE (lower(email)), created on MySQL underutf8mb4_0900_ai_ciwherelower('e')andlower('é')are one key, let the second row replace the first throughON DUPLICATE KEY UPDATEat exit 0, at CDC apply and on every idempotent copy path (schema add-table, resumed and chunk-resumed copies, the VStream COPY, restore; measured on MySQL 8.4). Now a part passing one column throughLOWER,UPPER,TRIM,LTRIM,RTRIM, a characterCASTorCOLLATEis compared per row on the target as the index compares it, under the collation read off the hidden column, and on the source as the bytes of the columns it reads; any other collated part is taken to match on the target, so it refuses only a conflict between rows whose columns differ. Where the hidden column cannot be read (vtgate on Vitess and PlanetScale), the part is assumed to match and the guard refuses when the target matched two rows whose columns differ. A merge refusesKEY-COLLATION-MERGE, naming the key to recreate comparing byte for byte (COLLATE utf8mb4_0900_bin, orutf8mb4_nopad_binon MariaDB). FUNCTIONAL-CAST-HIDDEN-COLLATION: a charset-lessCASTor a string literal takes the creating session'scollation_connection, and anALTER … MODIFYof the column leaves the hidden column on its old collation, so a MySQL → MySQL key rebuilt from its text alone could merge rows the source kept apart ('a'/'A','ss'/'ß','e'/'é'; released versions are exposed too). From a vanilla MySQL source sluice now reads each part's hidden-column collation and emits it on the keys it creates (((expr) COLLATE <it>)); a backup that records it is stampedFormatVersion15. The cost: where the guard arms, a conflict between byte-different values that the source would equate now refuses instead of converging, mostly on Vitess and PlanetScale and on MySQL → MySQL CDC apply, whose change-stream shape carries no secondary indexes, so every collated functional key arms there. A Postgres target is not exposed: its upsert never arbitrates on an expression index, so a conflict through one is a loud23505.
KEY-COLLATION-GUARD-UNSUPPORTED. If the target cannot express the source's collation for the comparison (the collation or character set does not exist there), the stream refuses at its first write to the table rather than apply unguarded, naming the source's collation and the comparison collation the target lacks. Since v0.159.1 the copy phase's idempotent writer refuses the same way, before any row of the table is written. Create the collation on the target, or align the key column's collation on both sides. A MySQL-family key on a Postgres column with a nondeterministic collation refuses this way, because Postgres has no collation to compare it as. So does a MySQL 8 utf8mb4_0900_* key on a MariaDB target older than 10.10 (details). A second cause, since v0.161.0: a functional key part whose columns sluice cannot resolve. No collation change fixes it, so the table is refused before any row of it is written, and the message names each part with its own remedy: a part naming a column the table does not have (for example from a non-ASCII column name): recreate the key over ASCII-named columns; a string literal sluice cannot scan: write it plainly, or key a stored generated column instead; generated columns nested more than eight deep: key a shallower expression. An armed key whose columns are still not all in the insert is refused the same way. Or leave the table out with --exclude-table.
KEY-COLLATION-HISTORY-UNORDERED (v0.159.2+). sync from-backup places no schema version between a window's changes, so it cannot tell which rows were written under which collation of a key. It refuses, before applying any of it, every window that changes a key column's collation: a schema delta that does, two consecutive recorded versions of a table that disagree, or a version that disagrees 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. Chain restore from a trigger-CDC source (pgtrigger, sqlite-trigger, d1-trigger), which records no schema versions and has no position order, refuses the same way for any window whose schema delta changes a key column's collation, including a forward, tightening change it used to restore correctly. Since v0.160.0 it also refuses a window that records a Postgres key collation not established for its rows, because the broker's applier is never armed from a window's versions and so cannot compare that key byte for byte; take a new full backup to replay past it. This refusal is what makes the broker's guard sound (v0.160.1+). The broker arms the applier's KEY-COLLATION-MERGE check from each incremental's recorded schema, handed to the applier in memory before the incremental replays and never persisted, rather than from the window's versions; that one shape is correct for every row of the window because any window whose versions disagree with it on a key collation has already been refused here. Through v0.160.0 the broker armed nothing at all (v0.160.1 notice). The marker arrives inside a KEY-COLLATION-MERGE refusal. 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), or restore that full on its own. A VStream (Vitess/PlanetScale) window replays as before: a VStream schema version records no collation and is not read as a change. See also Sync from a backup chain.
The chain-restore remedy (v0.159.3+, Bug 308). restore neither resumes nor accepts --restart-from-scratch, so a chain restore's KEY-COLLATION-MERGE replaces the applier's remedy with one it can run, by command and key:
- Inside a window that itself changes a key column's collation: take a fresh full backup after the key change and restore it into an empty target. For the
sync from-backup run --reset-target-datacold start, which runs the same chain restore, the remedy issluice sync from-backup run --reset-target-data --yesagainst the new chain. - Outside such a window, when sluice creates the key under a target default collation that treats the two values as equal (for example, a byte-compared Postgres key restored onto a MySQL-family target): the table cannot be restored onto that target as it is. Leave it out with
sluice restore --exclude-table, or use a target that keeps the source's comparison. - Outside such a window, for a key sluice would create correctly: the table most likely existed before the restore, because the restore's
CREATE TABLEisIF NOT EXISTS. Drop the restored tables and restore again, or leave the table out. - Where the engine cannot say what the key needs (the Postgres applier): both of the previous two remedies, each with the condition under which it applies.
The applier's own remedy, which sync start and sync from-backup run show, names the re-copy for each: sluice sync start --restart-from-scratch, or sluice sync from-backup run --reset-target-data --yes for a backup replay (through v0.159.2 it named --restart-from-scratch alone, which sync from-backup run does not accept).
KEY-COLLATION-VERSION-NAMESPACE (v0.159.3+). A MySQL or MariaDB backup records its tables with no database name while its change rows name the database; the shape a chain-restore window starts from takes the name its rows carry. A row naming no database at all — the ADD COLUMN fill a backup window records, one update per row by primary key, which names what the manifest names — neither sets nor contradicts it. A row of that table naming a different database than the shape was filed under, or arriving after the shape has already gone out naming none, refuses KEY-COLLATION-VERSION-NAMESPACE rather than let the lookup miss. It arrives inside a KEY-COLLATION-MERGE refusal (no new error code), refuses before that change is applied, and names its remedy: take a fresh full backup and restore from it. It is the check on the premise that a single-database backup's rows name one database: no real chain is known to reach it. It exists because of Bug 306.
KEY-COLLATION-SAME-ANCHOR (v0.160.0+). Where: a Postgres backup stream or backup incremental capture. A Postgres change stream anchors every schema version at LSN 0/0, and a backup keeps one schema version per table and position, so a window that wrote a row of a table and then saw that table's key collation change records two shapes of the table at one position, and keeping either would leave a restore unable to tell which rows were written under which collation (v0.160.0 notice). When two shapes of a table recorded at one position disagree on a key column's collation, the capture refuses to record the window: KEY-COLLATION-MERGE: KEY-COLLATION-SAME-ANCHOR: <table>: …, with no error code, exit 1. Remedy: take a new full backup after the key change (sluice backup full) and continue the chain from it; restarting the capture from the same position re-reads the catalog and is expected to refuse again. The cost: a forward-only key collation change in a window that already holds a row of the table refuses too, where it used to restore correctly — unless the first shape the window recorded was already not established, which compares the key byte for byte and needs no refusal. Under REPLICA IDENTITY FULL the change stream reports every column as part of the key, so a column added inside the window is not treated as a new key column unless a unique index names it; a real all-column primary key extended to a column added inside the window reads the same way. A cancelled backup stream whose last flush hits this refusal exits non-zero with it, not with an abandoned-window WARN (details).
All eight markers are codeless and exit 1. KEY-COLLATION-CHANGED rides inside RESUME-SCHEMA-DIVERGENCE or SCHEMA-CHANGE-REFUSED, KEY-COLLATION-HISTORY-UNORDERED, KEY-COLLATION-VERSION-NAMESPACE, KEY-COLLATION-SAME-ANCHOR and KEY-COLLATION-DELTA-FAILED (v0.160.2+, details) ride inside KEY-COLLATION-MERGE, and KEY-COLLATION-MERGE (with KEY-COLLATION-GUARD-UNSUPPORTED) is a refusal of its own; under sync run a leg that stops on any of these three is marked failed rather than restarted (fleet). KEY-COLLATION-REPLAY-AMBIGUOUS (v0.159.1+) is not on that list: sync run restarts the leg, and running the copy again is its remedy. KEY-COLLATION-PRE0159-2-UNVERIFIABLE (v0.160.2+) is a WARN, not a refusal: chain restore, sync from-backup and backup verify log it for every MySQL-family incremental captured before v0.159.2 that records a character column in a primary or unique key (details). Postgres sources: pgoutput carries no collation, so the CDC reader now reads each column's collation from pg_attribute.attcollation at every Relation message — one catalog query per table per session and per schema change, with no new privilege. Both Postgres readers record the database default collation as deterministic. Not-established key collations (v0.160.0+). The collation read at a Relation message is the one in force now, not necessarily the one the rows were written under, so the reader also reads the column's pg_attribute row, which altering the column rewrites. When a key column's catalog row is newer than the transaction being decoded, or was written by that same transaction (whose DDL may have run after its rows), and its collation is nondeterministic (an ICU case- or accent-insensitive collation), the reader records the collation as not established for those rows, and both appliers compare that key byte for byte: an incoming row whose key matches a byte-different existing key refuses KEY-COLLATION-MERGE instead of merging onto it. A byte comparison is stricter than the collation's, so the cost is loud: a conflict the collation would have equated also refuses, and the same happens after any other change to that column's catalog row made after, or in the same transaction as, the replayed rows — a rename, SET NOT NULL, a column-level GRANT, or a CREATE TABLE with its seed rows in one transaction. The comparison has a lower bound, because a frozen catalog row keeps its raw xmin, so a column's catalog row more than 231 xids old does not read as newer than every row. A backup segment that records such a key is stamped FormatVersion 14.
Not covered:
- VStream sources (PlanetScale/Vitess). The stream carries no collation, so none of these checks arm. After changing a key column's collation there, apply the same change on the target before any row distinct only under the new collation is written, or re-copy the table.
- At CDC apply, a Postgres-source PRIMARY KEY (or a UNIQUE key on a table without one) on the database default collation or a nondeterministic collation, landing on a MySQL-family target. That key is created under the target's default collation, usually case-insensitive, and a second source row distinct only in case merges. The applier deliberately leaves it unarmed, because arming it would refuse a re-delivered case-only change that converges today. A UNIQUE key beside a primary key on the same pairing is checked (a nondeterministic one since v0.159.1), and the copy phase arms the primary key too.
- A SQLite or D1 source key, including the
sqlite-triggerandd1-triggerstreams (HIGH, silent when reached; open, KEY-COLLATION-SQLITE-SOURCE-UNSTAMPED). Its reader records no collation, so plain key columns are not checked. Since v0.161.0 a functional unique key part on a MySQL-family target arms for these sources too. Measured with a--type-overridethat lands aBINARYtext key asvarcharunder a MySQL_cicollation, and with a hand-made_citarget table. - A trigger-CDC source (KEY-COLLATION-TRIGGER-CDC-NO-HISTORY; fixed for
pgtriggerin v0.161.0). Through v0.160.4 the trigger-CDC readers emitted no schema versions, so the applier had nothing to arm from and applied every change unchecked, in steady state: apgtriggerUNIQUE key that MySQL creates under a_cidefault could take a second source row distinct only in case onto the first at exit 0, at livesync start(cold start and warm resume) and in a chain restore (measured on Postgres 16 → MySQL 8). Since v0.161.0 thepgtriggerreader emits a schema version at each table's first change of a session (first-touch boundary; v0.161.0 notice).sqlite-triggerandd1-triggerrecord no collation and stay unarmed (previous item). - A Postgres-source backup chain written before v0.159.0, restored into a MySQL-family target, with a key on the database default collation. Such a manifest cannot tell that key from a source that records no collation, so it stays unchecked. A key with a named collation in such a chain is checked since v0.159.1, compared byte for byte in every key.
- Two residuals of the Postgres not-established check (v0.160.0+). A DDL whose transaction was assigned its xid before the replayed one, but committed after it, reads as older, so it is not caught. A table or key column renamed or dropped after the replayed rows misses the catalog lookup, which is by name, and stays unguarded, as before. Through v0.159.3 any Postgres key made stricter and then looser again while the stream lagged, or inside one transaction, could merge (v0.160.0 notice).
- Functional key parts (v0.161.0+). A Vitess or PlanetScale source's hidden collation cannot be read through vtgate, so such a key arms as unknown (loud on a respelled conflict only). A hidden collation changed and changed back inside one backup window is not seen (FUNCTIONAL-HIDDEN-COLLATION-WINDOW-REVERT). A target whose column character set differs from the source's (a
--type-overridethat changes it, say) can make the pinnedCOLLATEfail withERROR 1253at the index build, after the copy, loudly (FUNCTIONAL-HIDDEN-COLLATION-TARGET-CHARSET). Open, MEDIUM by code-reading, silent: a generated column whose expression has a charset-lessCASTor a literal comparison takes the creating session's collation, which nothing reads, so a target that recreates it computes different values; it reaches a functional key over that column and a plain UNIQUE on it (GENERATED-EXPR-SESSION-COLLATION). And a plain UNIQUE key on a generated column is armed only on the generated column's own declared collation, so a base column whose collation differs on the target (a--type-override, a targetMODIFY) changes its value unchecked (PLAIN-UNIQUE-GENERATED-COLUMN-BASE-COLLATION, open, MEDIUM by code-reading, silent). - MySQL 8 → MariaDB is compared under MariaDB's NO PAD
uca1400collations, so MariaDB's UCA 14.0.0 weights stand in for MySQL's UCA 9.0.0 ones. - A non-key column's collation-only change is not judged at a boundary and not applied by chain restore (no merge is possible there), and a chain whose incremental was captured before v0.159.0 carries no collation delta.
Postgres sources: the pre-ALTER replay wedge (PRE-ALTER-REPLAY-WEDGE) #
Loud, no data loss (GC-44 F24); the marker and the recovery text are new in v0.156.10, the wedge is not. On the four kinds of stream above, and under Shape A's coordinated router, the Postgres reader still refuses an ALTER COLUMN TYPE, DROP COLUMN or RENAME COLUMN on a table it has already seen in the session. A source transaction that writes a table, changes a column and writes the table again is therefore refused on its first delivery and on every restart, whatever the target holds, because each replay caches the pre-ALTER relation first. Nothing the transaction wrote after the ALTER reaches the target; rows it wrote before may already be there, and the recovery re-applies them. When the refused relation's transaction had already written the table, the reader's refusal carries PRE-ALTER-REPLAY-WEDGE and names the recovery for the case. After applying the change to the target:
- A type widening on a stream over a single database and schema, without
--inject-shard-column: start once with--schema-changes=forward, then return torefuse. This is the only change a forward start passes (GC-44 F26: aDROP COLUMNorRENAME COLUMNwedge cannot pass it). - Any other change, or a multi-database or multi-schema stream: drop the stream's replication slot with
sluice slot drop <slot> --source-driver=postgres --source <source DSN> --yes(the literal namesluice slot listprints), then start once with--restart-from-scratch, which re-copies every table in scope from a fresh snapshot past that transaction. --inject-shard-column: the target tables hold the sibling shards' rows too. First delete this shard's rows from each in-scope target table (DELETE FROM <schema>.<table> WHERE <shard column> = '<this shard's value>'), then drop the slot, then start once with--restart-from-scratch. Never use--reset-target-datahere: it drops every sibling shard's rows.
Each recovery is pinned end to end, with rows read back exact and the stream resuming in its own mode. An ALTER run in its own transaction followed by writes — the drained model's own shape — recovers with a plain restart. After the drained model, a DROP COLUMN or RENAME COLUMN wedge's restart may instead stop at the check above as SCHEMA-CHANGE-REFUSED, naming the column the target now lacks; adding the column back does not pass it either, so use the re-copy. Under sync run this refusal is not in the not-restarted list: a wedged leg is restarted and refuses again each time (GC-44 F27). Alert on a leg that keeps restarting with this marker, and apply the recovery outside the fleet.
Known residuals (GC-44) #
- F23 (Postgres source, silent): on a stream under
--schema-changes=forward, a column narrowed on the source while the stream was stopped is kept with a WARN (the target column is WIDER than the source's). The target is not narrowed, and its existing rows keep values the source'sALTERrounded, because Postgres schema history cannot prove the boundary comes after its earlier shape, so the narrowing cannot be told from a crash replay. If you see that WARN, narrow the target or re-copy the table. On a stream that forwards no DDL the same case refuses asAMBIGUOUS-SCHEMA-BOUNDARY. - F22 (silent, narrow; Postgres and VStream sources only since v0.159.0): a
DROPplusADDof the same column name and type, both made while the stream was stopped, is not seen; the target keeps the old column's values. Within one run it is caught. On a MySQL/MariaDB binlog source the same change now refusesCOLUMN-IDENTITY-CHANGEDwhen the resume replays itsALTERfrom the binlog, which a warm resume and abackup incrementalalways do (a binlog purged past it means a re-copy instead), except wherelower_case_table_nameslets theALTERspell the table in another case. - F17 (fixed in v0.159.0 and v0.159.1 for key columns, with named residuals): a key column's collation or character-set change met as the first schema boundary of a run, on a
refusestream, in a chain restore, or on any Postgres source, merged rows the source keeps apart. It is now judged at the boundary and checked on every upsert, the copy phase's idempotent writer included since v0.159.1 (KEY-COLLATION-*). Still uncovered: VStream sources, a Postgres default-collation or nondeterministic primary key on a MySQL-family target at CDC apply (deliberate), the two residuals of the Postgres not-established check (a DDL whose xid predates but whose commit follows the replayed transaction, and a table or key column renamed or dropped after the rows), SQLite and D1 source keys (no collation recorded), and MySQL 8 → MariaDB compared under UCA 14.0.0 weights. A backup window that changes a key's collation and changes it back, or loosens it, restored or replayed merged through v0.159.1; v0.159.2 fixed both for MySQL and MariaDB binlog sources (KEY-COLLATION-REVERTED-WINDOW-RESTORE, KEY-COLLATION-LOOSENED-WINDOW) for the chains it writes, not for abackup incrementalchain written by v0.159.1 or earlier, which recorded no evidence of the window (Bug 307), and its loosened-window restore armed only when a row of the table followed the loosening inside the window (Bug 306, fixed in v0.159.3). The Postgres twins (KEY-COLLATION-PG-SAME-ANCHOR, KEY-COLLATION-PG-LOOSENED-REPLAY, both HIGH) were fixed in v0.160.0 (v0.160.0 notice). A non-key column's collation is still not judged. - F24, F26 and F27 (loud): the pre-
ALTERreplay wedge stays open (F24); a forward start cannot pass aDROP COLUMNorRENAME COLUMNwedge, so for those the re-copy is the only recovery (F26); and the fleet supervisor restarts a wedged leg rather than marking it failed (F27).
The other open GC-44 items — among them a geometry SRID or an interval(p) precision changed while the stream was stopped (F16, F20), and type pairs that refuse on every start (F25, F15) — are listed in the v0.156.10 release notes.
The refusal message #
When a change refuses, the error is deliberately greppable and names the specific offending object plus the operator action. It carries three parts: the classify error (which shape / how many changes), a structured drift diff that names the exact columns / indexes / constraints that differ, and a recovery hint. The hint spells out the drained model:
- Run
sluice sync stop --waitto drain in-flight changes. - Apply the schema change on the target — manually, or through a governed channel such as
sluice deploy-ddl(PlanetScale deploy requests) orsluice schema add-tablewhen the change is a whole new table. - Resume by re-running
sluice sync startwith the same--stream-id— it warm-resumes from the persisted position. - It also notes that
--schema-changes=refusekeeps the drained model as the default for any subsequent source DDL. Since v0.156.10 that mode is target-checked: it applies no source DDL, and every schema boundary is compared with the target, stopping withSCHEMA-CHANGE-REFUSEDwhere the target cannot hold what the source now sends.
Operator runbook: recovering a refused change #
When a change refuses — or when you run --schema-changes=refuse deliberately — the recovery is the drained-schema-migrate sequence. Stop the stream with --wait so the CLI blocks until the streamer confirms a graceful drain (the in-flight batch is committed and the CDC position is persisted past the last applied event), apply the DDL to whichever side needs it, then resume from the persisted position. (One refusal is the exception: UNFORWARDED-SCHEMA-CHANGE is recorded on the target, so this sequence alone just replays it — apply the change to the target and acknowledge it with --accept-unforwarded-schema-change instead.)
# 1. Drain and stop — --wait blocks until the drain is confirmed
sluice sync stop --wait \
--stream-id app-prod \
--target-driver postgres --target 'postgres://...target...'
# 2. Apply the schema change on source and/or target as appropriate
psql "$SOURCE_DSN" -c 'ALTER TABLE accounts RENAME COLUMN label TO name;'
psql "$TARGET_DSN" -c 'ALTER TABLE accounts RENAME COLUMN label TO name;'
# 3. Resume — the same --stream-id warm-resumes from the persisted CDC position
sluice sync start \
--stream-id app-prod \
--source-driver mysql --source 'root:rootpw@tcp(localhost:3306)/app' \
--target-driver postgres --target 'postgres://...target...'
There is no resume flag to pass: re-invoking sync start with the same --stream-id finds that stream's persisted CDC position (source LSN / GTID set / VStream cursor) and continues from it, so pre-stop events apply cleanly and the first event after resume sees the new shape on both sides. It does not re-run the snapshot, and it never bulk-copies into the populated target. The order "stop → ALTER source → ALTER target → start" is robust regardless of which side commits the DDL first, as long as both sides carry the new shape before resume. On a Postgres source the persisted position is the last committed transaction's end LSN — the first byte after its commit record — so the resumed stream begins at the next transaction rather than re-delivering the one it closed (v0.138.0; through v0.137.4 a Postgres warm resume replayed the last applied transaction with its pre-DDL relation shape, and a refusal re-fired on the restart this runbook prescribes). A schema change applied to the source only, while the stream was stopped, is checked at resume against the target since v0.156.10 — forwarded, kept with a WARN, or refused with RESUME-SCHEMA-DIVERGENCE or SCHEMA-CHANGE-REFUSED (through v0.156.9 it was not classified at all). Applying it on both sides before restarting remains the predictable path.
A table rename done this way resumes on its own (v0.163.0). Rename the table on both sides while the stream is stopped (ALTER TABLE t RENAME TO t2 on Postgres, RENAME TABLE t TO t2 on MySQL-family), then restart: the stream's record of the tables it copied keeps each table's engine-stable id (Postgres pg_class.oid, InnoDB TABLE_ID), recognises t2 as t renamed, and follows it — provided t2 is in the stream's scope: with a literal --include-table, the restart must name t2 (otherwise t2 is out of scope, nothing follows, and t leaves the stream's record as gone from the source). If the target was not renamed — it still holds t, or lacks t2 — the restart refuses INCLUDE-TABLE-SCOPE-DRIFT saying so; a target that holds an unrelated t2 beside the stream's t refuses the same way (drop or rename that t2 away, then rename t). A table dropped and re-created under the new name is a different table (a new id) and refuses as never copied. Where no id proves the rename — a PlanetScale/Vitess source, a table name InnoDB stores encoded, or a stream whose record has no ids — the refusal names --accept-table-rename <old>=<new>: pass it once on that restart, after renaming the target too, and the record follows the rename; the target t2 is then trusted as-is (details).
A table swap is not a rename the stream follows. Swapping two tables while the stream is stopped (RENAME TABLE a TO tmp, b TO a, tmp TO b, or three ALTER TABLE … RENAMEs) leaves every name present, and the restart refuses TABLE-IDENTITY-CHANGED naming which table each name now holds: the target copies are under the old names, so applying would mix the two. Re-copy the swapped tables (empty each target table and sluice schema add-table it), or the whole stream with --restart-from-scratch. On Postgres a table dropped and re-created under the same name refuses the same way; on MySQL-family it does not, because InnoDB gives a table a new id on routine rebuilds (OPTIMIZE TABLE, a copying ALTER, TRUNCATE) — there the record takes the new id.
sluice schema diff runs the source schema through sluice's translation pipeline and reports drift against the target's actual schema — apply the ALTER on the source, run the diff, and it surfaces the missing-on-target columns / type mismatches with suggested ALTER statements as a starting point. It does not know your data volume or lock duration, so review them before running.Next steps #
- sync start reference — the
--schema-changesrow and the full sync flag set. - Migrate MySQL to Postgres — the one-shot migration the drained model resumes onto.
- schema preview / diff — pre-flight the drift, then apply the target-side change yourself (or via deploy-ddl).
