This is an automated email from the ASF dual-hosted git repository.

nzw921rx pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 2aa67dbd37 [Fix][CDC] Keep per-table restored offsets when tables are 
added across a savepoint (#13006)
2aa67dbd37 is described below

commit 2aa67dbd370d1591255da9a51d8ef70f01373010
Author: Goutam Adwant <[email protected]>
AuthorDate: Tue Oct 27 22:08:26 2026 -0700

    [Fix][CDC] Keep per-table restored offsets when tables are added across a 
savepoint (#13006)
---
 .../enumerator/IncrementalSplitAssigner.java       |  37 ++++-
 .../external/IncrementalSourceStreamFetcher.java   |  36 +++-
 .../cdc/base/source/split/IncrementalSplit.java    |  52 +++++-
 .../source/split/state/IncrementalSplitState.java  |  30 +++-
 .../enumerator/IncrementalSplitAssignerTest.java   |  99 +++++++++++
 .../IncrementalSourceStreamFetcherTest.java        | 181 +++++++++++++++++++++
 .../split/state/IncrementalSplitStateTest.java     |  33 ++++
 .../cdc/mysql/AbstractMysqlCDCITBase.java          | 113 ++++++++++++-
 ..._mysql_with_exactly_once_restore_add_table.conf |  68 ++++++++
 ...mysql_with_exactly_once_restore_two_tables.conf |  62 +++++++
 10 files changed, 699 insertions(+), 12 deletions(-)

diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssigner.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssigner.java
index aa994efd4a..5755cd2631 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssigner.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssigner.java
@@ -72,6 +72,14 @@ public class IncrementalSplitAssigner<C extends 
SourceConfig> implements SplitAs
      */
     private final Map<TableId, Offset> tableWatermarks = new HashMap<>();
 
+    /**
+     * Offset each restored table had already been read up to. It is the 
startup offset of the split
+     * the table was restored from, or a later offset that split still carried 
for the table. Unlike
+     * {@link #tableWatermarks} it does not decide where an incremental split 
starts; it only tells
+     * the reader which records of the table were already emitted. Used to 
support Exactly-Once.
+     */
+    private final Map<TableId, Offset> tableStartupOffsets = new HashMap<>();
+
     private boolean splitAssigned = false;
 
     private final List<IncrementalSplit> remainingSplits = new ArrayList<>();
@@ -195,6 +203,13 @@ public class IncrementalSplitAssigner<C extends 
SourceConfig> implements SplitAs
                                     continue;
                                 }
                                 tableWatermarks.put(tableId, startupOffset);
+                                tableStartupOffsets.put(
+                                        tableId,
+                                        laterOf(
+                                                startupOffset,
+                                                incrementalSplit
+                                                        
.getTableStartupOffsets()
+                                                        .get(tableId)));
                             }
                             if (this.startupOffset == null) {
                                 this.startupOffset = startupOffset;
@@ -320,6 +335,18 @@ public class IncrementalSplitAssigner<C extends 
SourceConfig> implements SplitAs
             startupOffset = 
sourceConfig.getStartupConfig().getStartupOffset(offsetFactory);
         }
         Offset incrementalSplitStartOffset = minOffset != null ? minOffset : 
startupOffset;
+        // Restored tables from different splits can be regrouped into this 
split, which starts at
+        // the earliest of their offsets. Keep the offset of every table that 
was already read
+        // further, so the reader does not emit those records a second time.
+        Map<TableId, Offset> splitTableStartupOffsets = new HashMap<>();
+        for (TableId tableId : capturedTables) {
+            Offset tableStartupOffset = tableStartupOffsets.get(tableId);
+            if (tableStartupOffset != null
+                    && incrementalSplitStartOffset != null
+                    && 
tableStartupOffset.isAfter(incrementalSplitStartOffset)) {
+                splitTableStartupOffsets.put(tableId, tableStartupOffset);
+            }
+        }
         // stop.mode=latest: resolve the stop offset exactly once here, at the 
first
         // incremental split creation, and reuse the same value for every 
subsequent split
         // and for the checkpoint. This is the single authoritative resolution 
point (the
@@ -348,7 +375,15 @@ public class IncrementalSplitAssigner<C extends 
SourceConfig> implements SplitAs
                 incrementalSplitStopOffset,
                 completedSnapshotSplitInfos,
                 checkpointTables,
-                historyTableChanges);
+                historyTableChanges,
+                splitTableStartupOffsets);
+    }
+
+    private static Offset laterOf(Offset offset, Offset other) {
+        if (offset == null) {
+            return other;
+        }
+        return other != null && other.isAfter(offset) ? other : offset;
     }
 
     @VisibleForTesting
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcher.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcher.java
index 03df95855e..54695e56e5 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcher.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcher.java
@@ -85,6 +85,8 @@ public class IncrementalSourceStreamFetcher implements 
Fetcher<SourceRecords, So
     private Map<TableId, Offset> maxSplitHighWatermarkMap;
     // finished spilt info
     private Map<TableId, List<CompletedSnapshotSplitInfo>> finishedSplitsInfo;
+    // offsets restored tables had already been read up to
+    private Map<TableId, Offset> tableStartupOffsets;
 
     private static final long READER_CLOSE_TIMEOUT_SECONDS = 30L;
 
@@ -282,6 +284,10 @@ public class IncrementalSourceStreamFetcher implements 
Fetcher<SourceRecords, So
             }
             // not enter pure binlog mode and need to check whether the 
current record meets the
             // emitting conditions.
+            Offset tableStartupOffset = tableStartupOffsets.get(tableId);
+            if (tableStartupOffset != null && 
position.isBefore(tableStartupOffset)) {
+                return false;
+            }
             if (finishedSplitsInfo.containsKey(tableId)) {
                 for (CompletedSnapshotSplitInfo splitInfo : 
finishedSplitsInfo.get(tableId)) {
                     if (taskContext.isRecordBetween(
@@ -316,16 +322,11 @@ public class IncrementalSourceStreamFetcher implements 
Fetcher<SourceRecords, So
         splitStartWatermark = currentIncrementalSplit.getStartupOffset();
         Map<TableId, List<CompletedSnapshotSplitInfo>> splitsInfoMap = new 
HashMap<>();
         Map<TableId, Offset> tableIdBinlogPositionMap = new HashMap<>();
+        Map<TableId, Offset> restoredTableOffsets =
+                currentIncrementalSplit.getTableStartupOffsets();
         List<CompletedSnapshotSplitInfo> completedSnapshotSplitInfos =
                 currentIncrementalSplit.getCompletedSnapshotSplitInfos();
 
-        // latest-offset mode
-        if (completedSnapshotSplitInfos.isEmpty()) {
-            for (TableId tableId : currentIncrementalSplit.getTableIds()) {
-                tableIdBinlogPositionMap.put(tableId, 
currentIncrementalSplit.getStartupOffset());
-            }
-        }
-
         // calculate the max high watermark of every table
         for (CompletedSnapshotSplitInfo finishedSplitInfo : 
completedSnapshotSplitInfos) {
             TableId tableId = finishedSplitInfo.getTableId();
@@ -340,8 +341,29 @@ public class IncrementalSourceStreamFetcher implements 
Fetcher<SourceRecords, So
                 tableIdBinlogPositionMap.put(tableId, highWatermark);
             }
         }
+
+        for (TableId tableId : currentIncrementalSplit.getTableIds()) {
+            Offset restoredOffset = restoredTableOffsets.get(tableId);
+            Offset maxHighWatermark = tableIdBinlogPositionMap.get(tableId);
+            if (maxHighWatermark == null) {
+                // The table has no snapshot split in this split: either the 
split starts from a
+                // log offset (latest-offset mode), or the table was already 
in the pure
+                // incremental phase before a restore that added other tables. 
Emit its changes
+                // from the offset it was already read up to. As in 
latest-offset mode, a record
+                // at exactly that offset is emitted: some dialects (e.g. 
Oracle SCN) share one
+                // offset between several records, so skipping it could lose 
data.
+                Offset tableStartupOffset =
+                        restoredOffset != null ? restoredOffset : 
splitStartWatermark;
+                if (tableStartupOffset != null) {
+                    tableIdBinlogPositionMap.put(tableId, tableStartupOffset);
+                }
+            } else if (restoredOffset != null && 
restoredOffset.isAfter(maxHighWatermark)) {
+                tableIdBinlogPositionMap.put(tableId, restoredOffset);
+            }
+        }
         this.finishedSplitsInfo = splitsInfoMap;
         this.maxSplitHighWatermarkMap = tableIdBinlogPositionMap;
+        this.tableStartupOffsets = restoredTableOffsets;
         this.pureBinlogPhaseTables.clear();
     }
 
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/IncrementalSplit.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/IncrementalSplit.java
index 39cb117c68..9ea1af5c11 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/IncrementalSplit.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/IncrementalSplit.java
@@ -28,6 +28,7 @@ import lombok.ToString;
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -63,6 +64,16 @@ public class IncrementalSplit extends SourceSplitBase {
     // debezium history table changes
     private final Map<TableId, byte[]> historyTableChanges;
 
+    /**
+     * Offsets that tables restored from a checkpoint had already been read up 
to, kept only for
+     * tables whose offset is after {@link #startupOffset}. This happens when 
tables restored from
+     * different incremental splits are regrouped into one split, which then 
starts at the earliest
+     * of their offsets. Used to support Exactly-Once.
+     *
+     * <p>Null for splits restored from checkpoints written before this field 
existed.
+     */
+    private final Map<TableId, Offset> tableStartupOffsets;
+
     public IncrementalSplit(
             String splitId,
             List<TableId> capturedTables,
@@ -101,7 +112,8 @@ public class IncrementalSplit extends SourceSplitBase {
                 split.getStopOffset(),
                 split.getCompletedSnapshotSplitInfos(),
                 tables,
-                historyTableChanges);
+                historyTableChanges,
+                split.getTableStartupOffsets());
     }
 
     @Deprecated
@@ -119,6 +131,7 @@ public class IncrementalSplit extends SourceSplitBase {
         this.completedSnapshotSplitInfos = completedSnapshotSplitInfos;
         this.checkpointDataType = checkpointDataType;
         this.historyTableChanges = new HashMap<>();
+        this.tableStartupOffsets = new HashMap<>();
     }
 
     public IncrementalSplit(
@@ -129,6 +142,26 @@ public class IncrementalSplit extends SourceSplitBase {
             List<CompletedSnapshotSplitInfo> completedSnapshotSplitInfos,
             List<CatalogTable> checkpointTables,
             Map<TableId, byte[]> historyTableChanges) {
+        this(
+                splitId,
+                capturedTables,
+                startupOffset,
+                stopOffset,
+                completedSnapshotSplitInfos,
+                checkpointTables,
+                historyTableChanges,
+                new HashMap<>());
+    }
+
+    public IncrementalSplit(
+            String splitId,
+            List<TableId> capturedTables,
+            Offset startupOffset,
+            Offset stopOffset,
+            List<CompletedSnapshotSplitInfo> completedSnapshotSplitInfos,
+            List<CatalogTable> checkpointTables,
+            Map<TableId, byte[]> historyTableChanges,
+            Map<TableId, Offset> tableStartupOffsets) {
         super(splitId);
         this.tableIds = capturedTables;
         this.startupOffset = startupOffset;
@@ -136,6 +169,16 @@ public class IncrementalSplit extends SourceSplitBase {
         this.completedSnapshotSplitInfos = completedSnapshotSplitInfos;
         this.checkpointTables = checkpointTables;
         this.historyTableChanges = historyTableChanges;
+        this.tableStartupOffsets = tableStartupOffsets;
+    }
+
+    /**
+     * Returns the offsets restored tables had already been read up to, keyed 
by table.
+     *
+     * @return the per-table offsets, empty for splits restored from older 
checkpoints
+     */
+    public Map<TableId, Offset> getTableStartupOffsets() {
+        return tableStartupOffsets == null ? Collections.emptyMap() : 
tableStartupOffsets;
     }
 
     /**
@@ -184,6 +227,10 @@ public class IncrementalSplit extends SourceSplitBase {
                         : historyTableChanges.entrySet().stream()
                                 .filter(entry -> 
capturedTableSet.contains(entry.getKey()))
                                 .collect(Collectors.toMap(Map.Entry::getKey, 
Map.Entry::getValue));
+        Map<TableId, Offset> filteredTableStartupOffsets =
+                getTableStartupOffsets().entrySet().stream()
+                        .filter(entry -> 
capturedTableSet.contains(entry.getKey()))
+                        .collect(Collectors.toMap(Map.Entry::getKey, 
Map.Entry::getValue));
         IncrementalSplit prunedSplit =
                 new IncrementalSplit(
                         splitId(),
@@ -192,7 +239,8 @@ public class IncrementalSplit extends SourceSplitBase {
                         stopOffset,
                         filteredCompletedSnapshotSplitInfos,
                         filteredCheckpointTables,
-                        filteredHistoryTableChanges);
+                        filteredHistoryTableChanges,
+                        filteredTableStartupOffsets);
         // Keep compatibility with checkpoints created before table-level 
schema history.
         prunedSplit.checkpointDataType = checkpointDataType;
         return prunedSplit;
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitState.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitState.java
index c04026bf1e..e35d6ca200 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitState.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitState.java
@@ -24,8 +24,11 @@ import io.debezium.relational.TableId;
 import lombok.Getter;
 import lombok.Setter;
 
+import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /** The state of split to describe the change log of table(s). */
 @Getter
@@ -43,11 +46,15 @@ public class IncrementalSplitState extends 
SourceSplitStateBase {
     private Offset maxSnapshotSplitsHighWatermark;
     private volatile boolean enterPureIncrementPhase;
 
+    /** Restored per-table offsets, see {@link 
IncrementalSplit#getTableStartupOffsets()}. */
+    private Map<TableId, Offset> tableStartupOffsets;
+
     public IncrementalSplitState(IncrementalSplit split) {
         super(split);
         this.tableIds = split.getTableIds();
         this.startupOffset = split.getStartupOffset();
         this.stopOffset = split.getStopOffset();
+        this.tableStartupOffsets = split.getTableStartupOffsets();
 
         if (split.getCompletedSnapshotSplitInfos().isEmpty()) {
             this.maxSnapshotSplitsHighWatermark = null;
@@ -71,7 +78,28 @@ public class IncrementalSplitState extends 
SourceSplitStateBase {
                 getTableIds(),
                 getStartupOffset(),
                 getStopOffset(),
-                incrementalSplit.getCompletedSnapshotSplitInfos());
+                incrementalSplit.getCompletedSnapshotSplitInfos(),
+                new ArrayList<>(),
+                new HashMap<>(),
+                pendingTableStartupOffsets());
+    }
+
+    /**
+     * A restored table offset only needs to be checkpointed until the split 
has read past it, so
+     * entries at or before the current startup offset are dropped.
+     */
+    private Map<TableId, Offset> pendingTableStartupOffsets() {
+        Map<TableId, Offset> pending = new HashMap<>();
+        if (tableStartupOffsets == null) {
+            return pending;
+        }
+        tableStartupOffsets.forEach(
+                (tableId, offset) -> {
+                    if (startupOffset == null || 
offset.isAfter(startupOffset)) {
+                        pending.put(tableId, offset);
+                    }
+                });
+        return pending;
     }
 
     public synchronized boolean markEnterPureIncrementPhaseIfNeed(Offset 
currentRecordPosition) {
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssignerTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssignerTest.java
index c02c79bc1d..67591fb024 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssignerTest.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSplitAssignerTest.java
@@ -31,8 +31,10 @@ import org.junit.jupiter.api.Test;
 
 import io.debezium.relational.TableId;
 
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.Optional;
 import java.util.Set;
@@ -235,6 +237,87 @@ class IncrementalSplitAssignerTest {
         verify(offsetFactory, never()).latest();
     }
 
+    @Test
+    void 
shouldStartRegroupedSplitAtEarliestRestoredOffsetAndKeepLaterTableOffsets() {
+        SourceConfig sourceConfig = mock(SourceConfig.class);
+        when(sourceConfig.getStartupConfig())
+                .thenReturn(new StartupConfig(StartupMode.INITIAL, null, null, 
null));
+        when(sourceConfig.getStopConfig())
+                .thenReturn(new StopConfig(StopMode.NEVER, null, null, null));
+        TableId table1 = TableId.parse("database.schema.table1");
+        TableId table2 = TableId.parse("database.schema.table2");
+        Offset stoppingOffset = mock(Offset.class);
+
+        // incremental.parallelism was 2: each table was read by its own 
split, and the split of
+        // table2 had read further than the split of table1 when the 
checkpoint was taken.
+        IncrementalSplit restoredSplit1 =
+                new IncrementalSplit(
+                        "incremental-split-0",
+                        Collections.singletonList(table1),
+                        new TestOffset(100),
+                        stoppingOffset,
+                        Collections.emptyList());
+        IncrementalSplit restoredSplit2 =
+                new IncrementalSplit(
+                        "incremental-split-1",
+                        Collections.singletonList(table2),
+                        new TestOffset(200),
+                        stoppingOffset,
+                        Collections.emptyList());
+        IncrementalSplitAssigner<SourceConfig> assigner =
+                new IncrementalSplitAssigner<>(
+                        createContext(sourceConfig, new 
HashSet<>(Arrays.asList(table1, table2))),
+                        1,
+                        mock(OffsetFactory.class));
+        assigner.addSplits(Arrays.asList(restoredSplit1, restoredSplit2));
+
+        IncrementalSplit split = assigner.getNext().get().asIncrementalSplit();
+
+        assertEquals(
+                new HashSet<>(Arrays.asList(table1, table2)), new 
HashSet<>(split.getTableIds()));
+        assertEquals(new TestOffset(100), split.getStartupOffset());
+        assertEquals(
+                Collections.singletonMap(table2, new TestOffset(200)),
+                split.getTableStartupOffsets());
+    }
+
+    @Test
+    void shouldKeepTableOffsetCarriedByRestoredSplit() {
+        SourceConfig sourceConfig = mock(SourceConfig.class);
+        when(sourceConfig.getStartupConfig())
+                .thenReturn(new StartupConfig(StartupMode.INITIAL, null, null, 
null));
+        when(sourceConfig.getStopConfig())
+                .thenReturn(new StopConfig(StopMode.NEVER, null, null, null));
+        TableId table1 = TableId.parse("database.schema.table1");
+        TableId table2 = TableId.parse("database.schema.table2");
+
+        // The split was checkpointed at 150 while it was still replaying up 
to table2's
+        // restored offset 200, so it carries that offset for table2.
+        IncrementalSplit restoredSplit =
+                new IncrementalSplit(
+                        "incremental-split-0",
+                        Arrays.asList(table1, table2),
+                        new TestOffset(150),
+                        mock(Offset.class),
+                        Collections.emptyList(),
+                        null,
+                        Collections.emptyMap(),
+                        Collections.singletonMap(table2, new TestOffset(200)));
+        IncrementalSplitAssigner<SourceConfig> assigner =
+                new IncrementalSplitAssigner<>(
+                        createContext(sourceConfig, new 
HashSet<>(Arrays.asList(table1, table2))),
+                        1,
+                        mock(OffsetFactory.class));
+        assigner.addSplits(Collections.singletonList(restoredSplit));
+
+        IncrementalSplit split = assigner.getNext().get().asIncrementalSplit();
+
+        assertEquals(new TestOffset(150), split.getStartupOffset());
+        assertEquals(
+                Collections.singletonMap(table2, new TestOffset(200)),
+                split.getTableStartupOffsets());
+    }
+
     private SplitAssigner.Context<SourceConfig> createContext(SourceConfig 
sourceConfig) {
         return createContext(
                 sourceConfig, 
Collections.singleton(TableId.parse("database.schema.table")));
@@ -248,4 +331,20 @@ class IncrementalSplitAssignerTest {
                 new HashMap<>(),
                 new HashMap<>());
     }
+
+    private static class TestOffset extends Offset {
+        private static final long serialVersionUID = 1L;
+
+        private final long position;
+
+        TestOffset(long position) {
+            this.position = position;
+            this.offset = Collections.singletonMap("pos", 
String.valueOf(position));
+        }
+
+        @Override
+        public int compareTo(Offset o) {
+            return Long.compare(position, ((TestOffset) o).position);
+        }
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcherTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcherTest.java
index ab3f93883e..4cf3478cbf 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcherTest.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/reader/external/IncrementalSourceStreamFetcherTest.java
@@ -18,7 +18,9 @@
 package org.apache.seatunnel.connectors.cdc.base.source.reader.external;
 
 import org.apache.seatunnel.connectors.cdc.base.schema.SchemaChangeResolver;
+import 
org.apache.seatunnel.connectors.cdc.base.source.event.SnapshotSplitWatermark;
 import org.apache.seatunnel.connectors.cdc.base.source.offset.Offset;
+import 
org.apache.seatunnel.connectors.cdc.base.source.split.CompletedSnapshotSplitInfo;
 import org.apache.seatunnel.connectors.cdc.base.source.split.IncrementalSplit;
 import org.apache.seatunnel.connectors.cdc.base.source.split.SourceRecords;
 import org.apache.seatunnel.connectors.cdc.base.source.split.SourceSplitBase;
@@ -598,6 +600,185 @@ public class IncrementalSourceStreamFetcherTest {
         Assertions.assertFalse(fetcher.shouldEmit(record));
     }
 
+    @Test
+    public void 
testShouldEmitRestoredTableChangesWhileAddedTableIsSnapshotting() {
+        // Restored after a savepoint that captured old_table, with new_table 
added to the
+        // configuration: the re-created split only carries snapshot info for 
new_table.
+        TableId oldTable = new TableId("testdb", "public", "old_table");
+        TableId newTable = new TableId("testdb", "public", "new_table");
+        IncrementalSplit split =
+                new IncrementalSplit(
+                        "incremental-split-0",
+                        Arrays.asList(oldTable, newTable),
+                        new TestOffset(100),
+                        null,
+                        
Collections.singletonList(completedSnapshotSplitInfo(newTable, 140, 150)));
+
+        IncrementalSourceStreamFetcher fetcher = submitExactlyOnceTask(split);
+        try {
+            Assertions.assertTrue(
+                    fetcher.shouldEmit(createDataEventWithSource(oldTable, 
120)),
+                    "change of the restored table between its checkpoint 
offset and the added table's high watermark was dropped");
+            
Assertions.assertTrue(fetcher.shouldEmit(createDataEventWithSource(oldTable, 
160)));
+            
Assertions.assertFalse(fetcher.shouldEmit(createDataEventWithSource(newTable, 
120)));
+            
Assertions.assertTrue(fetcher.shouldEmit(createDataEventWithSource(newTable, 
160)));
+        } finally {
+            fetcher.close();
+        }
+    }
+
+    @Test
+    public void testShouldEmitRegroupedRestoredTablesFromTheirOwnOffsets() {
+        // With incremental.parallelism > 1, table1 and table2 were read by 
different splits and
+        // checkpointed at 100 and 200. After the restore they share one split 
that starts at 100.
+        TableId table1 = new TableId("testdb", "public", "table1");
+        TableId table2 = new TableId("testdb", "public", "table2");
+        TableId newTable = new TableId("testdb", "public", "new_table");
+        IncrementalSplit split =
+                new IncrementalSplit(
+                        "incremental-split-0",
+                        Arrays.asList(table1, table2, newTable),
+                        new TestOffset(100),
+                        null,
+                        
Collections.singletonList(completedSnapshotSplitInfo(newTable, 140, 150)),
+                        null,
+                        Collections.emptyMap(),
+                        Collections.singletonMap(table2, new TestOffset(200)));
+
+        IncrementalSourceStreamFetcher fetcher = 
submitTask(exactlyOnceTaskContext(), split);
+        try {
+            
Assertions.assertTrue(fetcher.shouldEmit(createDataEventWithSource(table1, 
120)));
+            Assertions.assertFalse(
+                    fetcher.shouldEmit(createDataEventWithSource(table2, 150)),
+                    "change of table2 already emitted before the checkpoint 
was emitted again");
+            
Assertions.assertFalse(fetcher.shouldEmit(createDataEventWithSource(table2, 
199)));
+            
Assertions.assertTrue(fetcher.shouldEmit(createDataEventWithSource(table2, 
200)));
+            
Assertions.assertTrue(fetcher.shouldEmit(createDataEventWithSource(table2, 
250)));
+            
Assertions.assertFalse(fetcher.shouldEmit(createDataEventWithSource(newTable, 
120)));
+            
Assertions.assertTrue(fetcher.shouldEmit(createDataEventWithSource(newTable, 
160)));
+        } finally {
+            fetcher.close();
+        }
+    }
+
+    @Test
+    public void 
testShouldNotEmitSnapshotRangeChangesBeforeRestoredTableOffset() {
+        // The table was checkpointed at 200 before its split entered the pure 
incremental phase,
+        // and is regrouped into a split that starts earlier.
+        TableId table = new TableId("testdb", "public", "table1");
+        IncrementalSplit split =
+                new IncrementalSplit(
+                        "incremental-split-0",
+                        Collections.singletonList(table),
+                        new TestOffset(100),
+                        null,
+                        
Collections.singletonList(completedSnapshotSplitInfo(table, 140, 150)),
+                        null,
+                        Collections.emptyMap(),
+                        Collections.singletonMap(table, new TestOffset(200)));
+        FetchTask.Context taskContext = exactlyOnceTaskContext();
+        when(taskContext.isRecordBetween(any(), any(), 
any())).thenReturn(true);
+
+        IncrementalSourceStreamFetcher fetcher = submitTask(taskContext, 
split);
+        try {
+            
Assertions.assertFalse(fetcher.shouldEmit(createDataEventWithSource(table, 
170)));
+            
Assertions.assertTrue(fetcher.shouldEmit(createDataEventWithSource(table, 
200)));
+        } finally {
+            fetcher.close();
+        }
+    }
+
+    private static IncrementalSourceStreamFetcher 
submitExactlyOnceTask(IncrementalSplit split) {
+        return submitTask(exactlyOnceTaskContext(), split);
+    }
+
+    private static FetchTask.Context exactlyOnceTaskContext() {
+        FetchTask.Context taskContext = mock(FetchTask.Context.class);
+        when(taskContext.isExactlyOnce()).thenReturn(true);
+        when(taskContext.isDataChangeRecord(any())).thenReturn(true);
+        when(taskContext.getStreamOffset(any()))
+                .thenAnswer(
+                        invocation -> {
+                            SourceRecord record = invocation.getArgument(0);
+                            return new TestOffset(
+                                    Long.parseLong((String) 
record.sourceOffset().get("pos")));
+                        });
+        return taskContext;
+    }
+
+    private static IncrementalSourceStreamFetcher submitTask(
+            FetchTask.Context taskContext, IncrementalSplit split) {
+        FetchTask<SourceSplitBase> fetchTask = mock(FetchTask.class);
+        when(fetchTask.getSplit()).thenReturn(split);
+
+        IncrementalSourceStreamFetcher fetcher =
+                new IncrementalSourceStreamFetcher(taskContext, 0, null);
+        fetcher.submitTask(fetchTask);
+        return fetcher;
+    }
+
+    private static CompletedSnapshotSplitInfo completedSnapshotSplitInfo(
+            TableId tableId, long lowWatermark, long highWatermark) {
+        String splitId = tableId + ":0";
+        return new CompletedSnapshotSplitInfo(
+                splitId,
+                tableId,
+                null,
+                null,
+                null,
+                new SnapshotSplitWatermark(
+                        splitId, new TestOffset(lowWatermark), new 
TestOffset(highWatermark)));
+    }
+
+    static SourceRecord createDataEventWithSource(TableId tableId, long 
position) {
+        Schema sourceSchema =
+                SchemaBuilder.struct()
+                        .name("io.debezium.connector.postgresql.Source")
+                        .field("db", Schema.STRING_SCHEMA)
+                        .field("schema", Schema.STRING_SCHEMA)
+                        .field("table", Schema.STRING_SCHEMA)
+                        .field(DEBEZIUM_CONNECTOR_KEY, Schema.STRING_SCHEMA)
+                        .build();
+        Struct sourceStruct = new Struct(sourceSchema);
+        sourceStruct.put("db", tableId.catalog());
+        sourceStruct.put("schema", tableId.schema());
+        sourceStruct.put("table", tableId.table());
+        sourceStruct.put(DEBEZIUM_CONNECTOR_KEY, "postgresql");
+
+        Schema valueSchema =
+                SchemaBuilder.struct()
+                        .field(Envelope.FieldName.SOURCE, sourceSchema)
+                        .field(Envelope.FieldName.OPERATION, 
Schema.STRING_SCHEMA)
+                        .build();
+        Struct value = new Struct(valueSchema);
+        value.put(Envelope.FieldName.SOURCE, sourceStruct);
+        value.put(Envelope.FieldName.OPERATION, "u");
+        return new SourceRecord(
+                Collections.emptyMap(),
+                Collections.singletonMap("pos", String.valueOf(position)),
+                tableId.toString(),
+                null,
+                null,
+                valueSchema,
+                value);
+    }
+
+    static class TestOffset extends Offset {
+        private static final long serialVersionUID = 1L;
+
+        private final long position;
+
+        TestOffset(long position) {
+            this.position = position;
+            this.offset = Collections.singletonMap("pos", 
String.valueOf(position));
+        }
+
+        @Override
+        public int compareTo(Offset o) {
+            return Long.compare(position, ((TestOffset) o).position);
+        }
+    }
+
     public static class TestConnectorConfig extends CommonConnectorConfig {
 
         protected TestConnectorConfig(
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitStateTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitStateTest.java
index 70fa137b16..b008250a9a 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitStateTest.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/split/state/IncrementalSplitStateTest.java
@@ -32,7 +32,9 @@ import lombok.ToString;
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -130,6 +132,37 @@ public class IncrementalSplitStateTest {
         
Assertions.assertFalse(splitState.autoEnterPureIncrementPhaseIfAllowed());
     }
 
+    @Test
+    public void 
testCheckpointKeepsRestoredTableOffsetsUntilTheSplitReadsPastThem() {
+        TableId table1 = new TableId("db", "schema", "table1");
+        TableId table2 = new TableId("db", "schema", "table2");
+        Map<TableId, Offset> tableStartupOffsets = new HashMap<>();
+        tableStartupOffsets.put(table1, new TestOffset(150));
+        tableStartupOffsets.put(table2, new TestOffset(200));
+        IncrementalSplit split =
+                new IncrementalSplit(
+                        "test",
+                        Arrays.asList(table1, table2),
+                        new TestOffset(100),
+                        null,
+                        Collections.emptyList(),
+                        null,
+                        Collections.emptyMap(),
+                        tableStartupOffsets);
+        IncrementalSplitState splitState = new IncrementalSplitState(split);
+
+        Assertions.assertEquals(
+                tableStartupOffsets, 
splitState.toSourceSplit().getTableStartupOffsets());
+
+        splitState.setStartupOffset(new TestOffset(150));
+        Assertions.assertEquals(
+                Collections.singletonMap(table2, new TestOffset(200)),
+                splitState.toSourceSplit().getTableStartupOffsets());
+
+        splitState.setStartupOffset(new TestOffset(200));
+        
Assertions.assertTrue(splitState.toSourceSplit().getTableStartupOffsets().isEmpty());
+    }
+
     private static IncrementalSplit createIncrementalSplit(
             Offset startupOffset, List<CompletedSnapshotSplitInfo> 
snapshotSplits) {
         return new IncrementalSplit(
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
index ede5b78ebb..e403266b80 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
@@ -65,6 +65,7 @@ public abstract class AbstractMysqlCDCITBase extends 
TestSuiteBase implements Te
     protected static final String MYSQL_USER_PASSWORD = "mysqlpw";
     protected static final String MYSQL_DATABASE = "mysql_cdc";
     private static final String MYSQL_DATABASE2 = "mysql_cdc2";
+    private static final String APPEND_SINK_DATABASE = "mysql_cdc_append";
 
     private final String QUERY_SQL = "select * from %s.%s";
 
@@ -1053,6 +1054,111 @@ public abstract class AbstractMysqlCDCITBase extends 
TestSuiteBase implements Te
         log.info("****************** container logs end ******************");
     }
 
+    @TestTemplate
+    @DisabledOnContainer(
+            value = {},
+            type = {EngineType.SPARK, EngineType.FLINK},
+            disabledReason = "Currently SPARK and FLINK do not support 
restore")
+    public void 
testExactlyOnceRestoreWithAddedTableKeepsChangesOfExistingTables(
+            TestContainer container) throws IOException, InterruptedException {
+        String[] tables = {SOURCE_TABLE_1, SOURCE_TABLE_2, 
SOURCE_TABLE_1_CUSTOM_PRIMARY_KEY};
+        for (String table : tables) {
+            clearTable(MYSQL_DATABASE, table);
+            clearTable(MYSQL_DATABASE2, table);
+            createAppendOnlySinkTable(table);
+        }
+
+        initSourceTable(MYSQL_DATABASE, SOURCE_TABLE_1);
+        initSourceTable(MYSQL_DATABASE, SOURCE_TABLE_2);
+
+        Long jobId = JobIdGenerator.newJobId();
+        CompletableFuture.supplyAsync(
+                () -> {
+                    try {
+                        return container.executeJob(
+                                
"/mysqlcdc_to_mysql_with_exactly_once_restore_two_tables.conf",
+                                String.valueOf(jobId));
+                    } catch (Exception e) {
+                        log.error("Commit task exception :" + e.getMessage());
+                        throw new RuntimeException(e);
+                    }
+                });
+        awaitSinksMatchSource(SOURCE_TABLE_1, SOURCE_TABLE_2);
+
+        insertSourceTableRows(MYSQL_DATABASE, SOURCE_TABLE_1, 100, 110);
+        insertSourceTableRows(MYSQL_DATABASE, SOURCE_TABLE_2, 100, 110);
+        awaitSinksMatchSource(SOURCE_TABLE_1, SOURCE_TABLE_2);
+
+        Assertions.assertEquals(0, 
container.savepointJob(String.valueOf(jobId)).getExitCode());
+
+        // Changes made while the job is stopped are after the saved offset 
and before the
+        // snapshot of the added table.
+        insertSourceTableRows(MYSQL_DATABASE, SOURCE_TABLE_1, 200, 210);
+        insertSourceTableRows(MYSQL_DATABASE, SOURCE_TABLE_2, 200, 210);
+        initSourceTable(MYSQL_DATABASE, SOURCE_TABLE_1_CUSTOM_PRIMARY_KEY);
+
+        CompletableFuture.supplyAsync(
+                () -> {
+                    try {
+                        container.restoreJob(
+                                
"/mysqlcdc_to_mysql_with_exactly_once_restore_add_table.conf",
+                                String.valueOf(jobId));
+                    } catch (Exception e) {
+                        log.error("Commit task exception :" + e.getMessage());
+                        throw new RuntimeException(e);
+                    }
+                    return null;
+                });
+        insertSourceTableRows(MYSQL_DATABASE, SOURCE_TABLE_1, 300, 310);
+
+        awaitSinksMatchSource(tables);
+
+        insertSourceTableRows(MYSQL_DATABASE, SOURCE_TABLE_2, 400, 410);
+        insertSourceTableRows(MYSQL_DATABASE, 
SOURCE_TABLE_1_CUSTOM_PRIMARY_KEY, 400, 410);
+        awaitSinksMatchSource(tables);
+
+        Assertions.assertEquals(0, 
container.cancelJob(String.valueOf(jobId)).getExitCode());
+    }
+
+    /**
+     * Creates a copy of the sink table without a primary key, so that every 
emitted row is appended
+     * and a change emitted twice shows up as an extra row.
+     */
+    private void createAppendOnlySinkTable(String tableName) {
+        String appendSinkTable = APPEND_SINK_DATABASE + "." + tableName;
+        executeSql("CREATE DATABASE IF NOT EXISTS " + APPEND_SINK_DATABASE);
+        executeSql("DROP TABLE IF EXISTS " + appendSinkTable);
+        executeSql(
+                "CREATE TABLE " + appendSinkTable + " LIKE " + MYSQL_DATABASE2 
+ "." + tableName);
+        executeSql("ALTER TABLE " + appendSinkTable + " MODIFY id INT NOT 
NULL, DROP PRIMARY KEY");
+    }
+
+    private void awaitSinksMatchSource(String... tableNames) {
+        await().atMost(300000, TimeUnit.MILLISECONDS)
+                .pollInterval(1000, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () -> {
+                            for (String tableName : tableNames) {
+                                List<List<Object>> sourceRows =
+                                        query(
+                                                
getSourceQuerySQL(MYSQL_DATABASE, tableName)
+                                                        + " order by id");
+                                Assertions.assertIterableEquals(
+                                        sourceRows,
+                                        query(
+                                                
getSourceQuerySQL(MYSQL_DATABASE2, tableName)
+                                                        + " order by id"),
+                                        tableName);
+                                Assertions.assertIterableEquals(
+                                        sourceRows,
+                                        query(
+                                                
getSourceQuerySQL(APPEND_SINK_DATABASE, tableName)
+                                                        + " order by id"),
+                                        APPEND_SINK_DATABASE + "." + 
tableName);
+                            }
+                        });
+    }
+
     @TestTemplate
     @DisabledOnContainer(
             value = {},
@@ -1818,7 +1924,12 @@ public abstract class AbstractMysqlCDCITBase extends 
TestSuiteBase implements Te
     }
 
     private void initSourceTable(String database, String tableName) {
-        for (int i = 1; i < 100; i++) {
+        insertSourceTableRows(database, tableName, 1, 100);
+    }
+
+    private void insertSourceTableRows(
+            String database, String tableName, int fromId, int toIdExclusive) {
+        for (int i = fromId; i < toIdExclusive; i++) {
             executeSql(
                     "INSERT INTO "
                             + database
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_exactly_once_restore_add_table.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_exactly_once_restore_add_table.conf
new file mode 100644
index 0000000000..379894e505
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_exactly_once_restore_add_table.conf
@@ -0,0 +1,68 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+######
+###### Restores the two-table job with a third table added
+######
+
+env {
+  parallelism = 1
+  job.mode = "STREAMING"
+  checkpoint.interval = 5000
+}
+
+source {
+  MySQL-CDC {
+    plugin_output = "customers_mysql_cdc"
+    server-id = 5652-5660
+    username = "st_user_source"
+    password = "mysqlpw"
+    table-names = ["mysql_cdc.mysql_cdc_e2e_source_table", 
"mysql_cdc.mysql_cdc_e2e_source_table2", 
"mysql_cdc.mysql_cdc_e2e_source_table_1_custom_primary_key"]
+    table-names-config = [
+      {
+        table = "mysql_cdc.mysql_cdc_e2e_source_table_1_custom_primary_key"
+        primaryKeys = ["id"]
+      }
+    ]
+    url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc"
+    exactly_once = true
+  }
+}
+
+sink {
+  jdbc {
+    plugin_input = "customers_mysql_cdc"
+    url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc2"
+    driver = "com.mysql.cj.jdbc.Driver"
+    user = "st_user_sink"
+    password = "mysqlpw"
+    database = "mysql_cdc2"
+    table = "${table_name}"
+    primary_keys = ["${primary_key}"]
+    generate_sink_sql = true
+  }
+
+  jdbc {
+    plugin_input = "customers_mysql_cdc"
+    url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc_append"
+    driver = "com.mysql.cj.jdbc.Driver"
+    user = "st_user_sink"
+    password = "mysqlpw"
+    database = "mysql_cdc_append"
+    table = "${table_name}"
+    generate_sink_sql = true
+  }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_exactly_once_restore_two_tables.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_exactly_once_restore_two_tables.conf
new file mode 100644
index 0000000000..f7aef0ac5c
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_exactly_once_restore_two_tables.conf
@@ -0,0 +1,62 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+######
+###### Exactly-once job on two tables, savepointed before a table is added
+######
+
+env {
+  parallelism = 1
+  job.mode = "STREAMING"
+  checkpoint.interval = 5000
+}
+
+source {
+  MySQL-CDC {
+    plugin_output = "customers_mysql_cdc"
+    server-id = 5652-5660
+    username = "st_user_source"
+    password = "mysqlpw"
+    table-names = ["mysql_cdc.mysql_cdc_e2e_source_table", 
"mysql_cdc.mysql_cdc_e2e_source_table2"]
+    url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc"
+    exactly_once = true
+  }
+}
+
+sink {
+  jdbc {
+    plugin_input = "customers_mysql_cdc"
+    url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc2"
+    driver = "com.mysql.cj.jdbc.Driver"
+    user = "st_user_sink"
+    password = "mysqlpw"
+    database = "mysql_cdc2"
+    table = "${table_name}"
+    primary_keys = ["${primary_key}"]
+    generate_sink_sql = true
+  }
+
+  jdbc {
+    plugin_input = "customers_mysql_cdc"
+    url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc_append"
+    driver = "com.mysql.cj.jdbc.Driver"
+    user = "st_user_sink"
+    password = "mysqlpw"
+    database = "mysql_cdc_append"
+    table = "${table_name}"
+    generate_sink_sql = true
+  }
+}

Reply via email to