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 bbde250914 [Fix][CDC] Reconstruct missing snapshot completion 
watermark and always wait for a checkpoint (#12162)
bbde250914 is described below

commit bbde250914346068f50131e7907bef2c9ecbc448
Author: Jast <[email protected]>
AuthorDate: Fri Sep 11 05:15:03 2026 +0000

    [Fix][CDC] Reconstruct missing snapshot completion watermark and always 
wait for a checkpoint (#12162)
    
    Co-authored-by: zhangshenghang <[email protected]>
---
 .../source/enumerator/SnapshotSplitAssigner.java   | 53 +++++++++++-----
 .../enumerator/SnapshotSplitAssignerTest.java      | 73 +++++++++++++++++++---
 2 files changed, 105 insertions(+), 21 deletions(-)

diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssigner.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssigner.java
index cac8c1d128..d5d132e727 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssigner.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssigner.java
@@ -202,15 +202,25 @@ public class SnapshotSplitAssigner<C extends 
SourceConfig> implements SplitAssig
         completedSplitWatermarks.forEach(
                 watermark -> 
this.splitCompletedOffsets.put(watermark.getSplitId(), watermark));
         if (allSplitsCompleted()) {
-            // Skip the waiting checkpoint when current parallelism is 1 which 
means we do not need
-            // to care about the global output data order of snapshot splits 
and incremental split.
             if (currentParallelism == 1) {
+                // A single-reader job completes immediately. Zeta disables 
checkpointing
+                // entirely for batch jobs without 'checkpoint.interval', so 
waiting for
+                // notifyCheckpointComplete would hang such a job forever. The 
failover risk
+                // of skipping the checkpoint wait is covered by the durable 
finished-unacked
+                // splits in the reader's own checkpoint, whose re-report on 
restore and the
+                // back-fill in restoreCompletedSnapshotSplit reconstruct the 
completion state
+                // without replaying the splits.
                 assignerCompleted = true;
                 LOG.info(
-                        "Snapshot split assigner received all splits completed 
and the job parallelism is 1, snapshot split assigner is turn into completed 
status.");
+                        "Snapshot split assigner received all splits completed 
at parallelism 1, snapshot split assigner is turn into completed status.");
             } else {
+                // Multi-reader jobs must wait for a complete checkpoint 
before switching to
+                // the incremental phase, so that all records of snapshot 
splits are completely
+                // processed in the pipeline and no incremental record can 
overtake a snapshot
+                // record of the same key.
                 LOG.info(
-                        "Snapshot split assigner received all splits 
completed, waiting for a complete checkpoint to mark the assigner completed.");
+                        "Snapshot split assigner received all splits completed 
at parallelism {}, waiting for a complete checkpoint to mark the assigner 
completed.",
+                        currentParallelism);
             }
         }
     }
@@ -219,9 +229,9 @@ public class SnapshotSplitAssigner<C extends SourceConfig> 
implements SplitAssig
     public void addSplits(Collection<SourceSplitBase> splits) {
         for (SourceSplitBase split : splits) {
             SnapshotSplit snapshotSplit = split.asSnapshotSplit();
-            if (hasCheckpointedCompletionState(snapshotSplit)) {
+            if (restoreCompletedSnapshotSplit(snapshotSplit)) {
                 LOG.info(
-                        "Ignore add-back for completed snapshot split {}, keep 
checkpointed completion state",
+                        "Restore completed snapshot split {} from checkpoint 
without replaying it",
                         snapshotSplit.splitId());
                 continue;
             }
@@ -291,16 +301,31 @@ public class SnapshotSplitAssigner<C extends 
SourceConfig> implements SplitAssig
     }
 
     /**
-     * Returns whether the restored split already has durable completion state 
in the checkpoint.
+     * Returns whether the restored split already has durable completion state 
in the checkpoint,
+     * and back-fills any missing completion watermark from the split itself.
      *
-     * <p>We can safely skip add-back only when the checkpoint persisted both 
the original
-     * assignment and the finished watermark. If either side is missing, the 
split still needs to be
-     * replayed after restore so the enumerator can rebuild the missing 
completion state.
+     * <p>A finished reader that never reported its watermark before failover 
(for example because
+     * the reader crashed immediately after marking the split as 
snapshot-read-finished but before
+     * its next CompletedSnapshotSplitsReportEvent went out) can show up after 
restore as a finished
+     * split whose watermark has not yet been checkpointed. Without back-fill, 
the enumerator would
+     * re-enqueue the split and the snapshot phase would never finish.
+     *
+     * <p>The split is skipped on add-back in that case, and the missing 
watermark is reconstructed
+     * from the split's own low/high watermark. If the split was not finished 
before the failover we
+     * still re-enqueue it so the reader can replay it from its persisted 
state.
      */
-    private boolean hasCheckpointedCompletionState(SnapshotSplit 
snapshotSplit) {
-        return snapshotSplit.isSnapshotReadFinished()
-                && assignedSplits.containsKey(snapshotSplit.splitId())
-                && splitCompletedOffsets.containsKey(snapshotSplit.splitId());
+    private boolean restoreCompletedSnapshotSplit(SnapshotSplit snapshotSplit) 
{
+        if (!snapshotSplit.isSnapshotReadFinished()
+                || !assignedSplits.containsKey(snapshotSplit.splitId())) {
+            return false;
+        }
+        splitCompletedOffsets.putIfAbsent(
+                snapshotSplit.splitId(),
+                new SnapshotSplitWatermark(
+                        snapshotSplit.splitId(),
+                        snapshotSplit.getLowWatermark(),
+                        snapshotSplit.getHighWatermark()));
+        return true;
     }
 
     @VisibleForTesting
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssignerTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssignerTest.java
index 6e7ac75862..8cd030a542 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssignerTest.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/SnapshotSplitAssignerTest.java
@@ -65,7 +65,7 @@ public class SnapshotSplitAssignerTest {
     }
 
     @Test
-    public void 
testAddSplitsShouldReplayFinishedSplitWithoutCompletedWatermark() {
+    public void 
testAddSplitsShouldRestoreFinishedSplitWithoutCompletedWatermark() {
         SnapshotSplit finishedSplit = 
createFinishedSnapshotSplit("db1.table1.1");
         Map<String, SnapshotSplit> assignedSplits = new HashMap<>();
         assignedSplits.put(finishedSplit.splitId(), finishedSplit);
@@ -76,12 +76,17 @@ public class SnapshotSplitAssignerTest {
         splitAssigner.addSplits(Collections.singletonList(finishedSplit));
 
         SnapshotPhaseState state = splitAssigner.snapshotState(12L);
-        Assertions.assertEquals(1, state.getRemainingSplits().size());
+        // The split was already finished in the reader before the failover 
but its
+        // completed-watermark was never checkpointed. The assigner must 
reconstruct the
+        // watermark from the split itself and skip add-back, otherwise the 
snapshot phase
+        // would never finish.
+        Assertions.assertTrue(state.getRemainingSplits().isEmpty());
+        Assertions.assertEquals(
+                Collections.singleton(finishedSplit.splitId()), 
state.getAssignedSplits().keySet());
         Assertions.assertEquals(
-                finishedSplit.splitId(), 
state.getRemainingSplits().get(0).splitId());
-        Assertions.assertTrue(state.getAssignedSplits().isEmpty());
-        Assertions.assertTrue(state.getSplitCompletedOffsets().isEmpty());
-        Assertions.assertTrue(splitAssigner.waitingForCompletedSplits());
+                Collections.singleton(finishedSplit.splitId()),
+                state.getSplitCompletedOffsets().keySet());
+        Assertions.assertFalse(splitAssigner.waitingForCompletedSplits());
     }
 
     @Test
@@ -114,9 +119,63 @@ public class SnapshotSplitAssignerTest {
         Assertions.assertFalse(restoredAssigner.waitingForCompletedSplits());
     }
 
+    @Test
+    public void 
testSingleParallelismShouldCompleteImmediatelyWhenAllSplitsCompleted() {
+        // A single-reader batch job may run with checkpointing disabled (no
+        // 'checkpoint.interval' in env), so the assigner can never rely on
+        // notifyCheckpointComplete to flip the completed flag. It must 
complete immediately
+        // once all snapshot splits have reported their watermarks, otherwise 
such jobs hang
+        // forever between the snapshot and incremental phases.
+        SnapshotSplit finishedSplit = 
createFinishedSnapshotSplit("db1.table1.1");
+        Map<String, SnapshotSplit> assignedSplits = new HashMap<>();
+        assignedSplits.put(finishedSplit.splitId(), finishedSplit);
+
+        SnapshotSplitAssigner<?> splitAssigner =
+                createRestoredSnapshotSplitAssigner(assignedSplits, new 
HashMap<>(), 1);
+
+        Assertions.assertFalse(splitAssigner.isCompleted());
+
+        
splitAssigner.onCompletedSplits(Collections.singletonList(createWatermark(finishedSplit)));
+
+        Assertions.assertTrue(splitAssigner.isCompleted());
+        Assertions.assertFalse(splitAssigner.waitingForCompletedSplits());
+    }
+
+    @Test
+    public void testMultiParallelismShouldWaitForCheckpointBeforeCompleted() {
+        SnapshotSplit finishedSplit = 
createFinishedSnapshotSplit("db1.table1.1");
+        Map<String, SnapshotSplit> assignedSplits = new HashMap<>();
+        assignedSplits.put(finishedSplit.splitId(), finishedSplit);
+
+        SnapshotSplitAssigner<?> splitAssigner =
+                createRestoredSnapshotSplitAssigner(assignedSplits, new 
HashMap<>(), 10);
+
+        
splitAssigner.onCompletedSplits(Collections.singletonList(createWatermark(finishedSplit)));
+
+        // multi-parallelism jobs must not complete before a checkpoint pinned 
the completion
+        // state, otherwise incremental splits could overtake snapshot records 
of the same key
+        Assertions.assertFalse(splitAssigner.isCompleted());
+
+        splitAssigner.snapshotState(21L);
+        Assertions.assertFalse(splitAssigner.isCompleted());
+
+        splitAssigner.notifyCheckpointComplete(20L);
+        Assertions.assertFalse(splitAssigner.isCompleted());
+
+        splitAssigner.notifyCheckpointComplete(21L);
+        Assertions.assertTrue(splitAssigner.isCompleted());
+    }
+
     private SnapshotSplitAssigner<?> createRestoredSnapshotSplitAssigner(
             Map<String, SnapshotSplit> assignedSplits,
             Map<String, SnapshotSplitWatermark> completedOffsets) {
+        return createRestoredSnapshotSplitAssigner(assignedSplits, 
completedOffsets, 10);
+    }
+
+    private SnapshotSplitAssigner<?> createRestoredSnapshotSplitAssigner(
+            Map<String, SnapshotSplit> assignedSplits,
+            Map<String, SnapshotSplitWatermark> completedOffsets,
+            int currentParallelism) {
         SnapshotPhaseState checkpointState =
                 new SnapshotPhaseState(
                         Collections.emptyList(),
@@ -133,7 +192,7 @@ public class SnapshotSplitAssignerTest {
                         Collections.singleton(TableId.parse("db1.table1")),
                         checkpointState.getAssignedSplits(),
                         checkpointState.getSplitCompletedOffsets());
-        return new SnapshotSplitAssigner<>(context, 10, checkpointState, null);
+        return new SnapshotSplitAssigner<>(context, currentParallelism, 
checkpointState, null);
     }
 
     private SnapshotSplit createFinishedSnapshotSplit(String splitId) {

Reply via email to