This is an automated email from the ASF dual-hosted git repository.
davidzollo 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 33ffd48a49 [Fix][Connector-V2] Fix Paimon stream source state restore
(#11132)
33ffd48a49 is described below
commit 33ffd48a49b1f6074ff88818ce692c0c4db9b679
Author: QuakeWang <[email protected]>
AuthorDate: Fri Jun 19 18:28:02 2026 +0800
[Fix][Connector-V2] Fix Paimon stream source state restore (#11132)
Signed-off-by: QuakeWang <[email protected]>
---
.../seatunnel/paimon/source/PaimonSource.java | 1 +
.../seatunnel/paimon/source/PaimonSourceState.java | 43 +++++-
.../source/enumerator/AbstractSplitEnumerator.java | 34 ++++-
.../PaimonBatchSourceSplitEnumerator.java | 2 +-
.../PaimonStreamSourceSplitEnumerator.java | 17 +++
.../PaimonStreamSourceSplitEnumeratorTest.java | 167 +++++++++++++++++++++
6 files changed, 255 insertions(+), 9 deletions(-)
diff --git
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSource.java
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSource.java
index 501a1c46b9..aa16e457e1 100644
---
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSource.java
+++
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSource.java
@@ -176,6 +176,7 @@ public class PaimonSource
enumeratorContext,
checkpointState.getAssignedSplits(),
checkpointState.getCurrentSnapshotId(),
+ checkpointState.getCurrentSnapshotIds(),
readBuilders,
1);
}
diff --git
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSourceState.java
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSourceState.java
index db6392520c..73991cdd9a 100644
---
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSourceState.java
+++
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/PaimonSourceState.java
@@ -17,17 +17,15 @@
package org.apache.seatunnel.connectors.seatunnel.paimon.source;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
import javax.annotation.Nullable;
import java.io.Serializable;
+import java.util.Collections;
import java.util.Deque;
+import java.util.HashMap;
+import java.util.Map;
/** Paimon connector source state, saves the splits has assigned to readers. */
-@Getter
-@AllArgsConstructor
public class PaimonSourceState implements Serializable {
private static final long serialVersionUID = 1L;
@@ -35,4 +33,39 @@ public class PaimonSourceState implements Serializable {
private final Deque<PaimonSourceSplit> assignedSplits;
private final @Nullable Long currentSnapshotId;
+
+ private final Map<String, Long> currentSnapshotIds;
+
+ public PaimonSourceState(
+ Deque<PaimonSourceSplit> assignedSplits, @Nullable Long
currentSnapshotId) {
+ this.assignedSplits = assignedSplits;
+ this.currentSnapshotId = currentSnapshotId;
+ this.currentSnapshotIds = Collections.emptyMap();
+ }
+
+ public PaimonSourceState(
+ Deque<PaimonSourceSplit> assignedSplits, Map<String, Long>
currentSnapshotIds) {
+ this.assignedSplits = assignedSplits;
+ this.currentSnapshotIds = new HashMap<>(currentSnapshotIds);
+ this.currentSnapshotId = getSingleSnapshotId(this.currentSnapshotIds);
+ }
+
+ public Deque<PaimonSourceSplit> getAssignedSplits() {
+ return assignedSplits;
+ }
+
+ public @Nullable Long getCurrentSnapshotId() {
+ return currentSnapshotId;
+ }
+
+ public Map<String, Long> getCurrentSnapshotIds() {
+ return currentSnapshotIds == null ? Collections.emptyMap() :
currentSnapshotIds;
+ }
+
+ private static @Nullable Long getSingleSnapshotId(Map<String, Long>
currentSnapshotIds) {
+ if (currentSnapshotIds.size() != 1) {
+ return null;
+ }
+ return currentSnapshotIds.values().iterator().next();
+ }
}
diff --git
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/AbstractSplitEnumerator.java
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/AbstractSplitEnumerator.java
index e18bda0e22..ed1356656a 100644
---
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/AbstractSplitEnumerator.java
+++
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/AbstractSplitEnumerator.java
@@ -42,6 +42,7 @@ import java.util.Collection;
import java.util.Deque;
import java.util.HashMap;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
@@ -73,6 +74,8 @@ public abstract class AbstractSplitEnumerator
@Nullable protected Long nextSnapshotId;
+ protected final Map<String, Long> nextSnapshotIds;
+
private ExecutorService executorService;
public AbstractSplitEnumerator(
@@ -82,9 +85,28 @@ public abstract class AbstractSplitEnumerator
Map<String, ReadBuilder> readBuilders,
int splitMaxPerTask,
JobMode jobMode) {
+ this(
+ context,
+ pendingSplits,
+ nextSnapshotId,
+ new HashMap<>(),
+ readBuilders,
+ splitMaxPerTask,
+ jobMode);
+ }
+
+ public AbstractSplitEnumerator(
+ Context<PaimonSourceSplit> context,
+ Deque<PaimonSourceSplit> pendingSplits,
+ @Nullable Long nextSnapshotId,
+ Map<String, Long> nextSnapshotIds,
+ Map<String, ReadBuilder> readBuilders,
+ int splitMaxPerTask,
+ JobMode jobMode) {
this.context = context;
this.pendingSplits = new LinkedList<>(pendingSplits);
this.nextSnapshotId = nextSnapshotId;
+ this.nextSnapshotIds = new LinkedHashMap<>(nextSnapshotIds);
this.readersAwaitingSplit = new LinkedHashSet<>();
this.splitGenerator = new PaimonSourceSplitGenerator();
this.splitMaxNum = context.currentParallelism() * splitMaxPerTask;
@@ -101,8 +123,12 @@ public abstract class AbstractSplitEnumerator
? readBuilder.newScan()
: readBuilder.newStreamScan();
tableScans.put(tableId, scan);
- if (scan instanceof StreamTableScan && nextSnapshotId !=
null) {
- ((StreamTableScan) scan).restore(nextSnapshotId);
+ Long tableSnapshotId =
+ this.nextSnapshotIds.isEmpty()
+ ? nextSnapshotId
+ : this.nextSnapshotIds.get(tableId);
+ if (scan instanceof StreamTableScan && tableSnapshotId !=
null) {
+ ((StreamTableScan) scan).restore(tableSnapshotId);
}
});
}
@@ -146,7 +172,7 @@ public abstract class AbstractSplitEnumerator
@Override
public PaimonSourceState snapshotState(long checkpointId) throws Exception
{
synchronized (stateLock) {
- return new PaimonSourceState(pendingSplits, nextSnapshotId);
+ return new PaimonSourceState(pendingSplits, nextSnapshotIds);
}
}
@@ -245,6 +271,8 @@ public abstract class AbstractSplitEnumerator
for (PlanWithNextSnapshotId planWithNextSnapshotId :
planWithNextSnapshotIds) {
nextSnapshotId = planWithNextSnapshotId.nextSnapshotId;
+ nextSnapshotIds.put(
+ planWithNextSnapshotId.tableId,
planWithNextSnapshotId.nextSnapshotId);
TableScan.Plan plan = planWithNextSnapshotId.plan;
if (plan.splits().isEmpty()) {
continue;
diff --git
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonBatchSourceSplitEnumerator.java
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonBatchSourceSplitEnumerator.java
index 43cf5c4e3e..6b777e7704 100644
---
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonBatchSourceSplitEnumerator.java
+++
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonBatchSourceSplitEnumerator.java
@@ -58,7 +58,7 @@ public class PaimonBatchSourceSplitEnumerator extends
AbstractSplitEnumerator {
@Override
public PaimonSourceState snapshotState(long checkpointId) throws Exception
{
synchronized (stateLock) {
- return new PaimonSourceState(pendingSplits, null);
+ return new PaimonSourceState(pendingSplits, (Long) null);
}
}
diff --git
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonStreamSourceSplitEnumerator.java
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonStreamSourceSplitEnumerator.java
index 6852472f59..67706a8e51 100644
---
a/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonStreamSourceSplitEnumerator.java
+++
b/seatunnel-connectors-v2/connector-paimon/src/main/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonStreamSourceSplitEnumerator.java
@@ -39,10 +39,27 @@ public class PaimonStreamSourceSplitEnumerator extends
AbstractSplitEnumerator {
@Nullable Long nextSnapshotId,
Map<String, ReadBuilder> readBuilders,
int splitMaxPerTask) {
+ this(
+ context,
+ pendingSplits,
+ nextSnapshotId,
+ java.util.Collections.emptyMap(),
+ readBuilders,
+ splitMaxPerTask);
+ }
+
+ public PaimonStreamSourceSplitEnumerator(
+ Context<PaimonSourceSplit> context,
+ Deque<PaimonSourceSplit> pendingSplits,
+ @Nullable Long nextSnapshotId,
+ Map<String, Long> nextSnapshotIds,
+ Map<String, ReadBuilder> readBuilders,
+ int splitMaxPerTask) {
super(
context,
pendingSplits,
nextSnapshotId,
+ nextSnapshotIds,
readBuilders,
splitMaxPerTask,
JobMode.STREAMING);
diff --git
a/seatunnel-connectors-v2/connector-paimon/src/test/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonStreamSourceSplitEnumeratorTest.java
b/seatunnel-connectors-v2/connector-paimon/src/test/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonStreamSourceSplitEnumeratorTest.java
new file mode 100644
index 0000000000..dce0b36919
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-paimon/src/test/java/org/apache/seatunnel/connectors/seatunnel/paimon/source/enumerator/PaimonStreamSourceSplitEnumeratorTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.seatunnel.paimon.source.enumerator;
+
+import org.apache.seatunnel.api.source.SourceSplitEnumerator;
+import
org.apache.seatunnel.connectors.seatunnel.paimon.source.PaimonSourceSplit;
+import
org.apache.seatunnel.connectors.seatunnel.paimon.source.PaimonSourceState;
+
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.StreamTableScan;
+import org.apache.paimon.table.source.TableScan;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PaimonStreamSourceSplitEnumeratorTest {
+
+ @Test
+ void shouldKeepIndependentSnapshotIdForEachTableWhenRestore() throws
Exception {
+ StreamTableScan firstScan = newStreamScan(10L);
+ StreamTableScan secondScan = newStreamScan(20L);
+
+ PaimonStreamSourceSplitEnumerator enumerator =
+ new PaimonStreamSourceSplitEnumerator(
+ context(),
+ new LinkedList<>(),
+ null,
+ readBuilders(firstScan, secondScan),
+ 1);
+
+ enumerator.processDiscoveredSplits(enumerator.scanNextSnapshot(),
null);
+ PaimonSourceState state = enumerator.snapshotState(1L);
+ StreamTableScan restoredFirstScan = newStreamScan(11L);
+ StreamTableScan restoredSecondScan = newStreamScan(21L);
+
+ PaimonStreamSourceSplitEnumerator restored =
+ new PaimonStreamSourceSplitEnumerator(
+ context(),
+ state.getAssignedSplits(),
+ state.getCurrentSnapshotId(),
+ state.getCurrentSnapshotIds(),
+ readBuilders(restoredFirstScan, restoredSecondScan),
+ 1);
+ restored.close();
+ enumerator.close();
+
+ verify(firstScan).checkpoint();
+ verify(secondScan).checkpoint();
+ assertEquals(10L, state.getCurrentSnapshotIds().get("db.table_a"));
+ assertEquals(20L, state.getCurrentSnapshotIds().get("db.table_b"));
+ verify(restoredFirstScan).restore(10L);
+ verify(restoredSecondScan).restore(20L);
+ }
+
+ @Test
+ void shouldNotFallbackToAnotherTableSnapshotWhenTableSnapshotIsNull()
throws Exception {
+ StreamTableScan firstScan = newStreamScan(10L);
+ StreamTableScan secondScan = newStreamScan(null);
+
+ PaimonStreamSourceSplitEnumerator enumerator =
+ new PaimonStreamSourceSplitEnumerator(
+ context(),
+ new LinkedList<>(),
+ null,
+ readBuilders(firstScan, secondScan),
+ 1);
+
+ enumerator.processDiscoveredSplits(enumerator.scanNextSnapshot(),
null);
+ PaimonSourceState state = enumerator.snapshotState(1L);
+ StreamTableScan restoredFirstScan = newStreamScan(11L);
+ StreamTableScan restoredSecondScan = newStreamScan(21L);
+
+ PaimonStreamSourceSplitEnumerator restored =
+ new PaimonStreamSourceSplitEnumerator(
+ context(),
+ state.getAssignedSplits(),
+ state.getCurrentSnapshotId(),
+ state.getCurrentSnapshotIds(),
+ readBuilders(restoredFirstScan, restoredSecondScan),
+ 1);
+ restored.close();
+ enumerator.close();
+
+ assertEquals(10L, state.getCurrentSnapshotIds().get("db.table_a"));
+ assertTrue(state.getCurrentSnapshotIds().containsKey("db.table_b"));
+ assertNull(state.getCurrentSnapshotIds().get("db.table_b"));
+ verify(restoredFirstScan).restore(10L);
+ verify(restoredSecondScan, never()).restore(10L);
+ }
+
+ @Test
+ void shouldRestoreLegacySingleSnapshotId() throws Exception {
+ StreamTableScan restoredScan = newStreamScan(11L);
+
+ PaimonStreamSourceSplitEnumerator restored =
+ new PaimonStreamSourceSplitEnumerator(
+ context(), new LinkedList<>(), 10L,
readBuilders(restoredScan), 1);
+ restored.close();
+
+ verify(restoredScan).restore(10L);
+ }
+
+ private static Map<String, ReadBuilder> readBuilders(
+ StreamTableScan firstScan, StreamTableScan secondScan) {
+ Map<String, ReadBuilder> readBuilders = new LinkedHashMap<>();
+ readBuilders.put("db.table_a", readBuilder(firstScan));
+ readBuilders.put("db.table_b", readBuilder(secondScan));
+ return readBuilders;
+ }
+
+ private static Map<String, ReadBuilder> readBuilders(StreamTableScan scan)
{
+ Map<String, ReadBuilder> readBuilders = new LinkedHashMap<>();
+ readBuilders.put("db.table_a", readBuilder(scan));
+ return readBuilders;
+ }
+
+ private static ReadBuilder readBuilder(StreamTableScan scan) {
+ ReadBuilder readBuilder = mock(ReadBuilder.class);
+ when(readBuilder.newStreamScan()).thenReturn(scan);
+ return readBuilder;
+ }
+
+ private static StreamTableScan newStreamScan(Long nextSnapshotId) {
+ StreamTableScan scan = mock(StreamTableScan.class);
+ TableScan.Plan plan = mock(TableScan.Plan.class);
+ when(plan.splits()).thenReturn(Collections.emptyList());
+ when(scan.plan()).thenReturn(plan);
+ when(scan.checkpoint()).thenReturn(nextSnapshotId);
+ return scan;
+ }
+
+ private static SourceSplitEnumerator.Context<PaimonSourceSplit> context() {
+ SourceSplitEnumerator.Context<PaimonSourceSplit> context =
+ mock(SourceSplitEnumerator.Context.class);
+ when(context.currentParallelism()).thenReturn(1);
+ when(context.registeredReaders()).thenReturn(new HashSet<>());
+ return context;
+ }
+}