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 9c69ad68 [FLINK-39712][Connectors/Kafka] Avoid duplicate 
bounded-source completion notifications
9c69ad68 is described below

commit 9c69ad689515705361e2a7a3e4d7dd2cccfaf412
Author: Aleksandr Savonin <[email protected]>
AuthorDate: Mon Sep 7 12:06:19 2026 +0200

    [FLINK-39712][Connectors/Kafka] Avoid duplicate bounded-source completion 
notifications
    
    A bounded DynamicKafkaSource can signal readers again after they have
    finished when another reader registers or returns splits. An undeliverable
    NoMoreSplitsEvent then fails the job. Record which readers have received
    completion and re-arm the signal when a reader registers again.
    
    Deduplication must be paired with assignment deferral: a cluster callback
    can report completion before the remaining clusters assign their splits.
    Defer completion across returned-split assignment, reader registration and
    reported-split recovery, then recheck completion after each operation.
    Keep the guard active through the recovery-gate drain because draining
    clears its pending state before active and retained split assignment ends.
    Centralize that synchronous guard in a private Runnable helper that clears
    the flag in finally while preserving each completion callback's ordering.
    
    Wire the real completion callback into the enumerator test context and add
    five regressions covering startup, late registration, returned splits,
    reported splits, and active plus retained reassignment. Assert both signal
    counts and the exact split set present at completion.
    
    Generated-by: OpenAI Codex (GPT-6)
---
 .../enumerator/DynamicKafkaSourceEnumerator.java   |  51 +++--
 .../DynamicKafkaSourceEnumeratorTest.java          | 230 ++++++++++++++++++++-
 2 files changed, 266 insertions(+), 15 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 6cc98d91..60a2fe9b 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
@@ -115,6 +115,8 @@ public class DynamicKafkaSourceEnumerator
             retainedClusterEnumeratorStates;
     private boolean firstDiscoveryComplete;
     private final ReaderRecoveryGate readerRecoveryGate;
+    private final Set<Integer> readersWithNoMoreSplits = new HashSet<>();
+    private boolean splitAssignmentInProgress;
 
     public DynamicKafkaSourceEnumerator(
             KafkaStreamSubscriber kafkaStreamSubscriber,
@@ -410,6 +412,10 @@ public class DynamicKafkaSourceEnumerator
     }
 
     private void handleNoMoreSplits() {
+        // A cluster callback may run before other clusters have assigned 
their splits.
+        if (splitAssignmentInProgress || 
readerRecoveryGate.hasPendingRecovery()) {
+            return;
+        }
         if (Boundedness.BOUNDED.equals(boundedness)) {
             boolean allEnumeratorsHaveSignalledNoMoreSplits = true;
             for (StoppableKafkaEnumContextProxy context : 
clusterEnumContextMap.values()) {
@@ -418,10 +424,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.");
             }
@@ -751,8 +759,11 @@ public class DynamicKafkaSourceEnumerator
     @Override
     public void addSplitsBack(List<DynamicKafkaSourceSplit> splits, int 
subtaskId) {
         logger.debug("Adding splits back for {}", subtaskId);
-        splitAssignmentStrategy.onSplitsBack(splits, subtaskId);
-        addSplitsBackToClusterEnumerators(splits, subtaskId, false);
+        runWithSplitAssignmentInProgress(
+                () -> {
+                    splitAssignmentStrategy.onSplitsBack(splits, subtaskId);
+                    addSplitsBackToClusterEnumerators(splits, subtaskId, 
false);
+                });
         handleNoMoreSplits();
     }
 
@@ -791,6 +802,7 @@ public class DynamicKafkaSourceEnumerator
     @Override
     public void addReader(int subtaskId) {
         logger.debug("Adding reader {}", subtaskId);
+        readersWithNoMoreSplits.remove(subtaskId);
         ReaderInfo readerInfo = enumContext.registeredReaders().get(subtaskId);
         if (readerInfo != null) {
             readerRecoveryGate.recordReportedSplits(
@@ -801,7 +813,7 @@ public class DynamicKafkaSourceEnumerator
             return;
         }
 
-        addReaderToClusterEnumerators(subtaskId);
+        runWithSplitAssignmentInProgress(() -> 
addReaderToClusterEnumerators(subtaskId));
         handleNoMoreSplits();
     }
 
@@ -813,17 +825,30 @@ public class DynamicKafkaSourceEnumerator
             return true;
         }
 
-        readerRecoveryGate.markInitialRegistrationComplete();
-        if (readerRecoveryGate.hasReportedSplits()) {
-            reassignReportedSplits();
-        } else {
-            flushPendingSplitAssignmentsForRegisteredReaders();
-        }
+        // Draining the gate clears its pending state before reassignment 
finishes.
+        runWithSplitAssignmentInProgress(
+                () -> {
+                    readerRecoveryGate.markInitialRegistrationComplete();
+                    if (readerRecoveryGate.hasReportedSplits()) {
+                        reassignReportedSplits();
+                    } else {
+                        flushPendingSplitAssignmentsForRegisteredReaders();
+                    }
+                });
         handleNoMoreSplits();
         flushPendingMetadataUpdateEvents();
         return true;
     }
 
+    private void runWithSplitAssignmentInProgress(Runnable assignment) {
+        splitAssignmentInProgress = true;
+        try {
+            assignment.run();
+        } finally {
+            splitAssignmentInProgress = false;
+        }
+    }
+
     private boolean allReadersRegistered() {
         return enumContext.registeredReaders().size() == 
enumContext.currentParallelism();
     }
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 796819c4..389beb68 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
@@ -127,6 +127,158 @@ 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 
testBoundedSourceReassignsReportedSplitsBeforeCompletingReader() 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> reportedSplits = 
context.getAssignedSplits(0);
+            context.getSplitsAssignmentSequence().clear();
+            context.unregisterReader(0);
+            context.registerReader(ReaderInfo.createReaderInfo(0, "restarted", 
reportedSplits));
+            enumerator.addReader(0);
+
+            assertThat(context.getAssignedSplits(0))
+                    .containsExactlyInAnyOrderElementsOf(reportedSplits);
+            context.assertReaderCompleted(0, 2);
+            for (int reader = 1; reader < NUM_SUBTASKS; reader++) {
+                assertThat(context.splitsAtCompletion.get(reader)).hasSize(1);
+            }
+        }
+    }
+
+    @Test
+    public void 
testBoundedSourceCompletesReadersAfterActiveAndRetainedReassignment()
+            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);
+
+            Map<Integer, List<DynamicKafkaSourceSplit>> reportedSplitsByReader 
= new HashMap<>();
+            for (int reader = 0; reader < 2; reader++) {
+                reportedSplitsByReader.put(reader, 
context.getAssignedSplits(reader));
+                context.unregisterReader(reader);
+            }
+            reportedSplitsByReader
+                    .get(0)
+                    .add(
+                            new DynamicKafkaSourceSplit(
+                                    "removed-cluster",
+                                    new KafkaPartitionSplit(
+                                            new 
TopicPartition("removed-topic", 0), 5),
+                                    Long.MAX_VALUE));
+            context.getSplitsAssignmentSequence().clear();
+            context.splitsAtCompletion.clear();
+
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(0, "restarted", 
reportedSplitsByReader.get(0)));
+            enumerator.addReader(0);
+            assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+            assertThat(context.splitsAtCompletion).isEmpty();
+
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(1, "restarted", 
reportedSplitsByReader.get(1)));
+            enumerator.addReader(1);
+
+            assertThat(context.splitsAtCompletion).containsOnlyKeys(0, 1);
+            for (int reader = 0; reader < 2; reader++) {
+                List<DynamicKafkaSourceSplit> reportedSplits = 
reportedSplitsByReader.get(reader);
+                assertThat(context.getAssignedSplits(reader))
+                        .containsExactlyInAnyOrderElementsOf(reportedSplits);
+                assertThat(context.splitsAtCompletion.get(reader))
+                        .singleElement()
+                        .isEqualTo(
+                                reportedSplits.stream()
+                                        .map(DynamicKafkaSourceSplit::splitId)
+                                        .collect(Collectors.toSet()));
+            }
+        }
+    }
+
     @Test
     public void testStartupWithContinuousDiscovery() throws Throwable {
         try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
@@ -1999,6 +2151,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(
@@ -2476,7 +2647,8 @@ public class DynamicKafkaSourceEnumeratorTest {
             return new TestKafkaEnumContextProxy(
                     kafkaClusterId,
                     kafkaMetadataService,
-                    (MockSplitEnumeratorContext<DynamicKafkaSourceSplit>) 
enumContext);
+                    (MockSplitEnumeratorContext<DynamicKafkaSourceSplit>) 
enumContext,
+                    signalNoMoreSplitsCallback);
         }
     }
 
@@ -2488,7 +2660,15 @@ public class DynamicKafkaSourceEnumeratorTest {
                 String kafkaClusterId,
                 KafkaMetadataService kafkaMetadataService,
                 MockSplitEnumeratorContext<DynamicKafkaSourceSplit> 
enumContext) {
-            super(kafkaClusterId, kafkaMetadataService, enumContext, null);
+            this(kafkaClusterId, kafkaMetadataService, enumContext, null);
+        }
+
+        private TestKafkaEnumContextProxy(
+                String kafkaClusterId,
+                KafkaMetadataService kafkaMetadataService,
+                MockSplitEnumeratorContext<DynamicKafkaSourceSplit> 
enumContext,
+                Runnable signalNoMoreSplitsCallback) {
+            super(kafkaClusterId, kafkaMetadataService, enumContext, 
signalNoMoreSplitsCallback);
             this.enumContext = enumContext;
         }
 
@@ -2605,6 +2785,52 @@ 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 DroppingCoordinatorThreadContext
             extends MockSplitEnumeratorContext<DynamicKafkaSourceSplit> {
         private DroppingCoordinatorThreadContext(int parallelism) {

Reply via email to