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

MartijnVisser pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-kafka.git


The following commit(s) were added to refs/heads/main by this push:
     new bb1ed100 [FLINK-41088][Connectors/Kafka] Key recovery reports by 
partition
bb1ed100 is described below

commit bb1ed100d698e6205aee8818c5e2cd14fee3ae14
Author: MartijnVisser <[email protected]>
AuthorDate: Thu Oct 22 20:29:48 2026 +0200

    [FLINK-41088][Connectors/Kafka] Key recovery reports by partition
    
    The formatted split id is <cluster>-<topic>-<partition>, so cluster a
    with topic b-c and cluster a-b with topic c share the id a-b-c-0.
    ReaderRecoveryGate merged a reader's reports by that id and dropped one
    of the two partitions. reassignReportedSplits rejected two active ones
    reported by different readers, which failed every restore.
    
    Both now key by cluster id and TopicPartition, as the restarted-reader
    path already does. A partition reported by two readers still fails the
    restore, and the message now names the cluster.
    
    Generated-by: Claude Code 2.1.308 (Claude Opus 5.5)
---
 .../enumerator/DynamicKafkaSourceEnumerator.java   |  25 +-
 .../source/enumerator/ReaderRecoveryGate.java      |  15 +-
 .../DynamicKafkaSourceEnumeratorRecoveryTest.java  | 282 +++++++++++++++++++--
 .../source/enumerator/ReaderRecoveryGateTest.java  |  23 ++
 4 files changed, 315 insertions(+), 30 deletions(-)

diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumerator.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumerator.java
index 487a7400..19d580dc 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumerator.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumerator.java
@@ -83,6 +83,11 @@ public class DynamicKafkaSourceEnumerator
     private static final Logger logger =
             LoggerFactory.getLogger(DynamicKafkaSourceEnumerator.class);
 
+    /** Deterministic order for splits whose formatted ids can collide across 
clusters. */
+    private static final Comparator<DynamicKafkaSourceSplit> SPLIT_ORDER =
+            Comparator.comparing(DynamicKafkaSourceSplit::splitId)
+                    .thenComparing(DynamicKafkaSourceSplit::getKafkaClusterId);
+
     // Each cluster will have its own sub enumerator
     private final Map<String, SplitEnumerator<KafkaPartitionSplit, 
KafkaSourceEnumState>>
             clusterEnumeratorMap;
@@ -930,7 +935,7 @@ public class DynamicKafkaSourceEnumerator
     }
 
     private void reassignReportedSplits() {
-        Map<String, ReportedSplit> activeReportedSplits = new TreeMap<>();
+        Map<Tuple2<String, TopicPartition>, ReportedSplit> 
activeReportedSplits = new HashMap<>();
         Map<Integer, List<DynamicKafkaSourceSplit>> retainedSplitsByReader = 
new TreeMap<>();
         long currentTimeMillis = System.currentTimeMillis();
 
@@ -942,13 +947,13 @@ public class DynamicKafkaSourceEnumerator
                     DynamicKafkaSourceSplit activeSplit = 
split.clearRetention();
                     ReportedSplit previous =
                             activeReportedSplits.putIfAbsent(
-                                    activeSplit.splitId(),
+                                    toPartitionKey(activeSplit),
                                     new ReportedSplit(activeSplit, readerId));
                     if (previous != null) {
                         throw new IllegalStateException(
                                 String.format(
                                         "Split %s was reported by both reader 
%d and reader %d",
-                                        activeSplit.splitId(), 
previous.readerId, readerId));
+                                        activeSplit, previous.readerId, 
readerId));
                     }
                 } else {
                     DynamicKafkaSourceSplit retainedSplit =
@@ -964,8 +969,11 @@ public class DynamicKafkaSourceEnumerator
             }
         }
 
+        List<ReportedSplit> orderedReportedSplits = new 
ArrayList<>(activeReportedSplits.values());
+        orderedReportedSplits.sort(
+                Comparator.comparing(reportedSplit -> reportedSplit.split, 
SPLIT_ORDER));
         List<DynamicKafkaSourceSplit> activeSplits =
-                activeReportedSplits.values().stream()
+                orderedReportedSplits.stream()
                         .map(reportedSplit -> reportedSplit.split)
                         .collect(Collectors.toList());
         splitAssignmentStrategy.onRecoveredSplits(activeSplits, 
enumContext.currentParallelism());
@@ -975,7 +983,7 @@ public class DynamicKafkaSourceEnumerator
             // With the initial gate closed, registeredReaders() is empty, so 
the reader ID
             // has no effect here. The flush after reconciliation covers all 
readers. Enqueue
             // one batch per cluster to avoid scanning pending copies once per 
reported split.
-            int reportingReader = 
activeReportedSplits.values().iterator().next().readerId;
+            int reportingReader = orderedReportedSplits.get(0).readerId;
             addSplitsBackToClusterEnumerators(activeSplits, reportingReader, 
true);
         }
 
@@ -995,9 +1003,7 @@ public class DynamicKafkaSourceEnumerator
                 .drainReportedSplits(readerId)
                 .forEach(split -> reportedSplits.put(toPartitionKey(split), 
split));
         List<DynamicKafkaSourceSplit> orderedSplits = new 
ArrayList<>(reportedSplits.values());
-        orderedSplits.sort(
-                Comparator.comparing(DynamicKafkaSourceSplit::splitId)
-                        
.thenComparing(DynamicKafkaSourceSplit::getKafkaClusterId));
+        orderedSplits.sort(SPLIT_ORDER);
         for (DynamicKafkaSourceSplit split : orderedSplits) {
             if (!isCurrentSplitOwner(split, readerId)) {
                 continue;
@@ -1278,7 +1284,8 @@ public class DynamicKafkaSourceEnumerator
         }
     }
 
-    private static Tuple2<String, TopicPartition> 
toPartitionKey(DynamicKafkaSourceSplit split) {
+    /** Identifies the physical partition; formatted split ids collide across 
clusters. */
+    static Tuple2<String, TopicPartition> 
toPartitionKey(DynamicKafkaSourceSplit split) {
         return Tuple2.of(
                 split.getKafkaClusterId(), 
split.getKafkaPartitionSplit().getTopicPartition());
     }
diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
index 4cc8656e..b4e34166 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
@@ -19,8 +19,11 @@
 package org.apache.flink.connector.kafka.dynamic.source.enumerator;
 
 import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.java.tuple.Tuple2;
 import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
 
+import org.apache.kafka.common.TopicPartition;
+
 import javax.annotation.Nullable;
 
 import java.util.ArrayList;
@@ -99,7 +102,8 @@ class ReaderRecoveryGate {
     /**
      * Sub-enumerators can assign newly discovered splits while reported 
splits await reassignment.
      * A checkpoint may therefore contain both pending reports and reader-held 
splits, which must be
-     * merged on restore. Preferring the current report for overlapping split 
ids is defensive.
+     * merged on restore. Preferring the current report for the same physical 
partition is
+     * defensive.
      */
     private static List<DynamicKafkaSourceSplit> mergeReportedSplits(
             @Nullable List<DynamicKafkaSourceSplit> previousReportedSplits,
@@ -107,14 +111,15 @@ class ReaderRecoveryGate {
         if (previousReportedSplits == null || 
previousReportedSplits.isEmpty()) {
             return new ArrayList<>(reportedSplits);
         }
-        Map<String, DynamicKafkaSourceSplit> mergedBySplitId = new 
LinkedHashMap<>();
+        Map<Tuple2<String, TopicPartition>, DynamicKafkaSourceSplit> 
mergedByPartition =
+                new LinkedHashMap<>();
         for (DynamicKafkaSourceSplit split : previousReportedSplits) {
-            mergedBySplitId.put(split.splitId(), split);
+            
mergedByPartition.put(DynamicKafkaSourceEnumerator.toPartitionKey(split), 
split);
         }
         for (DynamicKafkaSourceSplit split : reportedSplits) {
-            mergedBySplitId.put(split.splitId(), split);
+            
mergedByPartition.put(DynamicKafkaSourceEnumerator.toPartitionKey(split), 
split);
         }
-        return new ArrayList<>(mergedBySplitId.values());
+        return new ArrayList<>(mergedByPartition.values());
     }
 
     /** Whether recovery gating is active and registrations must be deferred. 
*/
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
index d20c2116..2379ba42 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
@@ -24,6 +24,7 @@ import 
org.apache.flink.api.connector.source.SplitEnumeratorContext;
 import org.apache.flink.api.connector.source.SplitsAssignment;
 import 
org.apache.flink.api.connector.source.SupportsSplitReassignmentOnRecovery;
 import org.apache.flink.api.connector.source.mocks.MockSplitEnumeratorContext;
+import org.apache.flink.api.java.tuple.Tuple2;
 import org.apache.flink.connector.kafka.dynamic.metadata.ClusterMetadata;
 import org.apache.flink.connector.kafka.dynamic.metadata.KafkaMetadataService;
 import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
@@ -66,6 +67,7 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Recovery tests for {@link DynamicKafkaSourceEnumerator}. */
 public class DynamicKafkaSourceEnumeratorRecoveryTest {
@@ -990,6 +992,199 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
                 Arguments.of(false, 1));
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void 
testInitialRecoveryReRegistrationKeepsPartitionsWithCollidingSplitIds(
+            boolean bounded) throws Throwable {
+        DynamicKafkaSourceSplit activeSplit = createSplit("a", "b-c", 123L, 
1000L);
+        DynamicKafkaSourceSplit retainedSplit =
+                createSplit("a-b", "c", 456L, 
2000L).retainUntil(Long.MAX_VALUE);
+        assertThat(activeSplit.splitId()).isEqualTo(retainedSplit.splitId());
+        KafkaStream stream = createKafkaStream("stream", "a", "b-c");
+        List<DynamicKafkaSourceSplit> report = List.of(activeSplit, 
retainedSplit);
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(2);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                "stream",
+                                new 
MockKafkaMetadataService(Collections.singleton(stream)),
+                                context,
+                                createModeProperties("per_cluster"),
+                                createRestoredState(stream, "a", 
List.of(activeSplit)),
+                                bounded)) {
+            enumerator.start();
+            registerReaderAndReportRestoredSplits(context, enumerator, 0, 
report);
+            // Reader 0 fails while the initial gate still waits for reader 1.
+            context.unregisterReader(0);
+            enumerator.addSplitsBack(Collections.emptyList(), 0);
+            registerReaderAndReportRestoredSplits(context, enumerator, 0, 
report);
+            registerReaderAndReportRestoredSplits(context, enumerator, 1, 
Collections.emptyList());
+            context.runNextOneTimeCallable();
+            completePartitionDiscovery(getClusterEnumerator(enumerator, "a"));
+
+            assertExactCheckpointOffsets(context, 0, report);
+            assertSinglePhysicalOwners(context, 0, report.size());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void 
testRestoredPendingReportsKeepPartitionsWithCollidingSplitIds(boolean bounded)
+            throws Throwable {
+        DynamicKafkaSourceSplit activeSplit = createSplit("a", "b-c", 123L, 
1000L);
+        DynamicKafkaSourceSplit retainedSplit =
+                createSplit("a-b", "c", 456L, 
2000L).retainUntil(Long.MAX_VALUE);
+        assertThat(activeSplit.splitId()).isEqualTo(retainedSplit.splitId());
+        KafkaStream stream = createKafkaStream("stream", "a", "b-c");
+        List<DynamicKafkaSourceSplit> report = List.of(activeSplit, 
retainedSplit);
+        // A checkpoint taken while the initial gate was pending carries 
reader 0's report.
+        DynamicKafkaSourceEnumState restoredState =
+                serdeRoundTrip(
+                        new DynamicKafkaSourceEnumState(
+                                Collections.singleton(stream),
+                                Collections.singletonMap(
+                                        "a",
+                                        new KafkaSourceEnumState(
+                                                
unwrapSplits(List.of(activeSplit)),
+                                                Collections.emptyList(),
+                                                true)),
+                                Collections.emptyMap(),
+                                Collections.singletonMap(0, report)));
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(2);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                "stream",
+                                new 
MockKafkaMetadataService(Collections.singleton(stream)),
+                                context,
+                                createModeProperties("per_cluster"),
+                                restoredState,
+                                bounded)) {
+            enumerator.start();
+            registerReaderAndReportRestoredSplits(context, enumerator, 0, 
report);
+            registerReaderAndReportRestoredSplits(context, enumerator, 1, 
Collections.emptyList());
+            context.runNextOneTimeCallable();
+            completePartitionDiscovery(getClusterEnumerator(enumerator, "a"));
+
+            assertExactCheckpointOffsets(context, 0, report);
+            assertSinglePhysicalOwners(context, 0, report.size());
+        }
+    }
+
+    @ParameterizedTest
+    @MethodSource("collidingSplitModes")
+    public void testInitialRecoveryKeepsActivePartitionsWithCollidingSplitIds(
+            String mode, boolean bounded) throws Throwable {
+        DynamicKafkaSourceSplit firstSplit = createSplit("a", "b-c", 123L, 
1000L);
+        DynamicKafkaSourceSplit secondSplit = createSplit("a-b", "c", 456L, 
2000L);
+        assertThat(firstSplit.splitId()).isEqualTo(secondSplit.splitId());
+        KafkaStream stream = createKafkaStream("stream", Map.of("a", "b-c", 
"a-b", "c"));
+        DynamicKafkaSourceEnumState restoredState =
+                createRestoredState(
+                        stream, Map.of("a", List.of(firstSplit), "a-b", 
List.of(secondSplit)));
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(2);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                "stream",
+                                new 
MockKafkaMetadataService(Collections.singleton(stream)),
+                                context,
+                                createModeProperties(mode),
+                                restoredState,
+                                bounded)) {
+            enumerator.start();
+            registerReaderAndReportRestoredSplits(context, enumerator, 0, 
List.of(firstSplit));
+            registerReaderAndReportRestoredSplits(context, enumerator, 1, 
List.of(secondSplit));
+            context.runNextOneTimeCallable();
+            completePartitionDiscovery(getClusterEnumerator(enumerator, "a"));
+            completePartitionDiscovery(getClusterEnumerator(enumerator, 
"a-b"));
+
+            assertExactCheckpointOffsets(context, 0, List.of(firstSplit, 
secondSplit));
+            assertSinglePhysicalOwners(context, 0, 2);
+        }
+    }
+
+    @Test
+    public void testInitialRecoveryRejectsPartitionReportedByTwoReaders() 
throws Throwable {
+        DynamicKafkaSourceSplit split = createSplit("cluster-0", "topic", 
123L, 1000L);
+        KafkaStream stream = createKafkaStream("stream", "cluster-0", "topic");
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(2);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                "stream",
+                                new 
MockKafkaMetadataService(Collections.singleton(stream)),
+                                context,
+                                createGlobalModeProperties(),
+                                createRestoredState(stream, "cluster-0", 
List.of(split)))) {
+            enumerator.start();
+            context.runNextOneTimeCallable();
+            registerReaderAndReportRestoredSplits(context, enumerator, 0, 
List.of(split));
+
+            assertThatThrownBy(
+                            () ->
+                                    registerReaderAndReportRestoredSplits(
+                                            context,
+                                            enumerator,
+                                            1,
+                                            List.of(withOffset(split, 456L))))
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessageContaining("kafkaClusterId='cluster-0'")
+                    .hasMessageContaining("reported by both reader 0 and 
reader 1");
+        }
+    }
+
+    @ParameterizedTest
+    @MethodSource("collidingSplitModes")
+    public void testLocalRestartKeepsActivePartitionsWithCollidingSplitIds(
+            String mode, boolean bounded) throws Throwable {
+        DynamicKafkaSourceSplit firstSplit = createSplit("a", "b-c", 10L, 
1000L);
+        DynamicKafkaSourceSplit secondSplit = createSplit("a-b", "c", 20L, 
2000L);
+        assertThat(firstSplit.splitId()).isEqualTo(secondSplit.splitId());
+        KafkaStream stream = createKafkaStream("stream", Map.of("a", "b-c", 
"a-b", "c"));
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(1);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                "stream",
+                                new 
MockKafkaMetadataService(Collections.singleton(stream)),
+                                context,
+                                createModeProperties(mode),
+                                new DynamicKafkaSourceEnumState(),
+                                bounded)) {
+            enumerator.start();
+            registerReaderAndReportRestoredSplits(context, enumerator, 0, 
Collections.emptyList());
+            context.runNextOneTimeCallable();
+            completePartitionDiscovery(
+                    getClusterEnumerator(enumerator, "a"), 
firstSplit.getKafkaPartitionSplit());
+            completePartitionDiscovery(
+                    getClusterEnumerator(enumerator, "a-b"), 
secondSplit.getKafkaPartitionSplit());
+            assertExactCheckpointOffsets(context, 0, List.of(firstSplit, 
secondSplit));
+
+            int priorAssignments = 
context.getSplitsAssignmentSequence().size();
+            List<DynamicKafkaSourceSplit> report =
+                    List.of(withOffset(firstSplit, 500L), 
withOffset(secondSplit, 600L));
+            context.unregisterReader(0);
+            enumerator.addSplitsBack(Collections.emptyList(), 0);
+            registerReaderAndReportRestoredSplits(context, enumerator, 0, 
report);
+
+            assertExactCheckpointOffsets(context, priorAssignments, report);
+        }
+    }
+
+    private static Stream<Arguments> collidingSplitModes() {
+        return Stream.of(
+                Arguments.of("per_cluster", true),
+                Arguments.of("per_cluster", false),
+                Arguments.of("global", true),
+                Arguments.of("global", false));
+    }
+
     @Test
     public void testLocalFailurePreservesStillInactiveRetainedSplit() throws 
Throwable {
         String cluster = "cluster-0";
@@ -1645,7 +1840,15 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
                         split.getKafkaPartitionSplit().getTopicPartition(), 
offset));
     }
 
-    private static void completePartitionDiscovery(KafkaSourceEnumerator 
enumerator)
+    private static KafkaSourceEnumerator getClusterEnumerator(
+            DynamicKafkaSourceEnumerator enumerator, String clusterId) {
+        return (KafkaSourceEnumerator)
+                ((Map<?, ?>) Whitebox.getInternalState(enumerator, 
"clusterEnumeratorMap"))
+                        .get(clusterId);
+    }
+
+    private static void completePartitionDiscovery(
+            KafkaSourceEnumerator enumerator, KafkaPartitionSplit... 
discoveredSplits)
             throws Exception {
         Class<?> changeClass =
                 Class.forName(KafkaSourceEnumerator.class.getName() + 
"$PartitionSplitChange");
@@ -1657,7 +1860,7 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
         handler.setAccessible(true);
         handler.invoke(
                 enumerator,
-                constructor.newInstance(Collections.emptySet(), 
Collections.emptySet()),
+                constructor.newInstance(Set.of(discoveredSplits), 
Collections.emptySet()),
                 null);
     }
 
@@ -1678,7 +1881,7 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
             MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
             int skipAssignments,
             int expectedPartitions) {
-        Map<String, Set<Integer>> owners = new HashMap<>();
+        Map<Tuple2<String, TopicPartition>, Set<Integer>> owners = new 
HashMap<>();
         context.getSplitsAssignmentSequence().stream()
                 .skip(skipAssignments)
                 .forEach(
@@ -1690,8 +1893,9 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
                                                         splits.forEach(
                                                                 split ->
                                                                         
owners.computeIfAbsent(
-                                                                               
         split
-                                                                               
                 .splitId(),
+                                                                               
         DynamicKafkaSourceEnumerator
+                                                                               
                 .toPartitionKey(
+                                                                               
                         split),
                                                                                
         ignored ->
                                                                                
                 new HashSet<>())
                                                                                
 .add(reader))));
@@ -1762,19 +1966,31 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
 
     private static DynamicKafkaSourceEnumState createRestoredState(
             KafkaStream kafkaStream, String clusterId, 
List<DynamicKafkaSourceSplit> activeSplits) {
-        return new DynamicKafkaSourceEnumState(
-                Collections.singleton(kafkaStream),
-                Collections.singletonMap(
-                        clusterId,
-                        new KafkaSourceEnumState(
-                                unwrapSplits(activeSplits), 
Collections.emptyList(), true)));
+        return createRestoredState(kafkaStream, 
Collections.singletonMap(clusterId, activeSplits));
+    }
+
+    private static DynamicKafkaSourceEnumState createRestoredState(
+            KafkaStream kafkaStream, Map<String, 
List<DynamicKafkaSourceSplit>> splitsByCluster) {
+        Map<String, KafkaSourceEnumState> clusterStates = new HashMap<>();
+        splitsByCluster.forEach(
+                (clusterId, activeSplits) ->
+                        clusterStates.put(
+                                clusterId,
+                                new KafkaSourceEnumState(
+                                        unwrapSplits(activeSplits),
+                                        Collections.emptyList(),
+                                        true)));
+        return new 
DynamicKafkaSourceEnumState(Collections.singleton(kafkaStream), clusterStates);
     }
 
     private static Properties createGlobalModeProperties() {
-        Properties properties = createBaseProperties();
-        properties.setProperty(
-                DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE.key(),
+        return createModeProperties(
                 
DynamicKafkaSourceOptions.EnumeratorMode.GLOBAL.name().toLowerCase());
+    }
+
+    private static Properties createModeProperties(String mode) {
+        Properties properties = createBaseProperties();
+        
properties.setProperty(DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE.key(), 
mode);
         return properties;
     }
 
@@ -1792,14 +2008,25 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
             SplitEnumeratorContext<DynamicKafkaSourceSplit> context,
             Properties properties,
             DynamicKafkaSourceEnumState restoredState) {
+        return createEnumerator(
+                streamId, metadataService, context, properties, restoredState, 
false);
+    }
+
+    private static DynamicKafkaSourceEnumerator createEnumerator(
+            String streamId,
+            KafkaMetadataService metadataService,
+            SplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+            Properties properties,
+            DynamicKafkaSourceEnumState restoredState,
+            boolean bounded) {
         return new DynamicKafkaSourceEnumerator(
                 new KafkaStreamSetSubscriber(Collections.singleton(streamId)),
                 metadataService,
                 context,
                 OffsetsInitializer.earliest(),
-                new NoStoppingOffsetsInitializer(),
+                bounded ? OffsetsInitializer.latest() : new 
NoStoppingOffsetsInitializer(),
                 properties,
-                Boundedness.CONTINUOUS_UNBOUNDED,
+                bounded ? Boundedness.BOUNDED : 
Boundedness.CONTINUOUS_UNBOUNDED,
                 restoredState,
                 new NoOpKafkaEnumContextProxyFactory());
     }
@@ -1809,6 +2036,21 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
         return createKafkaStream(streamId, clusterId, 
Collections.singleton(activeTopic));
     }
 
+    private static KafkaStream createKafkaStream(
+            String streamId, Map<String, String> activeTopicByCluster) {
+        Properties clusterProperties = new Properties();
+        clusterProperties.setProperty(
+                CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, 
"localhost:9092");
+        Map<String, ClusterMetadata> clusterMetadata = new HashMap<>();
+        activeTopicByCluster.forEach(
+                (clusterId, topic) ->
+                        clusterMetadata.put(
+                                clusterId,
+                                new ClusterMetadata(
+                                        Collections.singleton(topic), 
clusterProperties)));
+        return new KafkaStream(streamId, clusterMetadata);
+    }
+
     private static KafkaStream createKafkaStream(
             String streamId, String clusterId, Set<String> activeTopics) {
         Properties clusterProperties = new Properties();
@@ -1820,6 +2062,14 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
                         clusterId, new ClusterMetadata(activeTopics, 
clusterProperties)));
     }
 
+    private static DynamicKafkaSourceSplit createSplit(
+            String clusterId, String topic, long startingOffset, long 
stoppingOffset) {
+        return new DynamicKafkaSourceSplit(
+                clusterId,
+                new KafkaPartitionSplit(
+                        new TopicPartition(topic, 0), startingOffset, 
stoppingOffset));
+    }
+
     private static List<DynamicKafkaSourceSplit> createSplits(
             String clusterId, String topic, int count) {
         List<DynamicKafkaSourceSplit> splits = new ArrayList<>();
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
index 4b2cfa51..fce2f6d7 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
@@ -200,6 +200,29 @@ class ReaderRecoveryGateTest {
         assertThat(gate.drainReportedSplits(1)).isEmpty();
     }
 
+    @Test
+    void testMergeKeepsDistinctPartitionsWithCollidingSplitIds() {
+        // Cluster "a" topic "b-c" and cluster "a-b" topic "c" both format to 
split id "a-b-c-0".
+        DynamicKafkaSourceSplit firstSplit =
+                new DynamicKafkaSourceSplit(
+                        "a", new KafkaPartitionSplit(new TopicPartition("b-c", 
0), 123L));
+        DynamicKafkaSourceSplit secondSplit =
+                new DynamicKafkaSourceSplit(
+                        "a-b", new KafkaPartitionSplit(new TopicPartition("c", 
0), 456L));
+        DynamicKafkaSourceSplit returnedSplit =
+                new DynamicKafkaSourceSplit(
+                        "a-b", new KafkaPartitionSplit(new TopicPartition("c", 
0), 789L));
+        assertThat(firstSplit.splitId()).isEqualTo(secondSplit.splitId());
+
+        ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
+        gate.recordReportedSplits(0, Collections.singletonList(firstSplit));
+        gate.recordReportedSplits(0, Collections.singletonList(secondSplit));
+        
assertThat(gate.snapshotReportedSplits().get(0)).containsExactly(firstSplit, 
secondSplit);
+
+        gate.recordReturnedSplits(0, Collections.singletonList(returnedSplit));
+        
assertThat(gate.snapshotReportedSplits().get(0)).containsExactly(firstSplit, 
returnedSplit);
+    }
+
     @Test
     void testDrainReportedSplitsReturnsReaderOrderAndClears() {
         ReaderRecoveryGate gate = new ReaderRecoveryGate(true);

Reply via email to