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 1fe15cbb84 [Improve][Connector-V2] Improve source split round-robin 
assignment for InfluxDB IoTDB Kudu and TDengine (#11573)
1fe15cbb84 is described below

commit 1fe15cbb847d7dc0a000ea035deec7950c0d8bba
Author: JeremyXin <[email protected]>
AuthorDate: Tue Jul 28 09:29:14 2026 +0800

    [Improve][Connector-V2] Improve source split round-robin assignment for 
InfluxDB IoTDB Kudu and TDengine (#11573)
---
 .../source/InfluxDBSourceSplitEnumerator.java      |  38 ++++-
 .../influxdb/state/InfluxDBSourceState.java        |   1 +
 .../source/InfluxDBSourceSplitEnumeratorTest.java  | 158 ++++++++++++++++++
 .../source/IoTDBv2SourceSplitEnumerator.java       |  38 ++++-
 .../iotdbv2/state/IoTDBv2SourceState.java          |   1 +
 .../source/IoTDBv2SourceSplitEnumeratorTest.java   | 158 ++++++++++++++++++
 .../iotdb/source/IoTDBSourceSplitEnumerator.java   |  36 +++-
 .../seatunnel/iotdb/state/IoTDBSourceState.java    |   1 +
 .../source/IoTDBSourceSplitEnumeratorTest.java     | 153 +++++++++++++++++
 .../kudu/source/KuduSourceSplitEnumerator.java     |  23 ++-
 .../seatunnel/kudu/state/KuduSourceState.java      |   1 +
 .../kudu/source/KuduSourceSplitEnumeratorTest.java | 185 +++++++++++++++++++++
 .../source/TDengineSourceSplitEnumerator.java      |  35 +++-
 .../tdengine/state/TDengineSourceState.java        |   1 +
 .../source/TDengineSourceSplitEnumeratorTest.java  | 164 ++++++++++++++++++
 15 files changed, 959 insertions(+), 34 deletions(-)

diff --git 
a/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/source/InfluxDBSourceSplitEnumerator.java
 
b/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/source/InfluxDBSourceSplitEnumerator.java
index 694765a9fa..5e06afda18 100644
--- 
a/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/source/InfluxDBSourceSplitEnumerator.java
+++ 
b/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/source/InfluxDBSourceSplitEnumerator.java
@@ -31,11 +31,14 @@ import lombok.extern.slf4j.Slf4j;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 
 @Slf4j
 public class InfluxDBSourceSplitEnumerator
@@ -44,6 +47,7 @@ public class InfluxDBSourceSplitEnumerator
     private final Context<InfluxDBSourceSplit> context;
     private final Map<Integer, List<InfluxDBSourceSplit>> pendingSplit;
     private final Object stateLock = new Object();
+    private final AtomicInteger assignCount = new AtomicInteger(0);
     private volatile boolean shouldEnumerate;
 
     public InfluxDBSourceSplitEnumerator(
@@ -62,6 +66,7 @@ public class InfluxDBSourceSplitEnumerator
         if (sourceState != null) {
             this.shouldEnumerate = sourceState.isShouldEnumerate();
             this.pendingSplit.putAll(sourceState.getPendingSplit());
+            this.assignCount.set(sourceState.getAssignCount());
         }
     }
 
@@ -85,11 +90,18 @@ public class InfluxDBSourceSplitEnumerator
     }
 
     @Override
-    public void addSplitsBack(List splits, int subtaskId) {
+    public void addSplitsBack(List<InfluxDBSourceSplit> splits, int subtaskId) 
{
         log.debug("Add back splits {} to InfluxDBSourceSplitEnumerator.", 
splits);
         if (!splits.isEmpty()) {
-            addPendingSplit(splits);
-            assignSplit(Collections.singletonList(subtaskId));
+            addPendingSplit(splits, subtaskId);
+            if (context.registeredReaders().contains(subtaskId)) {
+                assignSplit(Collections.singletonList(subtaskId));
+            } else {
+                log.warn(
+                        "Reader {} is not registered. Pending splits {} are 
not assigned.",
+                        subtaskId,
+                        splits);
+            }
         }
     }
 
@@ -109,7 +121,7 @@ public class InfluxDBSourceSplitEnumerator
     @Override
     public InfluxDBSourceState snapshotState(long checkpointId) {
         synchronized (stateLock) {
-            return new InfluxDBSourceState(shouldEnumerate, pendingSplit);
+            return new InfluxDBSourceState(shouldEnumerate, pendingSplit, 
assignCount.get());
         }
     }
 
@@ -181,13 +193,23 @@ public class InfluxDBSourceSplitEnumerator
 
     private void addPendingSplit(Collection<InfluxDBSourceSplit> splits) {
         int readerCount = context.currentParallelism();
-        for (InfluxDBSourceSplit split : splits) {
-            int ownerReader = getSplitOwner(split.splitId(), readerCount);
+
+        List<InfluxDBSourceSplit> sortedSplits =
+                splits.stream()
+                        
.sorted(Comparator.comparing(InfluxDBSourceSplit::splitId))
+                        .collect(Collectors.toList());
+
+        for (InfluxDBSourceSplit split : sortedSplits) {
+            int ownerReader = getSplitOwner(assignCount.getAndIncrement(), 
readerCount);
             log.info("Assigning {} to {} reader.", split, ownerReader);
             pendingSplit.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).add(split);
         }
     }
 
+    private void addPendingSplit(Collection<InfluxDBSourceSplit> splits, int 
ownerReader) {
+        pendingSplit.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).addAll(splits);
+    }
+
     private void assignSplit(Collection<Integer> readers) {
         log.debug("Assign pendingSplits to readers {}", readers);
 
@@ -209,8 +231,8 @@ public class InfluxDBSourceSplitEnumerator
         }
     }
 
-    private static int getSplitOwner(String tp, int numReaders) {
-        return (tp.hashCode() & Integer.MAX_VALUE) % numReaders;
+    private static int getSplitOwner(int currentAssignCount, int numReaders) {
+        return currentAssignCount % numReaders;
     }
 
     @Override
diff --git 
a/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/state/InfluxDBSourceState.java
 
b/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/state/InfluxDBSourceState.java
index 29b0dc2317..4a7d11abbf 100644
--- 
a/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/state/InfluxDBSourceState.java
+++ 
b/seatunnel-connectors-v2/connector-influxdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/influxdb/state/InfluxDBSourceState.java
@@ -33,4 +33,5 @@ public class InfluxDBSourceState implements Serializable {
     private static final long serialVersionUID = 7132198105704653582L;
     private boolean shouldEnumerate;
     private Map<Integer, List<InfluxDBSourceSplit>> pendingSplit;
+    private int assignCount;
 }
diff --git 
a/seatunnel-connectors-v2/connector-influxdb/src/test/java/org/apache/seatunnel/connectors/seatunnel/influxdb/source/InfluxDBSourceSplitEnumeratorTest.java
 
b/seatunnel-connectors-v2/connector-influxdb/src/test/java/org/apache/seatunnel/connectors/seatunnel/influxdb/source/InfluxDBSourceSplitEnumeratorTest.java
new file mode 100644
index 0000000000..4b787d4bff
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-influxdb/src/test/java/org/apache/seatunnel/connectors/seatunnel/influxdb/source/InfluxDBSourceSplitEnumeratorTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.influxdb.source;
+
+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.seatunnel.influxdb.state.InfluxDBSourceState;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class InfluxDBSourceSplitEnumeratorTest {
+
+    @Test
+    public void shouldBalanceSplitsEvenlyAcrossReaders() throws Exception {
+        TestingContext context = new TestingContext(4);
+        InfluxDBSourceSplitEnumerator enumerator =
+                new InfluxDBSourceSplitEnumerator(context, null, null);
+
+        invokeAddPendingSplit(enumerator, buildSplits(10));
+
+        InfluxDBSourceState state = enumerator.snapshotState(1L);
+        Map<Integer, List<InfluxDBSourceSplit>> pendingSplits = 
state.getPendingSplit();
+
+        Assertions.assertEquals(4, pendingSplits.size());
+        Assertions.assertEquals(3, pendingSplits.get(0).size());
+        Assertions.assertEquals(3, pendingSplits.get(1).size());
+        Assertions.assertEquals(2, pendingSplits.get(2).size());
+        Assertions.assertEquals(2, pendingSplits.get(3).size());
+    }
+
+    @Test
+    public void shouldContinueRoundRobinAfterRestore() throws Exception {
+        TestingContext context = new TestingContext(3);
+        InfluxDBSourceState restoredState = new InfluxDBSourceState(true, new 
HashMap<>(), 1);
+        InfluxDBSourceSplitEnumerator enumerator =
+                new InfluxDBSourceSplitEnumerator(context, restoredState, 
null);
+
+        invokeAddPendingSplit(
+                enumerator, Collections.singletonList(new 
InfluxDBSourceSplit("7", "")));
+
+        InfluxDBSourceState state = enumerator.snapshotState(1L);
+
+        Assertions.assertFalse(state.getPendingSplit().containsKey(0));
+        Assertions.assertEquals(1, state.getPendingSplit().get(1).size());
+        Assertions.assertFalse(state.getPendingSplit().containsKey(2));
+    }
+
+    @Test
+    public void shouldReassignReturnedSplitsToOriginalReader() throws 
Exception {
+        TestingContext context = new TestingContext(3);
+        InfluxDBSourceSplitEnumerator enumerator =
+                new InfluxDBSourceSplitEnumerator(context, null, null);
+
+        enumerator.addSplitsBack(buildSplits(3), 2);
+
+        Assertions.assertEquals(0, context.getAssignedSplitCount(0));
+        Assertions.assertEquals(0, context.getAssignedSplitCount(1));
+        Assertions.assertEquals(3, context.getAssignedSplitCount(2));
+
+        InfluxDBSourceState state = enumerator.snapshotState(1L);
+        Assertions.assertTrue(state.getPendingSplit().isEmpty());
+    }
+
+    private void invokeAddPendingSplit(
+            InfluxDBSourceSplitEnumerator enumerator, 
List<InfluxDBSourceSplit> splits)
+            throws Exception {
+        Method addPendingSplit =
+                InfluxDBSourceSplitEnumerator.class.getDeclaredMethod(
+                        "addPendingSplit", java.util.Collection.class);
+        addPendingSplit.setAccessible(true);
+        addPendingSplit.invoke(enumerator, splits);
+    }
+
+    private List<InfluxDBSourceSplit> buildSplits(int size) {
+        List<InfluxDBSourceSplit> splits = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            splits.add(new InfluxDBSourceSplit(String.valueOf(i), "query-" + 
i));
+        }
+        return splits;
+    }
+
+    private static class TestingContext
+            implements SourceSplitEnumerator.Context<InfluxDBSourceSplit> {
+        private final int parallelism;
+        private final Set<Integer> readers;
+        private final Map<Integer, List<InfluxDBSourceSplit>> assignments = 
new HashMap<>();
+
+        private TestingContext(int parallelism) {
+            this.parallelism = parallelism;
+            this.readers = new LinkedHashSet<>();
+            for (int i = 0; i < parallelism; i++) {
+                readers.add(i);
+            }
+        }
+
+        @Override
+        public int currentParallelism() {
+            return parallelism;
+        }
+
+        @Override
+        public Set<Integer> registeredReaders() {
+            return readers;
+        }
+
+        @Override
+        public void assignSplit(int subtaskId, List<InfluxDBSourceSplit> 
splits) {
+            assignments.computeIfAbsent(subtaskId, ignored -> new 
ArrayList<>()).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;
+        }
+
+        private int getAssignedSplitCount(int subtaskId) {
+            return assignments.getOrDefault(subtaskId, 
Collections.emptyList()).size();
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/source/IoTDBv2SourceSplitEnumerator.java
 
b/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/source/IoTDBv2SourceSplitEnumerator.java
index 0da8de9bae..b3bc5f6ada 100644
--- 
a/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/source/IoTDBv2SourceSplitEnumerator.java
+++ 
b/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/source/IoTDBv2SourceSplitEnumerator.java
@@ -30,11 +30,14 @@ import lombok.extern.slf4j.Slf4j;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 
 import static 
org.apache.seatunnel.connectors.seatunnel.iotdbv2.config.IoTDBv2SourceOptions.LOWER_BOUND;
 import static 
org.apache.seatunnel.connectors.seatunnel.iotdbv2.config.IoTDBv2SourceOptions.NUM_PARTITIONS;
@@ -59,6 +62,7 @@ public class IoTDBv2SourceSplitEnumerator
     private final Context<IoTDBv2SourceSplit> context;
     private final ReadonlyConfig conf;
     private final Map<Integer, List<IoTDBv2SourceSplit>> pendingSplit;
+    private final AtomicInteger assignCount = new AtomicInteger(0);
     private volatile boolean shouldEnumerate;
 
     public IoTDBv2SourceSplitEnumerator(
@@ -77,6 +81,7 @@ public class IoTDBv2SourceSplitEnumerator
         if (sourceState != null) {
             this.shouldEnumerate = sourceState.isShouldEnumerate();
             this.pendingSplit.putAll(sourceState.getPendingSplit());
+            this.assignCount.set(sourceState.getAssignCount());
         }
     }
 
@@ -183,8 +188,15 @@ public class IoTDBv2SourceSplitEnumerator
     public void addSplitsBack(List<IoTDBv2SourceSplit> splits, int subtaskId) {
         log.debug("Add back splits {} to IoTDBSourceSplitEnumerator.", splits);
         if (!splits.isEmpty()) {
-            addPendingSplit(splits);
-            assignSplit(Collections.singletonList(subtaskId));
+            addPendingSplit(splits, subtaskId);
+            if (context.registeredReaders().contains(subtaskId)) {
+                assignSplit(Collections.singletonList(subtaskId));
+            } else {
+                log.warn(
+                        "Reader {} is not registered. Pending splits {} are 
not assigned.",
+                        subtaskId,
+                        splits);
+            }
         }
     }
 
@@ -204,14 +216,26 @@ public class IoTDBv2SourceSplitEnumerator
     private void addPendingSplit(Collection<IoTDBv2SourceSplit> splits) {
         synchronized (stateLock) {
             int readerCount = context.currentParallelism();
-            for (IoTDBv2SourceSplit split : splits) {
-                int ownerReader = getSplitOwner(split.splitId(), readerCount);
+
+            List<IoTDBv2SourceSplit> sortedSplits =
+                    splits.stream()
+                            
.sorted(Comparator.comparing(IoTDBv2SourceSplit::splitId))
+                            .collect(Collectors.toList());
+
+            for (IoTDBv2SourceSplit split : sortedSplits) {
+                int ownerReader = getSplitOwner(assignCount.getAndIncrement(), 
readerCount);
                 log.info("Assigning {} to {} reader.", split, ownerReader);
                 pendingSplit.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).add(split);
             }
         }
     }
 
+    private void addPendingSplit(Collection<IoTDBv2SourceSplit> splits, int 
ownerReader) {
+        synchronized (stateLock) {
+            pendingSplit.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).addAll(splits);
+        }
+    }
+
     private void assignSplit(Collection<Integer> readers) {
         log.debug("Assign pendingSplits to readers {}", readers);
 
@@ -238,12 +262,12 @@ public class IoTDBv2SourceSplitEnumerator
     @Override
     public IoTDBv2SourceState snapshotState(long checkpointId) throws 
Exception {
         synchronized (stateLock) {
-            return new IoTDBv2SourceState(shouldEnumerate, pendingSplit);
+            return new IoTDBv2SourceState(shouldEnumerate, pendingSplit, 
assignCount.get());
         }
     }
 
-    private static int getSplitOwner(String tp, int numReaders) {
-        return (tp.hashCode() & Integer.MAX_VALUE) % numReaders;
+    private static int getSplitOwner(int currentAssignCount, int numReaders) {
+        return currentAssignCount % numReaders;
     }
 
     @Override
diff --git 
a/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/state/IoTDBv2SourceState.java
 
b/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/state/IoTDBv2SourceState.java
index 9ddb15725c..149e0556e4 100644
--- 
a/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/state/IoTDBv2SourceState.java
+++ 
b/seatunnel-connectors-v2/connector-iotdb-v2/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/state/IoTDBv2SourceState.java
@@ -33,4 +33,5 @@ public class IoTDBv2SourceState implements Serializable {
     private static final long serialVersionUID = 7142773921678153583L;
     private boolean shouldEnumerate;
     private Map<Integer, List<IoTDBv2SourceSplit>> pendingSplit;
+    private int assignCount;
 }
diff --git 
a/seatunnel-connectors-v2/connector-iotdb-v2/src/test/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/source/IoTDBv2SourceSplitEnumeratorTest.java
 
b/seatunnel-connectors-v2/connector-iotdb-v2/src/test/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/source/IoTDBv2SourceSplitEnumeratorTest.java
new file mode 100644
index 0000000000..226bb893b8
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-iotdb-v2/src/test/java/org/apache/seatunnel/connectors/seatunnel/iotdbv2/source/IoTDBv2SourceSplitEnumeratorTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.iotdbv2.source;
+
+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.seatunnel.iotdbv2.state.IoTDBv2SourceState;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class IoTDBv2SourceSplitEnumeratorTest {
+
+    @Test
+    public void shouldBalanceSplitsEvenlyAcrossReaders() throws Exception {
+        TestingContext context = new TestingContext(4);
+        IoTDBv2SourceSplitEnumerator enumerator =
+                new IoTDBv2SourceSplitEnumerator(context, null, null);
+
+        invokeAddPendingSplit(enumerator, buildSplits(10));
+
+        IoTDBv2SourceState state = enumerator.snapshotState(1L);
+        Map<Integer, List<IoTDBv2SourceSplit>> pendingSplits = 
state.getPendingSplit();
+
+        Assertions.assertEquals(4, pendingSplits.size());
+        Assertions.assertEquals(3, pendingSplits.get(0).size());
+        Assertions.assertEquals(3, pendingSplits.get(1).size());
+        Assertions.assertEquals(2, pendingSplits.get(2).size());
+        Assertions.assertEquals(2, pendingSplits.get(3).size());
+    }
+
+    @Test
+    public void shouldContinueRoundRobinAfterRestore() throws Exception {
+        TestingContext context = new TestingContext(3);
+        IoTDBv2SourceState restoredState = new IoTDBv2SourceState(true, new 
HashMap<>(), 1);
+        IoTDBv2SourceSplitEnumerator enumerator =
+                new IoTDBv2SourceSplitEnumerator(context, null, restoredState);
+
+        invokeAddPendingSplit(
+                enumerator, Collections.singletonList(new 
IoTDBv2SourceSplit("7", "")));
+
+        IoTDBv2SourceState state = enumerator.snapshotState(1L);
+
+        Assertions.assertFalse(state.getPendingSplit().containsKey(0));
+        Assertions.assertEquals(1, state.getPendingSplit().get(1).size());
+        Assertions.assertFalse(state.getPendingSplit().containsKey(2));
+    }
+
+    @Test
+    public void shouldReassignReturnedSplitsToOriginalReader() throws 
Exception {
+        TestingContext context = new TestingContext(3);
+        IoTDBv2SourceSplitEnumerator enumerator =
+                new IoTDBv2SourceSplitEnumerator(context, null, null);
+
+        enumerator.addSplitsBack(buildSplits(3), 2);
+
+        Assertions.assertEquals(0, context.getAssignedSplitCount(0));
+        Assertions.assertEquals(0, context.getAssignedSplitCount(1));
+        Assertions.assertEquals(3, context.getAssignedSplitCount(2));
+
+        IoTDBv2SourceState state = enumerator.snapshotState(1L);
+        Assertions.assertTrue(state.getPendingSplit().isEmpty());
+    }
+
+    private void invokeAddPendingSplit(
+            IoTDBv2SourceSplitEnumerator enumerator, List<IoTDBv2SourceSplit> 
splits)
+            throws Exception {
+        Method addPendingSplit =
+                IoTDBv2SourceSplitEnumerator.class.getDeclaredMethod(
+                        "addPendingSplit", java.util.Collection.class);
+        addPendingSplit.setAccessible(true);
+        addPendingSplit.invoke(enumerator, splits);
+    }
+
+    private List<IoTDBv2SourceSplit> buildSplits(int size) {
+        List<IoTDBv2SourceSplit> splits = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            splits.add(new IoTDBv2SourceSplit(String.valueOf(i), "query-" + 
i));
+        }
+        return splits;
+    }
+
+    private static class TestingContext
+            implements SourceSplitEnumerator.Context<IoTDBv2SourceSplit> {
+        private final int parallelism;
+        private final Set<Integer> readers;
+        private final Map<Integer, List<IoTDBv2SourceSplit>> assignments = new 
HashMap<>();
+
+        private TestingContext(int parallelism) {
+            this.parallelism = parallelism;
+            this.readers = new LinkedHashSet<>();
+            for (int i = 0; i < parallelism; i++) {
+                readers.add(i);
+            }
+        }
+
+        @Override
+        public int currentParallelism() {
+            return parallelism;
+        }
+
+        @Override
+        public Set<Integer> registeredReaders() {
+            return readers;
+        }
+
+        @Override
+        public void assignSplit(int subtaskId, List<IoTDBv2SourceSplit> 
splits) {
+            assignments.computeIfAbsent(subtaskId, ignored -> new 
ArrayList<>()).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;
+        }
+
+        private int getAssignedSplitCount(int subtaskId) {
+            return assignments.getOrDefault(subtaskId, 
Collections.emptyList()).size();
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/source/IoTDBSourceSplitEnumerator.java
 
b/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/source/IoTDBSourceSplitEnumerator.java
index a004819591..d9bdd0e221 100644
--- 
a/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/source/IoTDBSourceSplitEnumerator.java
+++ 
b/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/source/IoTDBSourceSplitEnumerator.java
@@ -30,11 +30,14 @@ import lombok.extern.slf4j.Slf4j;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 
 import static 
org.apache.iotdb.tsfile.common.constant.QueryConstant.RESERVED_TIME;
 import static 
org.apache.seatunnel.connectors.seatunnel.iotdb.config.IoTDBSourceOptions.LOWER_BOUND;
@@ -59,6 +62,7 @@ public class IoTDBSourceSplitEnumerator
     private final Context<IoTDBSourceSplit> context;
     private final ReadonlyConfig conf;
     private final Map<Integer, List<IoTDBSourceSplit>> pendingSplit;
+    private final AtomicInteger assignCount = new AtomicInteger(0);
     private volatile boolean shouldEnumerate;
 
     public IoTDBSourceSplitEnumerator(
@@ -77,6 +81,7 @@ public class IoTDBSourceSplitEnumerator
         if (sourceState != null) {
             this.shouldEnumerate = sourceState.isShouldEnumerate();
             this.pendingSplit.putAll(sourceState.getPendingSplit());
+            this.assignCount.set(sourceState.getAssignCount());
         }
     }
 
@@ -183,8 +188,15 @@ public class IoTDBSourceSplitEnumerator
     public void addSplitsBack(List<IoTDBSourceSplit> splits, int subtaskId) {
         log.debug("Add back splits {} to IoTDBSourceSplitEnumerator.", splits);
         if (!splits.isEmpty()) {
-            addPendingSplit(splits);
-            assignSplit(Collections.singletonList(subtaskId));
+            addPendingSplit(splits, subtaskId);
+            if (context.registeredReaders().contains(subtaskId)) {
+                assignSplit(Collections.singletonList(subtaskId));
+            } else {
+                log.warn(
+                        "Reader {} is not registered. Pending splits {} are 
not assigned.",
+                        subtaskId,
+                        splits);
+            }
         }
     }
 
@@ -203,13 +215,23 @@ public class IoTDBSourceSplitEnumerator
 
     private void addPendingSplit(Collection<IoTDBSourceSplit> splits) {
         int readerCount = context.currentParallelism();
-        for (IoTDBSourceSplit split : splits) {
-            int ownerReader = getSplitOwner(split.splitId(), readerCount);
+
+        List<IoTDBSourceSplit> sortedSplits =
+                splits.stream()
+                        
.sorted(Comparator.comparing(IoTDBSourceSplit::splitId))
+                        .collect(Collectors.toList());
+
+        for (IoTDBSourceSplit split : sortedSplits) {
+            int ownerReader = getSplitOwner(assignCount.getAndIncrement(), 
readerCount);
             log.info("Assigning {} to {} reader.", split, ownerReader);
             pendingSplit.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).add(split);
         }
     }
 
+    private void addPendingSplit(Collection<IoTDBSourceSplit> splits, int 
ownerReader) {
+        pendingSplit.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).addAll(splits);
+    }
+
     private void assignSplit(Collection<Integer> readers) {
         log.debug("Assign pendingSplits to readers {}", readers);
 
@@ -234,12 +256,12 @@ public class IoTDBSourceSplitEnumerator
     @Override
     public IoTDBSourceState snapshotState(long checkpointId) throws Exception {
         synchronized (stateLock) {
-            return new IoTDBSourceState(shouldEnumerate, pendingSplit);
+            return new IoTDBSourceState(shouldEnumerate, pendingSplit, 
assignCount.get());
         }
     }
 
-    private static int getSplitOwner(String tp, int numReaders) {
-        return (tp.hashCode() & Integer.MAX_VALUE) % numReaders;
+    private static int getSplitOwner(int currentAssignCount, int numReaders) {
+        return currentAssignCount % numReaders;
     }
 
     @Override
diff --git 
a/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/state/IoTDBSourceState.java
 
b/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/state/IoTDBSourceState.java
index 1d8fdc39bd..b2d5eba147 100644
--- 
a/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/state/IoTDBSourceState.java
+++ 
b/seatunnel-connectors-v2/connector-iotdb/src/main/java/org/apache/seatunnel/connectors/seatunnel/iotdb/state/IoTDBSourceState.java
@@ -33,4 +33,5 @@ public class IoTDBSourceState implements Serializable {
     private static final long serialVersionUID = 7142773921678153583L;
     private boolean shouldEnumerate;
     private Map<Integer, List<IoTDBSourceSplit>> pendingSplit;
+    private int assignCount;
 }
diff --git 
a/seatunnel-connectors-v2/connector-iotdb/src/test/java/org/apache/seatunnel/connectors/seatunnel/iotdb/source/IoTDBSourceSplitEnumeratorTest.java
 
b/seatunnel-connectors-v2/connector-iotdb/src/test/java/org/apache/seatunnel/connectors/seatunnel/iotdb/source/IoTDBSourceSplitEnumeratorTest.java
new file mode 100644
index 0000000000..9647d9235e
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-iotdb/src/test/java/org/apache/seatunnel/connectors/seatunnel/iotdb/source/IoTDBSourceSplitEnumeratorTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.iotdb.source;
+
+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.seatunnel.iotdb.state.IoTDBSourceState;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class IoTDBSourceSplitEnumeratorTest {
+
+    @Test
+    public void shouldBalanceSplitsEvenlyAcrossReaders() throws Exception {
+        TestingContext context = new TestingContext(4);
+        IoTDBSourceSplitEnumerator enumerator = new 
IoTDBSourceSplitEnumerator(context, null, null);
+
+        invokeAddPendingSplit(enumerator, buildSplits(10));
+
+        IoTDBSourceState state = enumerator.snapshotState(1L);
+        Map<Integer, List<IoTDBSourceSplit>> pendingSplits = 
state.getPendingSplit();
+
+        Assertions.assertEquals(4, pendingSplits.size());
+        Assertions.assertEquals(3, pendingSplits.get(0).size());
+        Assertions.assertEquals(3, pendingSplits.get(1).size());
+        Assertions.assertEquals(2, pendingSplits.get(2).size());
+        Assertions.assertEquals(2, pendingSplits.get(3).size());
+    }
+
+    @Test
+    public void shouldContinueRoundRobinAfterRestore() throws Exception {
+        TestingContext context = new TestingContext(3);
+        IoTDBSourceState restoredState = new IoTDBSourceState(true, new 
HashMap<>(), 1);
+        IoTDBSourceSplitEnumerator enumerator =
+                new IoTDBSourceSplitEnumerator(context, null, restoredState);
+
+        invokeAddPendingSplit(enumerator, Collections.singletonList(new 
IoTDBSourceSplit("7", "")));
+
+        IoTDBSourceState state = enumerator.snapshotState(1L);
+
+        Assertions.assertFalse(state.getPendingSplit().containsKey(0));
+        Assertions.assertEquals(1, state.getPendingSplit().get(1).size());
+        Assertions.assertFalse(state.getPendingSplit().containsKey(2));
+    }
+
+    @Test
+    public void shouldReassignReturnedSplitsToOriginalReader() throws 
Exception {
+        TestingContext context = new TestingContext(3);
+        IoTDBSourceSplitEnumerator enumerator = new 
IoTDBSourceSplitEnumerator(context, null, null);
+
+        enumerator.addSplitsBack(buildSplits(3), 2);
+
+        Assertions.assertEquals(0, context.getAssignedSplitCount(0));
+        Assertions.assertEquals(0, context.getAssignedSplitCount(1));
+        Assertions.assertEquals(3, context.getAssignedSplitCount(2));
+
+        IoTDBSourceState state = enumerator.snapshotState(1L);
+        Assertions.assertTrue(state.getPendingSplit().isEmpty());
+    }
+
+    private void invokeAddPendingSplit(
+            IoTDBSourceSplitEnumerator enumerator, List<IoTDBSourceSplit> 
splits) throws Exception {
+        Method addPendingSplit =
+                IoTDBSourceSplitEnumerator.class.getDeclaredMethod(
+                        "addPendingSplit", java.util.Collection.class);
+        addPendingSplit.setAccessible(true);
+        addPendingSplit.invoke(enumerator, splits);
+    }
+
+    private List<IoTDBSourceSplit> buildSplits(int size) {
+        List<IoTDBSourceSplit> splits = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            splits.add(new IoTDBSourceSplit(String.valueOf(i), "query-" + i));
+        }
+        return splits;
+    }
+
+    private static class TestingContext implements 
SourceSplitEnumerator.Context<IoTDBSourceSplit> {
+        private final int parallelism;
+        private final Set<Integer> readers;
+        private final Map<Integer, List<IoTDBSourceSplit>> assignments = new 
HashMap<>();
+
+        private TestingContext(int parallelism) {
+            this.parallelism = parallelism;
+            this.readers = new LinkedHashSet<>();
+            for (int i = 0; i < parallelism; i++) {
+                readers.add(i);
+            }
+        }
+
+        @Override
+        public int currentParallelism() {
+            return parallelism;
+        }
+
+        @Override
+        public Set<Integer> registeredReaders() {
+            return readers;
+        }
+
+        @Override
+        public void assignSplit(int subtaskId, List<IoTDBSourceSplit> splits) {
+            assignments.computeIfAbsent(subtaskId, ignored -> new 
ArrayList<>()).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;
+        }
+
+        private int getAssignedSplitCount(int subtaskId) {
+            return assignments.getOrDefault(subtaskId, 
Collections.emptyList()).size();
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/source/KuduSourceSplitEnumerator.java
 
b/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/source/KuduSourceSplitEnumerator.java
index 469332da1c..6121e005a4 100644
--- 
a/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/source/KuduSourceSplitEnumerator.java
+++ 
b/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/source/KuduSourceSplitEnumerator.java
@@ -33,11 +33,13 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
@@ -53,6 +55,7 @@ public class KuduSourceSplitEnumerator
     private final Map<Integer, List<KuduSourceSplit>> pendingSplits;
     private final Map<TablePath, KuduSourceTableConfig> tables;
     private final KuduInputFormat kuduInputFormat;
+    private final AtomicInteger assignCount = new AtomicInteger(0);
 
     private final Object stateLock = new Object();
 
@@ -79,6 +82,7 @@ public class KuduSourceSplitEnumerator
         } else {
             this.pendingTables = new 
ConcurrentLinkedQueue<>(checkpointState.getPendingTables());
             this.pendingSplits = new 
HashMap<>(checkpointState.getPendingSplits());
+            this.assignCount.set(checkpointState.getAssignCount());
         }
     }
 
@@ -160,8 +164,14 @@ public class KuduSourceSplitEnumerator
 
     private void addPendingSplit(Collection<KuduSourceSplit> splits) {
         int readerCount = enumeratorContext.currentParallelism();
-        for (KuduSourceSplit split : splits) {
-            int ownerReader = getSplitOwner(split.splitId(), readerCount);
+
+        List<KuduSourceSplit> sortedSplits =
+                splits.stream()
+                        .sorted(Comparator.comparing(KuduSourceSplit::splitId))
+                        .collect(Collectors.toList());
+
+        for (KuduSourceSplit split : sortedSplits) {
+            int ownerReader = getSplitOwner(assignCount.getAndIncrement(), 
readerCount);
             log.info("Assigning {} to {} reader.", split, ownerReader);
             pendingSplits.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).add(split);
         }
@@ -171,8 +181,8 @@ public class KuduSourceSplitEnumerator
         pendingSplits.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).addAll(splits);
     }
 
-    private int getSplitOwner(String splitId, int numReaders) {
-        return (splitId.hashCode() & Integer.MAX_VALUE) % numReaders;
+    private int getSplitOwner(int currentAssignCount, int numReaders) {
+        return currentAssignCount % numReaders;
     }
 
     @Override
@@ -198,7 +208,10 @@ public class KuduSourceSplitEnumerator
     @Override
     public KuduSourceState snapshotState(long checkpointId) throws Exception {
         synchronized (stateLock) {
-            return new KuduSourceState(new ArrayList(pendingTables), new 
HashMap<>(pendingSplits));
+            return new KuduSourceState(
+                    new ArrayList<>(pendingTables),
+                    new HashMap<>(pendingSplits),
+                    assignCount.get());
         }
     }
 
diff --git 
a/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/state/KuduSourceState.java
 
b/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/state/KuduSourceState.java
index 95cd02dd90..0b76218aef 100644
--- 
a/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/state/KuduSourceState.java
+++ 
b/seatunnel-connectors-v2/connector-kudu/src/main/java/org/apache/seatunnel/connectors/seatunnel/kudu/state/KuduSourceState.java
@@ -33,4 +33,5 @@ public class KuduSourceState implements Serializable {
     private static final long serialVersionUID = -3141157457869831037L;
     private List<TablePath> pendingTables;
     private Map<Integer, List<KuduSourceSplit>> pendingSplits;
+    private int assignCount;
 }
diff --git 
a/seatunnel-connectors-v2/connector-kudu/src/test/java/org/apache/seatunnel/connectors/seatunnel/kudu/source/KuduSourceSplitEnumeratorTest.java
 
b/seatunnel-connectors-v2/connector-kudu/src/test/java/org/apache/seatunnel/connectors/seatunnel/kudu/source/KuduSourceSplitEnumeratorTest.java
new file mode 100644
index 0000000000..d992f037a3
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-kudu/src/test/java/org/apache/seatunnel/connectors/seatunnel/kudu/source/KuduSourceSplitEnumeratorTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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.kudu.source;
+
+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.api.table.catalog.TablePath;
+import org.apache.seatunnel.connectors.seatunnel.kudu.config.KuduSourceConfig;
+import org.apache.seatunnel.connectors.seatunnel.kudu.state.KuduSourceState;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import sun.misc.Unsafe;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class KuduSourceSplitEnumeratorTest {
+
+    @Test
+    public void shouldBalanceSplitsEvenlyAcrossReaders() throws Exception {
+        TestingContext context = new TestingContext(4);
+        KuduSourceSplitEnumerator enumerator =
+                new KuduSourceSplitEnumerator(context, mockSourceConfig(), 
null);
+
+        invokeAddPendingSplit(enumerator, buildSplits(10));
+
+        KuduSourceState state = enumerator.snapshotState(1L);
+        Map<Integer, List<KuduSourceSplit>> pendingSplits = 
state.getPendingSplits();
+
+        Assertions.assertEquals(4, pendingSplits.size());
+        Assertions.assertEquals(3, pendingSplits.get(0).size());
+        Assertions.assertEquals(3, pendingSplits.get(1).size());
+        Assertions.assertEquals(2, pendingSplits.get(2).size());
+        Assertions.assertEquals(2, pendingSplits.get(3).size());
+    }
+
+    @Test
+    public void shouldContinueRoundRobinAfterRestore() throws Exception {
+        TestingContext context = new TestingContext(3);
+        KuduSourceState restoredState =
+                new KuduSourceState(Collections.emptyList(), new HashMap<>(), 
1);
+        KuduSourceSplitEnumerator enumerator =
+                new KuduSourceSplitEnumerator(context, mockSourceConfig(), 
restoredState);
+
+        invokeAddPendingSplit(enumerator, 
Collections.singletonList(buildSplit(7)));
+
+        KuduSourceState state = enumerator.snapshotState(1L);
+
+        Assertions.assertFalse(state.getPendingSplits().containsKey(0));
+        Assertions.assertEquals(1, state.getPendingSplits().get(1).size());
+        Assertions.assertFalse(state.getPendingSplits().containsKey(2));
+    }
+
+    @Test
+    public void shouldReassignReturnedSplitsToOriginalReader() {
+        TestingContext context = new TestingContext(3);
+        KuduSourceSplitEnumerator enumerator =
+                new KuduSourceSplitEnumerator(context, mockSourceConfig(), 
null);
+
+        enumerator.addSplitsBack(buildSplits(3), 2);
+
+        Assertions.assertEquals(0, context.getAssignedSplitCount(0));
+        Assertions.assertEquals(0, context.getAssignedSplitCount(1));
+        Assertions.assertEquals(3, context.getAssignedSplitCount(2));
+
+        Assertions.assertTrue(context.getAssignments().containsKey(2));
+    }
+
+    private void invokeAddPendingSplit(
+            KuduSourceSplitEnumerator enumerator, List<KuduSourceSplit> 
splits) throws Exception {
+        Method addPendingSplit =
+                KuduSourceSplitEnumerator.class.getDeclaredMethod(
+                        "addPendingSplit", java.util.Collection.class);
+        addPendingSplit.setAccessible(true);
+        addPendingSplit.invoke(enumerator, splits);
+    }
+
+    private List<KuduSourceSplit> buildSplits(int size) {
+        List<KuduSourceSplit> splits = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            splits.add(buildSplit(i));
+        }
+        return splits;
+    }
+
+    private KuduSourceSplit buildSplit(int splitId) {
+        return new KuduSourceSplit(TablePath.of("db", null, "table"), splitId, 
new byte[0]);
+    }
+
+    private KuduSourceConfig mockSourceConfig() {
+        try {
+            Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
+            unsafeField.setAccessible(true);
+            Unsafe unsafe = (Unsafe) unsafeField.get(null);
+            KuduSourceConfig sourceConfig =
+                    (KuduSourceConfig) 
unsafe.allocateInstance(KuduSourceConfig.class);
+
+            Field tableConfigListField = 
KuduSourceConfig.class.getDeclaredField("tableConfigList");
+            tableConfigListField.setAccessible(true);
+            tableConfigListField.set(sourceConfig, Collections.emptyList());
+            return sourceConfig;
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException("Failed to create test 
KuduSourceConfig", e);
+        }
+    }
+
+    private static class TestingContext implements 
SourceSplitEnumerator.Context<KuduSourceSplit> {
+        private final int parallelism;
+        private final Set<Integer> readers;
+        private final Map<Integer, List<KuduSourceSplit>> assignments = new 
HashMap<>();
+
+        private TestingContext(int parallelism) {
+            this.parallelism = parallelism;
+            this.readers = new LinkedHashSet<>();
+            for (int i = 0; i < parallelism; i++) {
+                readers.add(i);
+            }
+        }
+
+        @Override
+        public int currentParallelism() {
+            return parallelism;
+        }
+
+        @Override
+        public Set<Integer> registeredReaders() {
+            return readers;
+        }
+
+        @Override
+        public void assignSplit(int subtaskId, List<KuduSourceSplit> splits) {
+            assignments.computeIfAbsent(subtaskId, ignored -> new 
ArrayList<>()).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;
+        }
+
+        private int getAssignedSplitCount(int subtaskId) {
+            return assignments.getOrDefault(subtaskId, 
Collections.emptyList()).size();
+        }
+
+        private Map<Integer, List<KuduSourceSplit>> getAssignments() {
+            return assignments;
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/source/TDengineSourceSplitEnumerator.java
 
b/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/source/TDengineSourceSplitEnumerator.java
index 911a9a6ec1..a95f6fbf85 100644
--- 
a/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/source/TDengineSourceSplitEnumerator.java
+++ 
b/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/source/TDengineSourceSplitEnumerator.java
@@ -29,10 +29,12 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 
 @Slf4j
@@ -45,6 +47,7 @@ public class TDengineSourceSplitEnumerator
     private volatile boolean shouldEnumerate;
     private final Object stateLock = new Object();
     private final Map<Integer, List<TDengineSourceSplit>> pendingSplits = new 
ConcurrentHashMap<>();
+    private final AtomicInteger assignCount = new AtomicInteger(0);
 
     public TDengineSourceSplitEnumerator(
             StableMetadata stableMetadata,
@@ -65,11 +68,12 @@ public class TDengineSourceSplitEnumerator
         if (sourceState != null) {
             this.shouldEnumerate = sourceState.isShouldEnumerate();
             this.pendingSplits.putAll(sourceState.getPendingSplits());
+            this.assignCount.set(sourceState.getAssignCount());
         }
     }
 
-    private static int getSplitOwner(String tp, int numReaders) {
-        return (tp.hashCode() & Integer.MAX_VALUE) % numReaders;
+    private static int getSplitOwner(int currentAssignCount, int numReaders) {
+        return currentAssignCount % numReaders;
     }
 
     @Override
@@ -95,8 +99,14 @@ public class TDengineSourceSplitEnumerator
 
     private void addPendingSplit(List<TDengineSourceSplit> newSplits) {
         int readerCount = context.currentParallelism();
-        for (TDengineSourceSplit split : newSplits) {
-            int ownerReader = getSplitOwner(split.splitId(), readerCount);
+
+        List<TDengineSourceSplit> sortedSplits =
+                newSplits.stream()
+                        
.sorted(Comparator.comparing(TDengineSourceSplit::splitId))
+                        .collect(Collectors.toList());
+
+        for (TDengineSourceSplit split : sortedSplits) {
+            int ownerReader = getSplitOwner(assignCount.getAndIncrement(), 
readerCount);
             pendingSplits.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).add(split);
         }
     }
@@ -145,8 +155,15 @@ public class TDengineSourceSplitEnumerator
     public void addSplitsBack(List<TDengineSourceSplit> splits, int subtaskId) 
{
         log.info("Add back splits {} to TDengineSourceSplitEnumerator.", 
splits);
         if (!splits.isEmpty()) {
-            addPendingSplit(splits);
-            assignSplit(Collections.singletonList(subtaskId));
+            addPendingSplit(splits, subtaskId);
+            if (context.registeredReaders().contains(subtaskId)) {
+                assignSplit(Collections.singletonList(subtaskId));
+            } else {
+                log.warn(
+                        "Reader {} is not registered. Pending splits {} are 
not assigned.",
+                        subtaskId,
+                        splits);
+            }
         }
     }
 
@@ -184,10 +201,14 @@ public class TDengineSourceSplitEnumerator
         }
     }
 
+    private void addPendingSplit(Collection<TDengineSourceSplit> splits, int 
ownerReader) {
+        pendingSplits.computeIfAbsent(ownerReader, r -> new 
ArrayList<>()).addAll(splits);
+    }
+
     @Override
     public TDengineSourceState snapshotState(long checkpointId) {
         synchronized (stateLock) {
-            return new TDengineSourceState(shouldEnumerate, pendingSplits);
+            return new TDengineSourceState(shouldEnumerate, pendingSplits, 
assignCount.get());
         }
     }
 
diff --git 
a/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/state/TDengineSourceState.java
 
b/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/state/TDengineSourceState.java
index 0bd300a8d3..27e1f72b6a 100644
--- 
a/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/state/TDengineSourceState.java
+++ 
b/seatunnel-connectors-v2/connector-tdengine/src/main/java/org/apache/seatunnel/connectors/seatunnel/tdengine/state/TDengineSourceState.java
@@ -32,4 +32,5 @@ public class TDengineSourceState implements Serializable {
     private static final long serialVersionUID = 6915087497958523069L;
     private boolean shouldEnumerate;
     private final Map<Integer, List<TDengineSourceSplit>> pendingSplits;
+    private int assignCount;
 }
diff --git 
a/seatunnel-connectors-v2/connector-tdengine/src/test/java/org/apache/seatunnel/connectors/seatunnel/tdengine/source/TDengineSourceSplitEnumeratorTest.java
 
b/seatunnel-connectors-v2/connector-tdengine/src/test/java/org/apache/seatunnel/connectors/seatunnel/tdengine/source/TDengineSourceSplitEnumeratorTest.java
new file mode 100644
index 0000000000..70893c3900
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-tdengine/src/test/java/org/apache/seatunnel/connectors/seatunnel/tdengine/source/TDengineSourceSplitEnumeratorTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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.tdengine.source;
+
+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.seatunnel.tdengine.state.TDengineSourceState;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class TDengineSourceSplitEnumeratorTest {
+
+    @Test
+    public void shouldBalanceSplitsEvenlyAcrossReaders() throws Exception {
+        TestingContext context = new TestingContext(4);
+        TDengineSourceSplitEnumerator enumerator = newEnumerator(context, 
null);
+
+        invokeAddPendingSplit(enumerator, buildSplits(10));
+
+        TDengineSourceState state = enumerator.snapshotState(1L);
+        Map<Integer, List<TDengineSourceSplit>> pendingSplits = 
state.getPendingSplits();
+
+        Assertions.assertEquals(4, pendingSplits.size());
+        Assertions.assertEquals(3, pendingSplits.get(0).size());
+        Assertions.assertEquals(3, pendingSplits.get(1).size());
+        Assertions.assertEquals(2, pendingSplits.get(2).size());
+        Assertions.assertEquals(2, pendingSplits.get(3).size());
+    }
+
+    @Test
+    public void shouldContinueRoundRobinAfterRestore() throws Exception {
+        TestingContext context = new TestingContext(3);
+        TDengineSourceState restoredState = new TDengineSourceState(true, new 
HashMap<>(), 1);
+        TDengineSourceSplitEnumerator enumerator = newEnumerator(context, 
restoredState);
+
+        invokeAddPendingSplit(
+                enumerator, Collections.singletonList(new 
TDengineSourceSplit("7", "")));
+
+        TDengineSourceState state = enumerator.snapshotState(1L);
+
+        Assertions.assertFalse(state.getPendingSplits().containsKey(0));
+        Assertions.assertEquals(1, state.getPendingSplits().get(1).size());
+        Assertions.assertFalse(state.getPendingSplits().containsKey(2));
+    }
+
+    @Test
+    public void shouldReassignReturnedSplitsToOriginalReader() throws 
Exception {
+        TestingContext context = new TestingContext(3);
+        TDengineSourceSplitEnumerator enumerator = newEnumerator(context, 
null);
+
+        enumerator.addSplitsBack(buildSplits(3), 2);
+
+        Assertions.assertEquals(0, context.getAssignedSplitCount(0));
+        Assertions.assertEquals(0, context.getAssignedSplitCount(1));
+        Assertions.assertEquals(3, context.getAssignedSplitCount(2));
+
+        TDengineSourceState state = enumerator.snapshotState(1L);
+        Assertions.assertTrue(state.getPendingSplits().isEmpty());
+    }
+
+    private TDengineSourceSplitEnumerator newEnumerator(
+            TestingContext context, TDengineSourceState sourceState) {
+        return new TDengineSourceSplitEnumerator(
+                new StableMetadata(null, null, Collections.emptyList()),
+                null,
+                sourceState,
+                context);
+    }
+
+    private void invokeAddPendingSplit(
+            TDengineSourceSplitEnumerator enumerator, 
List<TDengineSourceSplit> splits)
+            throws Exception {
+        Method addPendingSplit =
+                TDengineSourceSplitEnumerator.class.getDeclaredMethod(
+                        "addPendingSplit", List.class);
+        addPendingSplit.setAccessible(true);
+        addPendingSplit.invoke(enumerator, splits);
+    }
+
+    private List<TDengineSourceSplit> buildSplits(int size) {
+        List<TDengineSourceSplit> splits = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            splits.add(new TDengineSourceSplit(String.valueOf(i), "query-" + 
i));
+        }
+        return splits;
+    }
+
+    private static class TestingContext
+            implements SourceSplitEnumerator.Context<TDengineSourceSplit> {
+        private final int parallelism;
+        private final Set<Integer> readers;
+        private final Map<Integer, List<TDengineSourceSplit>> assignments = 
new HashMap<>();
+
+        private TestingContext(int parallelism) {
+            this.parallelism = parallelism;
+            this.readers = new LinkedHashSet<>();
+            for (int i = 0; i < parallelism; i++) {
+                readers.add(i);
+            }
+        }
+
+        @Override
+        public int currentParallelism() {
+            return parallelism;
+        }
+
+        @Override
+        public Set<Integer> registeredReaders() {
+            return readers;
+        }
+
+        @Override
+        public void assignSplit(int subtaskId, List<TDengineSourceSplit> 
splits) {
+            assignments.computeIfAbsent(subtaskId, ignored -> new 
ArrayList<>()).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;
+        }
+
+        private int getAssignedSplitCount(int subtaskId) {
+            return assignments.getOrDefault(subtaskId, 
Collections.emptyList()).size();
+        }
+    }
+}

Reply via email to