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

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


The following commit(s) were added to refs/heads/v3.4 by this push:
     new aa7c8ef1 [FLINK-39712][Connectors/Kafka] Avoid duplicate 
bounded-source completion notifications
aa7c8ef1 is described below

commit aa7c8ef1388e6cd45706062001f377460561b28d
Author: Aleksandr Savonin <[email protected]>
AuthorDate: Mon Sep 21 19:12:41 2026 +0200

    [FLINK-39712][Connectors/Kafka] Avoid duplicate bounded-source completion 
notifications
    
    Backport the bounded-source completion fix to v3.4 from upstream commit
    9c69ad689515705361e2a7a3e4d7dd2cccfaf412.
    
    Send completion once per reader registration and defer it until all
    clusters finish assignments for reader registration or returned splits.
    Re-arm completion when a reader registers again. Adapt the guard to this
    release's assignment paths; it has no reported-split recovery gate.
    
    Wire the real completion callback into the test factory and add three
    regressions for initial completion, late readers and returned splits.
    Assert the exact assigned split set at completion as well as signal counts.
    
    Generated-by: OpenAI Codex (GPT-6)
---
 .../enumerator/DynamicKafkaSourceEnumerator.java   |  60 ++++++---
 .../DynamicKafkaSourceEnumeratorTest.java          | 147 ++++++++++++++++++++-
 2 files changed, 186 insertions(+), 21 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 b61cee40..93a95451 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
@@ -98,6 +98,8 @@ public class DynamicKafkaSourceEnumerator
     private Map<String, Set<String>> latestClusterTopicsMap;
     private Set<KafkaStream> latestKafkaStreams;
     private boolean firstDiscoveryComplete;
+    private final Set<Integer> readersWithNoMoreSplits = new HashSet<>();
+    private boolean splitAssignmentInProgress;
 
     public DynamicKafkaSourceEnumerator(
             KafkaStreamSubscriber kafkaStreamSubscriber,
@@ -214,6 +216,10 @@ public class DynamicKafkaSourceEnumerator
     }
 
     private void handleNoMoreSplits() {
+        // A cluster callback may run before other clusters have assigned 
their splits.
+        if (splitAssignmentInProgress) {
+            return;
+        }
         if (Boundedness.BOUNDED.equals(boundedness)) {
             boolean allEnumeratorsHaveSignalledNoMoreSplits = true;
             for (StoppableKafkaEnumContextProxy context : 
clusterEnumContextMap.values()) {
@@ -222,10 +228,12 @@ public class DynamicKafkaSourceEnumerator
             }
 
             if (firstDiscoveryComplete && 
allEnumeratorsHaveSignalledNoMoreSplits) {
-                logger.info(
-                        "Signal no more splits to all readers: {}",
-                        enumContext.registeredReaders().keySet());
-                
enumContext.registeredReaders().keySet().forEach(enumContext::signalNoMoreSplits);
+                for (int readerId : enumContext.registeredReaders().keySet()) {
+                    if (readersWithNoMoreSplits.add(readerId)) {
+                        logger.info("Signal no more splits to reader {}", 
readerId);
+                        enumContext.signalNoMoreSplits(readerId);
+                    }
+                }
             } else {
                 logger.info("Not ready to notify no more splits to readers.");
             }
@@ -463,18 +471,22 @@ public class DynamicKafkaSourceEnumerator
         }
 
         // add splits back and assign pending splits for all enumerators
-        for (String kafkaClusterId : kafkaPartitionSplits.keySet()) {
-            if (clusterEnumeratorMap.containsKey(kafkaClusterId)) {
-                clusterEnumeratorMap
-                        .get(kafkaClusterId)
-                        
.addSplitsBack(kafkaPartitionSplits.get(kafkaClusterId), subtaskId);
-            } else {
-                logger.warn(
-                        "Split refers to inactive cluster {} with current 
clusters being {}",
-                        kafkaClusterId,
-                        clusterEnumeratorMap.keySet());
-            }
-        }
+        runWithSplitAssignmentInProgress(
+                () -> {
+                    for (String kafkaClusterId : 
kafkaPartitionSplits.keySet()) {
+                        if (clusterEnumeratorMap.containsKey(kafkaClusterId)) {
+                            clusterEnumeratorMap
+                                    .get(kafkaClusterId)
+                                    .addSplitsBack(
+                                            
kafkaPartitionSplits.get(kafkaClusterId), subtaskId);
+                        } else {
+                            logger.warn(
+                                    "Split refers to inactive cluster {} with 
current clusters being {}",
+                                    kafkaClusterId,
+                                    clusterEnumeratorMap.keySet());
+                        }
+                    }
+                });
 
         handleNoMoreSplits();
     }
@@ -483,12 +495,24 @@ public class DynamicKafkaSourceEnumerator
     @Override
     public void addReader(int subtaskId) {
         logger.debug("Adding reader {}", subtaskId);
+        readersWithNoMoreSplits.remove(subtaskId);
         // assign pending splits from the sub enumerator
-        clusterEnumeratorMap.forEach(
-                (cluster, subEnumerator) -> 
subEnumerator.addReader(subtaskId));
+        runWithSplitAssignmentInProgress(
+                () ->
+                        clusterEnumeratorMap.forEach(
+                                (cluster, subEnumerator) -> 
subEnumerator.addReader(subtaskId)));
         handleNoMoreSplits();
     }
 
+    private void runWithSplitAssignmentInProgress(Runnable assignment) {
+        splitAssignmentInProgress = true;
+        try {
+            assignment.run();
+        } finally {
+            splitAssignmentInProgress = false;
+        }
+    }
+
     /**
      * Besides for checkpointing, this method is used in the restart sequence 
to retain the relevant
      * assigned splits so that there is no reader duplicate split assignment. 
See {@link
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorTest.java
index 86133345..fe2c64f0 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorTest.java
@@ -48,6 +48,7 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -101,6 +102,81 @@ public class DynamicKafkaSourceEnumeratorTest {
         }
     }
 
+    @Test
+    public void testBoundedSourceSignalsNoMoreSplitsOncePerReader() throws 
Throwable {
+        try (RecordingSplitEnumeratorContext context = new 
RecordingSplitEnumeratorContext();
+                DynamicKafkaSourceEnumerator enumerator = 
createBoundedEnumerator(context)) {
+            enumerator.start();
+            for (int reader = 0; reader < NUM_SUBTASKS; reader++) {
+                mockRegisterReaderAndSendReaderStartupEvent(context, 
enumerator, reader);
+            }
+
+            assertThat(context.splitsAtCompletion).isEmpty();
+            runAllOneTimeCallables(context);
+
+            for (int reader = 0; reader < NUM_SUBTASKS; reader++) {
+                context.assertReaderCompleted(reader, 1);
+            }
+            verifyAllSplitsHaveBeenAssigned(
+                    context.getSplitsAssignmentSequence(),
+                    DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC));
+        }
+    }
+
+    @Test
+    public void 
testBoundedSourceAssignsAllClustersBeforeCompletingLateReaders() throws 
Throwable {
+        try (RecordingSplitEnumeratorContext context = new 
RecordingSplitEnumeratorContext();
+                DynamicKafkaSourceEnumerator enumerator = 
createBoundedEnumerator(context)) {
+            enumerator.start();
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
0);
+            runAllOneTimeCallables(context);
+
+            context.assertReaderCompleted(0, 1);
+            assertThat(context.splitsAtCompletion).containsOnlyKeys(0);
+
+            for (int reader = 1; reader < NUM_SUBTASKS; reader++) {
+                mockRegisterReaderAndSendReaderStartupEvent(context, 
enumerator, reader);
+                for (int registeredReader = 0; registeredReader <= reader; 
registeredReader++) {
+                    context.assertReaderCompleted(registeredReader, 1);
+                }
+            }
+            verifyAllSplitsHaveBeenAssigned(
+                    context.getSplitsAssignmentSequence(),
+                    DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC));
+        }
+    }
+
+    @Test
+    public void testBoundedSourceCompletesRestartedReaderAfterReturnedSplits() 
throws Throwable {
+        try (RecordingSplitEnumeratorContext context = new 
RecordingSplitEnumeratorContext();
+                DynamicKafkaSourceEnumerator enumerator = 
createBoundedEnumerator(context)) {
+            enumerator.start();
+            for (int reader = 0; reader < NUM_SUBTASKS; reader++) {
+                mockRegisterReaderAndSendReaderStartupEvent(context, 
enumerator, reader);
+            }
+            runAllOneTimeCallables(context);
+
+            List<DynamicKafkaSourceSplit> returnedSplits = 
context.getAssignedSplits(0);
+            context.getSplitsAssignmentSequence().clear();
+            context.unregisterReader(0);
+            enumerator.addSplitsBack(returnedSplits, 0);
+
+            assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+            context.splitsAtCompletion
+                    .values()
+                    .forEach(completions -> 
assertThat(completions).hasSize(1));
+
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
0);
+
+            assertThat(context.getAssignedSplits(0))
+                    .containsExactlyInAnyOrderElementsOf(returnedSplits);
+            context.assertReaderCompleted(0, 2);
+            for (int reader = 1; reader < NUM_SUBTASKS; reader++) {
+                assertThat(context.splitsAtCompletion.get(reader)).hasSize(1);
+            }
+        }
+    }
+
     @Test
     public void testStartupWithContinuousDiscovery() throws Throwable {
         try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
@@ -820,6 +896,25 @@ public class DynamicKafkaSourceEnumeratorTest {
         }
     }
 
+    private DynamicKafkaSourceEnumerator createBoundedEnumerator(
+            SplitEnumeratorContext<DynamicKafkaSourceSplit> context) {
+        Properties properties = new Properties();
+        
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
 "0");
+        properties.setProperty(
+                
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "0");
+        return new DynamicKafkaSourceEnumerator(
+                new KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+                new MockKafkaMetadataService(
+                        
Collections.singleton(DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC))),
+                context,
+                OffsetsInitializer.earliest(),
+                OffsetsInitializer.latest(),
+                properties,
+                Boundedness.BOUNDED,
+                new DynamicKafkaSourceEnumState(),
+                new TestKafkaEnumContextProxyFactory());
+    }
+
     private DynamicKafkaSourceEnumerator createEnumerator(
             SplitEnumeratorContext<DynamicKafkaSourceSplit> context) {
         return createEnumerator(
@@ -1016,6 +1111,50 @@ public class DynamicKafkaSourceEnumeratorTest {
         }
     }
 
+    private static class RecordingSplitEnumeratorContext
+            extends MockSplitEnumeratorContext<DynamicKafkaSourceSplit> {
+        private final Map<Integer, List<Set<String>>> splitsAtCompletion = new 
HashMap<>();
+
+        private RecordingSplitEnumeratorContext() {
+            super(NUM_SUBTASKS);
+        }
+
+        @Override
+        public void signalNoMoreSplits(int subtask) {
+            super.signalNoMoreSplits(subtask);
+            splitsAtCompletion
+                    .computeIfAbsent(subtask, ignored -> new ArrayList<>())
+                    .add(
+                            getAssignedSplits(subtask).stream()
+                                    .map(DynamicKafkaSourceSplit::splitId)
+                                    .collect(Collectors.toSet()));
+        }
+
+        private List<DynamicKafkaSourceSplit> getAssignedSplits(int reader) {
+            return getSplitsAssignmentSequence().stream()
+                    .flatMap(
+                            assignment ->
+                                    assignment.assignment()
+                                            .getOrDefault(reader, 
Collections.emptyList()).stream())
+                    .collect(Collectors.toList());
+        }
+
+        private void assertReaderCompleted(int reader, int times) {
+            List<DynamicKafkaSourceSplit> assignedSplits = 
getAssignedSplits(reader);
+            
assertThat(assignedSplits).hasSize(DynamicKafkaSourceTestHelper.NUM_KAFKA_CLUSTERS);
+            Set<String> assignedSplitIds =
+                    assignedSplits.stream()
+                            .map(DynamicKafkaSourceSplit::splitId)
+                            .collect(Collectors.toSet());
+            assertThat(splitsAtCompletion.get(reader))
+                    .hasSize(times)
+                    .allSatisfy(
+                            splitIds ->
+                                    assertThat(splitIds)
+                                            
.containsExactlyInAnyOrderElementsOf(assignedSplitIds));
+        }
+    }
+
     private static class TestKafkaEnumContextProxyFactory
             implements 
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory {
 
@@ -1028,7 +1167,8 @@ public class DynamicKafkaSourceEnumeratorTest {
             return new TestKafkaEnumContextProxy(
                     kafkaClusterId,
                     kafkaMetadataService,
-                    (MockSplitEnumeratorContext<DynamicKafkaSourceSplit>) 
enumContext);
+                    (MockSplitEnumeratorContext<DynamicKafkaSourceSplit>) 
enumContext,
+                    signalNoMoreSplitsCallback);
         }
     }
 
@@ -1039,8 +1179,9 @@ public class DynamicKafkaSourceEnumeratorTest {
         public TestKafkaEnumContextProxy(
                 String kafkaClusterId,
                 KafkaMetadataService kafkaMetadataService,
-                MockSplitEnumeratorContext<DynamicKafkaSourceSplit> 
enumContext) {
-            super(kafkaClusterId, kafkaMetadataService, enumContext, null);
+                MockSplitEnumeratorContext<DynamicKafkaSourceSplit> 
enumContext,
+                Runnable signalNoMoreSplitsCallback) {
+            super(kafkaClusterId, kafkaMetadataService, enumContext, 
signalNoMoreSplitsCallback);
             this.enumContext = enumContext;
         }
 

Reply via email to