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

bowenli86 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 904e8821 [FLINK-39979] DynamicKafkaSource should mark reader idle when 
metadata removal leaves a subtask with no active subreaders (#276)
904e8821 is described below

commit 904e88213ac4988087499c5621fe5c4c2a6ca4f9
Author: bowenli86 <[email protected]>
AuthorDate: Fri Jun 26 09:08:49 2026 -0700

    [FLINK-39979] DynamicKafkaSource should mark reader idle when metadata 
removal leaves a subtask with no active subreaders (#276)
    
    DynamicKafkaSource can remain active after a metadata update removes the 
last active Kafka cluster/split from a reader subtask. Since no sub-reader 
remains to report idleness, downstream watermark progress can stall.
    
    When the reader is already waiting on isAvailable(), resetting availability 
to zero sub-readers also leaves the cached availability future incomplete, so 
pollNext() is not invoked to release pending split outputs or mark the output 
idle.
---
 .../source/reader/DynamicKafkaSourceReader.java    |  26 ++++
 .../DynamicKafkaSourceReaderIdlenessTest.java      | 158 +++++++++++++++++++++
 .../reader/DynamicKafkaSourceReaderTest.java       |   8 +-
 3 files changed, 191 insertions(+), 1 deletion(-)

diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
index e2a3a572..3d4dd040 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
@@ -106,6 +106,7 @@ public class DynamicKafkaSourceReader<T> implements 
SourceReader<T, DynamicKafka
     private int availabilityHelperSize;
     private boolean isActivelyConsumingSplits;
     private boolean isNoMoreSplits;
+    private boolean dynamicOutputIdle;
     private AtomicBoolean restartingReaders;
     private ReaderOutput<T> latestReaderOutput;
 
@@ -132,6 +133,7 @@ public class DynamicKafkaSourceReader<T> implements 
SourceReader<T, DynamicKafka
                 new 
MultipleFuturesAvailabilityHelper(this.availabilityHelperSize = 0);
         this.isNoMoreSplits = false;
         this.isActivelyConsumingSplits = false;
+        this.dynamicOutputIdle = false;
         this.restartingReaders = new AtomicBoolean();
         this.clustersProperties = new HashMap<>();
         this.pendingSplitOutputReleases = new HashSet<>();
@@ -157,6 +159,7 @@ public class DynamicKafkaSourceReader<T> implements 
SourceReader<T, DynamicKafka
     public InputStatus pollNext(ReaderOutput<T> readerOutput) throws Exception 
{
         latestReaderOutput = readerOutput;
         releasePendingSplitOutputs(readerOutput);
+        maybeUpdateNoActiveSplitOutputIdleness(readerOutput);
 
         // at startup, do not return end of input if metadata event has not 
been received
         if (clusterReaderMap.isEmpty()) {
@@ -605,6 +608,12 @@ public class DynamicKafkaSourceReader<T> implements 
SourceReader<T, DynamicKafka
                         this.availabilityHelperSize = clusterReaderMap.size());
         syncAvailabilityHelperWithReaders();
 
+        if (getNumberOfActiveSplits() == 0) {
+            restartingReaders.set(false);
+            cachedPreviousFuture.complete(null);
+            return;
+        }
+
         // We cannot immediately complete the previous future here. We must 
complete it only when
         // the new readers have finished handling the split assignment. 
Completing the future too
         // early can cause WakeupException (implicitly woken up by invocation 
to pollNext()) if the
@@ -709,6 +718,23 @@ public class DynamicKafkaSourceReader<T> implements 
SourceReader<T, DynamicKafka
         pendingSplits.removeIf(split -> split.isRetained() && 
!split.isRetained(currentTimeMillis));
     }
 
+    private int getNumberOfActiveSplits() {
+        return clusterReaderMap.values().stream()
+                
.mapToInt(KafkaSourceReader::getNumberOfCurrentlyAssignedSplits)
+                .sum();
+    }
+
+    private void maybeUpdateNoActiveSplitOutputIdleness(ReaderOutput<T> 
readerOutput) {
+        boolean hasActiveSplits = getNumberOfActiveSplits() > 0;
+        if (!hasActiveSplits && isActivelyConsumingSplits && 
!dynamicOutputIdle) {
+            readerOutput.markIdle();
+            dynamicOutputIdle = true;
+        } else if (hasActiveSplits && dynamicOutputIdle) {
+            readerOutput.markActive();
+            dynamicOutputIdle = false;
+        }
+    }
+
     static Configuration toConfiguration(Properties props) {
         Configuration config = new Configuration();
         props.stringPropertyNames().forEach(key -> config.setString(key, 
props.getProperty(key)));
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderIdlenessTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderIdlenessTest.java
new file mode 100644
index 00000000..0a34194f
--- /dev/null
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderIdlenessTest.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.flink.connector.kafka.dynamic.source.reader;
+
+import org.apache.flink.api.common.eventtime.Watermark;
+import org.apache.flink.connector.kafka.dynamic.metadata.ClusterMetadata;
+import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+import org.apache.flink.connector.kafka.dynamic.source.MetadataUpdateEvent;
+import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
+import 
org.apache.flink.connector.kafka.source.reader.deserializer.KafkaRecordDeserializationSchema;
+import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
+import org.apache.flink.connector.testutils.source.reader.TestingReaderContext;
+import org.apache.flink.connector.testutils.source.reader.TestingReaderOutput;
+import org.apache.flink.core.io.InputStatus;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests idleness transitions for {@link DynamicKafkaSourceReader}. */
+class DynamicKafkaSourceReaderIdlenessTest {
+    private static final String TOPIC = "topic";
+    private static final String REMAINING_TOPIC = "remaining-topic";
+    private static final String KAFKA_CLUSTER_ID = "cluster-1";
+
+    @Test
+    void testMetadataRemovalMarksReaderIdleWhenAllActiveSplitsRemoved() throws 
Exception {
+        TestingReaderContext context = new TestingReaderContext();
+        try (DynamicKafkaSourceReader<byte[]> reader =
+                new DynamicKafkaSourceReader<>(
+                        context,
+                        
KafkaRecordDeserializationSchema.valueOnly(ByteArrayDeserializer.class),
+                        getRequiredProperties())) {
+            reader.start();
+            reader.handleSourceEvents(getMetadataUpdateEvent());
+
+            DynamicKafkaSourceSplit split =
+                    new DynamicKafkaSourceSplit(
+                            KAFKA_CLUSTER_ID,
+                            new KafkaPartitionSplit(
+                                    new TopicPartition(TOPIC, 0),
+                                    0L,
+                                    KafkaPartitionSplit.NO_STOPPING_OFFSET));
+            reader.addSplits(Collections.singletonList(split));
+
+            CompletableFuture<Void> availabilityFuture = reader.isAvailable();
+            assertThat(availabilityFuture).isNotDone();
+
+            reader.handleSourceEvents(getMetadataUpdateEvent(REMAINING_TOPIC));
+            assertThat(reader.getAvailabilityHelperSize()).isEqualTo(1);
+            assertThat(availabilityFuture).isDone();
+
+            TrackingReaderOutputWithIdleness<byte[]> readerOutput =
+                    new TrackingReaderOutputWithIdleness<>();
+            
assertThat(reader.pollNext(readerOutput)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+            
assertThat(reader.pollNext(readerOutput)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+            
assertThat(readerOutput.releasedSplitIds()).containsExactly(split.splitId());
+            assertThat(readerOutput.idleCount()).isEqualTo(1);
+            assertThat(readerOutput.activeCount()).isZero();
+            assertThat(readerOutput.isIdle()).isTrue();
+        }
+    }
+
+    private static MetadataUpdateEvent getMetadataUpdateEvent() {
+        return getMetadataUpdateEvent(TOPIC);
+    }
+
+    private static MetadataUpdateEvent getMetadataUpdateEvent(String topic) {
+        Properties clusterProperties = new Properties();
+        clusterProperties.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
"localhost:9092");
+        ClusterMetadata clusterMetadata =
+                new ClusterMetadata(Collections.singleton(topic), 
clusterProperties);
+        return new MetadataUpdateEvent(
+                Collections.singleton(
+                        new KafkaStream(
+                                TOPIC,
+                                Collections.singletonMap(KAFKA_CLUSTER_ID, 
clusterMetadata))));
+    }
+
+    private static Properties getRequiredProperties() {
+        Properties properties = new Properties();
+        properties.setProperty(
+                ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
+                ByteArrayDeserializer.class.getName());
+        properties.setProperty(
+                ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
+                ByteArrayDeserializer.class.getName());
+        return properties;
+    }
+
+    private static class TrackingReaderOutputWithIdleness<E> extends 
TestingReaderOutput<E> {
+        private int idleCount;
+        private int activeCount;
+        private boolean idle;
+        private final List<String> releasedSplitIds = new ArrayList<>();
+
+        @Override
+        public void emitWatermark(Watermark watermark) {}
+
+        @Override
+        public void markIdle() {
+            idleCount++;
+            idle = true;
+        }
+
+        @Override
+        public void markActive() {
+            activeCount++;
+            idle = false;
+        }
+
+        @Override
+        public void releaseOutputForSplit(String splitId) {
+            releasedSplitIds.add(splitId);
+        }
+
+        private int idleCount() {
+            return idleCount;
+        }
+
+        private int activeCount() {
+            return activeCount;
+        }
+
+        private boolean isIdle() {
+            return idle;
+        }
+
+        private List<String> releasedSplitIds() {
+            return releasedSplitIds;
+        }
+    }
+}
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
index 180c3392..fea90b72 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
@@ -86,6 +86,12 @@ public class DynamicKafkaSourceReaderTest extends 
SourceReaderTestBase<DynamicKa
     private static class TrackingReaderOutput<E> extends 
TestingReaderOutput<E> {
         private final List<String> releasedSplitIds = new ArrayList<>();
 
+        @Override
+        public void markIdle() {}
+
+        @Override
+        public void markActive() {}
+
         @Override
         public void releaseOutputForSplit(String splitId) {
             releasedSplitIds.add(splitId);
@@ -314,7 +320,7 @@ public class DynamicKafkaSourceReaderTest extends 
SourceReaderTestBase<DynamicKa
     void testNoSubReadersInputStatus() throws Exception {
         try (DynamicKafkaSourceReader<Integer> reader =
                 (DynamicKafkaSourceReader<Integer>) createReader()) {
-            TestingReaderOutput<Integer> readerOutput = new 
TestingReaderOutput<>();
+            TrackingReaderOutput<Integer> readerOutput = new 
TrackingReaderOutput<>();
             InputStatus inputStatus = reader.pollNext(readerOutput);
             assertEquals(
                     InputStatus.NOTHING_AVAILABLE,

Reply via email to