This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] 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 c355b2f719 [Fix][Connector-CDC] Reassign restored splits to waiting
readers after checkpoint recovery (#11677)
c355b2f719 is described below
commit c355b2f719c35870c5545899a637ae2ba040036a
Author: Li Zhi <[email protected]>
AuthorDate: Sat Oct 17 07:23:22 2026 +0000
[Fix][Connector-CDC] Reassign restored splits to waiting readers after
checkpoint recovery (#11677)
---
.../api/source/SourceSplitEnumerator.java | 11 +-
.../enumerator/IncrementalSourceEnumerator.java | 27 +++-
.../IncrementalSourceEnumeratorTest.java | 162 +++++++++++++++++++++
.../cdc/mysql/AbstractMysqlCDCITBase.java | 151 +++++++++++++++++++
...sqlcdc_to_mysql_with_sink_failure_recovery.conf | 65 +++++++++
.../server/task/SourceSplitEnumeratorTask.java | 7 +
6 files changed, 418 insertions(+), 5 deletions(-)
diff --git
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceSplitEnumerator.java
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceSplitEnumerator.java
index d1488d00db..7cf5e16cbe 100644
---
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceSplitEnumerator.java
+++
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceSplitEnumerator.java
@@ -39,9 +39,9 @@ public interface SourceSplitEnumerator<SplitT extends
SourceSplit, StateT>
void open();
/**
- * Executes engine setup steps in a fixed, non‑concurrent sequence.
+ * Executes engine setup steps.
*
- * <p>Before the first {@link #run()} invocation, methods are called in
this order:
+ * <p>Methods are typically called in this order before the first {@link
#run()} invocation:
*
* <ol>
* <li>{@link #open()}
@@ -49,8 +49,11 @@ public interface SourceSplitEnumerator<SplitT extends
SourceSplit, StateT>
* <li>{@link #registerReader(int)}
* </ol>
*
- * <p>{@implNote The engine guarantees this invocation order and ensures
there are no
- * concurrency issues between these calls.}
+ * <p>The engine only guarantees that {@link #run()} is invoked after
{@link #open()} and after
+ * every reader has been registered. It does not guarantee the order
listed above: restored
+ * splits may be returned via {@link #addSplitsBack(List, int)} at any
point of the source
+ * lifecycle, for example after a checkpoint restore, before or after
{@link #run()}. How such
+ * late returned splits are dispatched to readers is left to the
implementation.
*/
void run() throws Exception;
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSourceEnumerator.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSourceEnumerator.java
index 87ca7dc507..48ab91a096 100644
---
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSourceEnumerator.java
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSourceEnumerator.java
@@ -38,6 +38,13 @@ import java.util.stream.Collectors;
/**
* Incremental source enumerator that enumerates receive the split request and
assign the split to
* source readers.
+ *
+ * <p>This enumerator tolerates out-of-order or late {@link
#addSplitsBack(List, int)} calls, e.g.
+ * checkpoint-restored splits returned after a reader already requested a
split: returned splits are
+ * always queued in the {@link SplitAssigner}, and they are dispatched to
waiting readers either on
+ * the first {@link #run()} assignment pass (when the enumerator was not
running yet) or on the next
+ * assignment cycle triggered by {@link #addSplitsBack(List, int)} (when the
enumerator is already
+ * running).
*/
public class IncrementalSourceEnumerator
implements SourceSplitEnumerator<SourceSplitBase, PendingSplitsState> {
@@ -67,6 +74,13 @@ public class IncrementalSourceEnumerator
@Override
public synchronized void run() throws Exception {
this.running = true;
+ // Dispatch the splits that addSplitsBack() returned before run()
executed: the engine
+ // hands the checkpoint-restored splits of a restarting reader to the
enumerator and only
+ // invokes run() once every reader has registered, so in a restart the
restored splits are
+ // normally queued in the assigner while running is still false and
addSplitsBack() skipped
+ // its assignment pass. They are not lost: any split request that
arrived in the meantime
+ // is already queued in readersAwaitingSplit by handleSplitRequest(),
so this first
+ // assignment pass hands out the returned splits to those waiting
readers.
assignSplits();
}
@@ -84,9 +98,20 @@ public class IncrementalSourceEnumerator
}
@Override
- public void addSplitsBack(List<SourceSplitBase> splits, int subtaskId) {
+ public synchronized void addSplitsBack(List<SourceSplitBase> splits, int
subtaskId) {
LOG.debug("Incremental Source Enumerator adds splits back: {}",
splits);
splitAssigner.addSplits(splits);
+ // running == false: the engine delivers the checkpoint-restored
splits of a restarting
+ // reader before it invokes run() (run() waits for every reader to
register), so the
+ // returned splits are still queued in the assigner and get dispatched
by run()'s first
+ // assignment pass - skipping the assignment here does not lose them.
+ // running == true: a restored split may arrive after the reader
already sent its split
+ // request and is parked in readersAwaitingSplit; re-run the
assignment loop so the
+ // waiting reader receives the restored split immediately instead of
stalling until
+ // another trigger.
+ if (running) {
+ assignSplits();
+ }
}
@Override
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSourceEnumeratorTest.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSourceEnumeratorTest.java
new file mode 100644
index 0000000000..86c5e7269a
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/IncrementalSourceEnumeratorTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.
+ */
+
+package org.apache.seatunnel.connectors.cdc.base.source.enumerator;
+
+import org.apache.seatunnel.api.common.metrics.MetricsContext;
+import org.apache.seatunnel.api.event.EventListener;
+import org.apache.seatunnel.api.source.SourceEvent;
+import org.apache.seatunnel.api.source.SourceSplitEnumerator;
+import
org.apache.seatunnel.connectors.cdc.base.source.enumerator.state.PendingSplitsState;
+import
org.apache.seatunnel.connectors.cdc.base.source.event.SnapshotSplitWatermark;
+import org.apache.seatunnel.connectors.cdc.base.source.split.SnapshotSplit;
+import org.apache.seatunnel.connectors.cdc.base.source.split.SourceSplitBase;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public class IncrementalSourceEnumeratorTest {
+
+ @Test
+ public void shouldAssignRestoredSplitsToWaitingReader() throws Exception {
+ TestingEnumeratorContext context = new TestingEnumeratorContext();
+ TestingSplitAssigner splitAssigner = new TestingSplitAssigner();
+ SourceSplitBase restoredSplit = new SnapshotSplit("restored", null,
null, null, null);
+
+ IncrementalSourceEnumerator enumerator =
+ new IncrementalSourceEnumerator(context, splitAssigner);
+ enumerator.open();
+ enumerator.run();
+ enumerator.handleSplitRequest(0);
+ enumerator.addSplitsBack(Collections.singletonList(restoredSplit), 0);
+
+ Assertions.assertEquals(
+ Collections.singletonList(restoredSplit),
splitAssigner.restoredSplits);
+ Assertions.assertEquals(Collections.singletonList(restoredSplit),
context.assignedSplits);
+ }
+
+ @Test
+ public void shouldAssignSplitsAddedBackBeforeRunExactlyOnce() throws
Exception {
+ TestingEnumeratorContext context = new TestingEnumeratorContext();
+ TestingSplitAssigner splitAssigner = new TestingSplitAssigner();
+ SourceSplitBase restoredSplit = new SnapshotSplit("restored", null,
null, null, null);
+
+ IncrementalSourceEnumerator enumerator =
+ new IncrementalSourceEnumerator(context, splitAssigner);
+ enumerator.open();
+ // Mirror the engine's restore ordering: the reader's split request
and the restored
+ // splits are delivered before the first run() invocation. Nothing is
dispatched yet
+ // because the enumerator is not running.
+ enumerator.handleSplitRequest(0);
+ enumerator.addSplitsBack(Collections.singletonList(restoredSplit), 0);
+ Assertions.assertEquals(Collections.emptyList(),
context.allAssignedSplits);
+
+ enumerator.run();
+
+ Assertions.assertEquals(
+ Collections.singletonList(restoredSplit),
splitAssigner.restoredSplits);
+ Assertions.assertEquals(Collections.singletonList(restoredSplit),
context.assignedSplits);
+ Assertions.assertEquals(
+ Collections.singletonList(restoredSplit),
context.allAssignedSplits);
+ }
+
+ private static final class TestingSplitAssigner implements SplitAssigner {
+ private List<SourceSplitBase> addedSplits = Collections.emptyList();
+ private List<SourceSplitBase> restoredSplits = Collections.emptyList();
+
+ @Override
+ public void open() {}
+
+ @Override
+ public Optional<SourceSplitBase> getNext() {
+ if (addedSplits.isEmpty()) {
+ return Optional.empty();
+ }
+ SourceSplitBase split = addedSplits.get(0);
+ addedSplits = Collections.emptyList();
+ return Optional.of(split);
+ }
+
+ @Override
+ public boolean waitingForCompletedSplits() {
+ return true;
+ }
+
+ @Override
+ public void onCompletedSplits(
+ List<SnapshotSplitWatermark> completedSnapshotSplitWatermarks)
{}
+
+ @Override
+ public void addSplits(Collection<SourceSplitBase> splits) {
+ restoredSplits = Collections.unmodifiableList(new
ArrayList<>(splits));
+ addedSplits = restoredSplits;
+ }
+
+ @Override
+ public PendingSplitsState snapshotState(long checkpointId) {
+ return null;
+ }
+
+ @Override
+ public void notifyCheckpointComplete(long checkpointId) {}
+ }
+
+ private static final class TestingEnumeratorContext
+ implements SourceSplitEnumerator.Context<SourceSplitBase> {
+ private List<SourceSplitBase> assignedSplits = Collections.emptyList();
+ private final List<SourceSplitBase> allAssignedSplits = new
ArrayList<>();
+
+ @Override
+ public int currentParallelism() {
+ return 1;
+ }
+
+ @Override
+ public Set<Integer> registeredReaders() {
+ return Collections.singleton(0);
+ }
+
+ @Override
+ public void assignSplit(int subtaskId, List<SourceSplitBase> splits) {
+ assignedSplits = splits;
+ allAssignedSplits.addAll(splits);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int subtask) {}
+
+ @Override
+ public void sendEventToSourceReader(int subtaskId, SourceEvent event)
{}
+
+ @Override
+ public MetricsContext getMetricsContext() {
+ return null;
+ }
+
+ @Override
+ public EventListener getEventListener() {
+ return null;
+ }
+ }
+}
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 55fa1d9662..00c2c066ac 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
@@ -179,6 +179,157 @@ public abstract class AbstractMysqlCDCITBase extends
TestSuiteBase implements Te
});
}
+ @TestTemplate
+ @DisabledOnContainer(
+ value = {},
+ type = {EngineType.SPARK, EngineType.FLINK},
+ disabledReason =
+ "This case verifies Zeta automatic checkpoint recovery
after a sink failure.")
+ public void testMysqlCdcContinuesAfterSinkFailure(TestContainer container)
throws Exception {
+ // Regression guard for restored-split reassignment after a sink
failure. With
+ // parallelism = 1 (see the conf), this clean full-pipeline restart
drives the
+ // pre-existing handleSplitRequest() dispatch path: the restored split
lands in the
+ // assigner while the enumerator is not yet running, and the reader
picks it up via
+ // its requestSplit() -> handleSplitRequest() call. The new running ==
true branch in
+ // IncrementalSourceEnumerator.addSplitsBack() is covered
deterministically by the unit
+ // test
IncrementalSourceEnumeratorTest#shouldAssignRestoredSplitsToWaitingReader; this
+ // e2e proves the end-to-end recovery path through the real engine.
+ clearTable(MYSQL_DATABASE, SOURCE_TABLE_1);
+ clearTable(MYSQL_DATABASE, SINK_TABLE);
+ Long jobId = JobIdGenerator.newJobId();
+ upsertDeleteSourceTable(MYSQL_DATABASE, SOURCE_TABLE_1);
+ CompletableFuture<Container.ExecResult> executeJobFuture =
+ CompletableFuture.supplyAsync(
+ () -> {
+ try {
+ return container.executeJob(
+
"/mysqlcdc_to_mysql_with_sink_failure_recovery.conf",
+ String.valueOf(jobId));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ await().atMost(60, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ "RUNNING",
container.getJobStatus(String.valueOf(jobId))));
+
+ await().atMost(60, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertIterableEquals(
+
query(getSourceQuerySQL(MYSQL_DATABASE, SOURCE_TABLE_1)),
+ query(getSinkQuerySQL(MYSQL_DATABASE,
SINK_TABLE))));
+
+ // The test config checkpoints every five seconds. Wait until the
snapshot state is
+ // durably checkpointed before failing the incremental CDC stream.
+ await().atMost(2, TimeUnit.MINUTES)
+ .untilAsserted(
+ () ->
+ Assertions.assertTrue(
+
container.getCompletedCheckpointCount(String.valueOf(jobId))
+ > 0));
+ String triggerName = "fail_cdc_sink_once";
+ executeSql("DROP TRIGGER IF EXISTS " + MYSQL_DATABASE + "." +
triggerName);
+ executeSql(
+ "CREATE TRIGGER "
+ + MYSQL_DATABASE
+ + "."
+ + triggerName
+ + " BEFORE INSERT ON "
+ + MYSQL_DATABASE
+ + "."
+ + SINK_TABLE
+ + " FOR EACH ROW SIGNAL SQLSTATE '45000' SET
MESSAGE_TEXT = 'injected sink failure'");
+ try {
+ int logOffset = container.getServerLogs().length();
+ executeSql(
+ "UPDATE "
+ + MYSQL_DATABASE
+ + "."
+ + SOURCE_TABLE_1
+ + " SET f_bigint = 20000 WHERE id = 5");
+ // The injected trigger makes the JDBC sink's insert fail, so the
engine detects
+ // the sink failure and enters the restart cycle
(job.retry.interval.seconds = 15).
+ // Wait for a positive signal in the engine log that the injected
failure actually
+ // fired instead of a fixed sleep, so the test cannot silently
pass without
+ // exercising the recovery path. The job status cannot be used
here because on dev
+ // it stays RUNNING throughout the restart window. Once the
failure is observed the
+ // finally block removes the trigger, so the post-restart replay
of the
+ // f_bigint=20000 binlog event normally does not hit the trigger a
second time. If
+ // the restart still wins that race, the replay fails once more
and consumes one of
+ // the extra retries granted by job.retry.times in the conf; the
trigger is gone by
+ // the next cycle, so the job recovers anyway. The two-minute
budget matches the
+ // other waits in this test.
+ await().atMost(2, TimeUnit.MINUTES)
+ .untilAsserted(
+ () -> {
+ String serverLogs = container.getServerLogs();
+ // The engine log can be truncated or rotated
while the job
+ // restarts; clamp the offset so a shorter log
is treated as
+ // "no match yet" instead of throwing
+ // StringIndexOutOfBoundsException and
aborting the wait.
+ Assertions.assertTrue(
+ serverLogs
+ .substring(Math.min(logOffset,
serverLogs.length()))
+ .contains("injected sink
failure"),
+ "injected sink failure was not
observed in the "
+ + "engine logs after the
f_bigint=20000 update");
+ });
+ } finally {
+ executeSql("DROP TRIGGER IF EXISTS " + MYSQL_DATABASE + "." +
triggerName);
+ }
+
+ // The job restarts from the last checkpoint and replays the binlog
events written
+ // after it (including the f_bigint=20000 update). The trigger must be
gone before
+ // that replay reaches the sink, otherwise the restored job fails
again. Verify the
+ // trigger was actually dropped, then confirm the job is still
RUNNING; this status
+ // check only confirms that the job is alive during recovery. The
source-to-sink
+ // equality assertion below is the actual signal that recovery has
converged.
+ await().atMost(30, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ 0L,
+ ((Number)
+ query(
+
"SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema = '"
+
+ MYSQL_DATABASE
+
+ "' AND trigger_name = '"
+
+ triggerName
+
+ "'")
+ .get(0)
+ .get(0))
+ .longValue()));
+ await().atMost(2, TimeUnit.MINUTES)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ "RUNNING",
container.getJobStatus(String.valueOf(jobId))));
+
+ executeSql(
+ "UPDATE "
+ + MYSQL_DATABASE
+ + "."
+ + SOURCE_TABLE_1
+ + " SET f_bigint = 30000 WHERE id = 5");
+ await().atMost(2, TimeUnit.MINUTES)
+ .untilAsserted(
+ () ->
+ Assertions.assertIterableEquals(
+
query(getSourceQuerySQL(MYSQL_DATABASE, SOURCE_TABLE_1)),
+ query(getSinkQuerySQL(MYSQL_DATABASE,
SINK_TABLE))));
+ Assertions.assertEquals("RUNNING",
container.getJobStatus(String.valueOf(jobId)));
+ Assertions.assertEquals(0,
container.cancelJob(String.valueOf(jobId)).getExitCode());
+ await().atMost(60, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ "CANCELED",
container.getJobStatus(String.valueOf(jobId))));
+ Assertions.assertEquals(0, executeJobFuture.get(60,
TimeUnit.SECONDS).getExitCode());
+ }
+
@TestTemplate
@DisabledOnContainer(
value = {},
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_sink_failure_recovery.conf
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_sink_failure_recovery.conf
new file mode 100644
index 0000000000..626f7f52f4
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_sink_failure_recovery.conf
@@ -0,0 +1,65 @@
+#
+# 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.
+#
+
+env {
+ parallelism = 1
+ job.mode = "STREAMING"
+ checkpoint.interval = 5000
+ # The test drops the injected-failure trigger as soon as the failure shows
up in the engine
+ # log. If a restart replays the failing binlog event before the trigger is
gone, that replay
+ # fails once more, so keep the default retry budget instead of a single
retry to give the
+ # job room to recover on the next restart cycle.
+ job.retry.times = 3
+ job.retry.interval.seconds = 15
+}
+
+source {
+ MySQL-CDC {
+ plugin_output = "customers_mysql_cdc"
+ server-id = 5652
+ username = "st_user_source"
+ password = "mysqlpw"
+ table-names = ["mysql_cdc.mysql_cdc_e2e_source_table"]
+ url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc"
+ }
+}
+
+transform {
+ sql {
+ plugin_input = "customers_mysql_cdc"
+ query = """ select id, f_binary, f_blob, f_long_varbinary, f_longblob,
f_tinyblob, f_varbinary, f_smallint, f_smallint_unsigned, f_mediumint,
+ f_mediumint_unsigned, f_int, f_int_unsigned, f_integer,
f_integer_unsigned, f_bigint, f_bigint_unsigned, f_numeric, f_decimal,
+ f_float, f_double, f_double_precision, f_longtext,
f_mediumtext, f_text, f_tinytext, f_varchar, f_date, f_datetime, f_timestamp,
+ f_bit1, f_bit64, f_char, f_enum, f_mediumblob, f_long_varchar,
f_real, f_time, f_tinyint, f_tinyint_unsigned, f_json, f_year
+ from dual """
+ plugin_output = "trans_mysql_cdc"
+ }
+}
+
+sink {
+ jdbc {
+ plugin_input = "trans_mysql_cdc"
+ url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc"
+ driver = "com.mysql.cj.jdbc.Driver"
+ user = "st_user_sink"
+ password = "mysqlpw"
+ generate_sink_sql = true
+ database = mysql_cdc
+ table = mysql_cdc_e2e_sink_table
+ primary_keys = ["id"]
+ }
+}
diff --git
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SourceSplitEnumeratorTask.java
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SourceSplitEnumeratorTask.java
index 18e1e8217d..02340b48ec 100644
---
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SourceSplitEnumeratorTask.java
+++
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SourceSplitEnumeratorTask.java
@@ -238,6 +238,13 @@ public class SourceSplitEnumeratorTask<SplitT extends
SourceSplit> extends Coord
}
int taskSize = taskMemberMapping.size();
if (maxReaderSize == taskSize) {
+ // Cross-class invariant: a reader's restoreState() synchronously
delivers its
+ // checkpoint-restored splits to the enumerator
(RestoredSplitOperation) before it
+ // registers, and run() only fires after every reader has
registered below. So in a
+ // clean full-pipeline restart the restored splits normally reach
the assigner while
+ // the enumerator is not running yet. That ordering is not
guaranteed (multi-reader
+ // interleaving, retry-induced delay), so
IncrementalSourceEnumerator.addSplitsBack()
+ // defensively re-runs assignment when the enumerator is already
running.
readerRegisterComplete = true;
log.debug(String.format("reader register complete, current task
size %d", taskSize));
} else {