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 91e01a56 [FLINK-40137][Connectors/Kafka] Extract ReaderRecoveryGate
from DynamicKafkaSourceEnumerator
91e01a56 is described below
commit 91e01a56ef6f5ea216a24c854a5a218616613f0b
Author: Sylwester Lachiewicz <[email protected]>
AuthorDate: Mon Jul 13 20:44:35 2026 +0200
[FLINK-40137][Connectors/Kafka] Extract ReaderRecoveryGate from
DynamicKafkaSourceEnumerator
The enumerator accumulated three interacting pieces of recovery gating
state (initial registration pending, reported splits pending
redistribution, deferred metadata update readers). Extract them into a
dedicated, unit-tested ReaderRecoveryGate so the gating state and its
lifecycle have an explicit name and structure. No behavior change.
Generated-by: Claude Fable 5
---
.../enumerator/DynamicKafkaSourceEnumerator.java | 42 ++----
.../source/enumerator/ReaderRecoveryGate.java | 132 +++++++++++++++++
.../source/enumerator/ReaderRecoveryGateTest.java | 156 +++++++++++++++++++++
3 files changed, 301 insertions(+), 29 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 32bd3b51..ea38c9e6 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
@@ -114,9 +114,7 @@ public class DynamicKafkaSourceEnumerator
private Map<String, DynamicKafkaSourceEnumState.RetainedClusterState>
retainedClusterEnumeratorStates;
private boolean firstDiscoveryComplete;
- private boolean initialReaderRegistrationPending;
- private final Map<Integer, List<DynamicKafkaSourceSplit>>
pendingReportedSplitsByReader;
- private final Set<Integer> pendingMetadataUpdateReaders;
+ private final ReaderRecoveryGate readerRecoveryGate;
public DynamicKafkaSourceEnumerator(
KafkaStreamSubscriber kafkaStreamSubscriber,
@@ -218,10 +216,8 @@ public class DynamicKafkaSourceEnumerator
runnable,
"dynamic-kafka-enumerator-closing-worker"));
this.asynchronousEnumeratorCloseFailure = new AtomicReference<>();
this.splitAssignmentStrategy =
createSplitAssignmentStrategy(properties);
- this.initialReaderRegistrationPending =
- hasRestoredEnumeratorState(dynamicKafkaSourceEnumState);
- this.pendingReportedSplitsByReader = new HashMap<>();
- this.pendingMetadataUpdateReaders = new HashSet<>();
+ this.readerRecoveryGate =
+ new
ReaderRecoveryGate(hasRestoredEnumeratorState(dynamicKafkaSourceEnumState));
if
(!dynamicKafkaSourceEnumState.getClusterEnumeratorStates().isEmpty()) {
logger.info("Dynamic Kafka source restored from checkpointed
enumerator state");
@@ -559,7 +555,7 @@ public class DynamicKafkaSourceEnumerator
/** NOTE: Must run on coordinator thread. */
private void sendMetadataUpdateEventToAvailableReaders() {
if (shouldDeferMetadataUpdateEvents()) {
-
pendingMetadataUpdateReaders.addAll(enumContext.registeredReaders().keySet());
+
readerRecoveryGate.deferMetadataUpdates(enumContext.registeredReaders().keySet());
return;
}
@@ -769,11 +765,8 @@ public class DynamicKafkaSourceEnumerator
logger.debug("Adding reader {}", subtaskId);
ReaderInfo readerInfo = enumContext.registeredReaders().get(subtaskId);
if (readerInfo != null) {
- List<DynamicKafkaSourceSplit> reportedSplits =
- readerInfo.getReportedSplitsOnRegistration();
- if (!reportedSplits.isEmpty()) {
- pendingReportedSplitsByReader.put(subtaskId, new
ArrayList<>(reportedSplits));
- }
+ readerRecoveryGate.recordReportedSplits(
+ subtaskId, readerInfo.getReportedSplitsOnRegistration());
}
if (tryCompletePendingReaderRegistration()) {
@@ -785,19 +778,15 @@ public class DynamicKafkaSourceEnumerator
}
private boolean tryCompletePendingReaderRegistration() {
- boolean hasPendingRecovery =
- initialReaderRegistrationPending ||
!pendingReportedSplitsByReader.isEmpty();
- if (!hasPendingRecovery) {
+ if (!readerRecoveryGate.hasPendingRecovery()) {
return false;
}
if (!firstDiscoveryComplete || !allReadersRegistered()) {
return true;
}
- if (initialReaderRegistrationPending) {
- initialReaderRegistrationPending = false;
- }
- if (!pendingReportedSplitsByReader.isEmpty()) {
+ readerRecoveryGate.markInitialRegistrationComplete();
+ if (readerRecoveryGate.hasReportedSplits()) {
reassignReportedSplits();
} else {
flushPendingSplitAssignmentsForRegisteredReaders();
@@ -831,7 +820,7 @@ public class DynamicKafkaSourceEnumerator
long currentTimeMillis = System.currentTimeMillis();
for (Entry<Integer, List<DynamicKafkaSourceSplit>> readerSplits :
- new TreeMap<>(pendingReportedSplitsByReader).entrySet()) {
+ readerRecoveryGate.drainReportedSplits().entrySet()) {
int readerId = readerSplits.getKey();
for (DynamicKafkaSourceSplit split : readerSplits.getValue()) {
if (isSplitActive(split)) {
@@ -876,19 +865,14 @@ public class DynamicKafkaSourceEnumerator
if (!retainedSplitsByReader.isEmpty()) {
enumContext.assignSplits(new
SplitsAssignment<>(retainedSplitsByReader));
}
- pendingReportedSplitsByReader.clear();
}
private boolean shouldDeferMetadataUpdateEvents() {
- return initialReaderRegistrationPending
- || (!pendingReportedSplitsByReader.isEmpty() &&
!allReadersRegistered());
+ return
readerRecoveryGate.shouldDeferMetadataUpdateEvents(allReadersRegistered());
}
private void flushPendingMetadataUpdateEvents() {
- List<Integer> readers = new ArrayList<>(pendingMetadataUpdateReaders);
- Collections.sort(readers);
- pendingMetadataUpdateReaders.clear();
- for (int readerId : readers) {
+ for (int readerId :
readerRecoveryGate.drainDeferredMetadataUpdateReaders()) {
if (enumContext.registeredReaders().containsKey(readerId)) {
sendMetadataUpdateEvent(readerId);
}
@@ -1044,7 +1028,7 @@ public class DynamicKafkaSourceEnumerator
if (enumContext.registeredReaders().containsKey(subtaskId)) {
if (shouldDeferMetadataUpdateEvents()) {
- pendingMetadataUpdateReaders.add(subtaskId);
+ readerRecoveryGate.deferMetadataUpdate(subtaskId);
} else {
sendMetadataUpdateEvent(subtaskId);
}
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
new file mode 100644
index 00000000..5707a078
--- /dev/null
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
@@ -0,0 +1,132 @@
+/*
+ * 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.enumerator;
+
+import org.apache.flink.annotation.Internal;
+import
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * Tracks the recovery-time reader registration state of the {@link
DynamicKafkaSourceEnumerator}.
+ *
+ * <p>The gate is armed by two recovery triggers:
+ *
+ * <ul>
+ * <li><b>Enumerator restore from checkpoint</b>: armed at construction
({@code
+ * restoredFromCheckpoint = true}). Split assignment and metadata update
events must be
+ * deferred until the first metadata discovery has completed and every
reader has
+ * (re-)registered, so that restored reader splits can be redistributed
consistently.
+ * <li><b>Reader re-registration after partial failover</b>: armed on a
running enumerator when a
+ * re-registering reader reports checkpointed splits ({@link
#recordReportedSplits} with
+ * non-empty splits while initial reader registration is already
complete). Metadata update
+ * events are deferred until all readers have registered again and
reported splits are
+ * redistributed.
+ * </ul>
+ *
+ * <p>This class owns that gating state; the enumerator remains responsible
for acting on it.
+ *
+ * <p>This class is not thread-safe and must only be used from the coordinator
thread: the
+ * enumerator methods that touch it are called by the {@code
SourceCoordinator}, and metadata
+ * discovery results are handed back through {@code runInCoordinatorThread}.
+ */
+@Internal
+class ReaderRecoveryGate {
+
+ /** Set on restore; cleared once all readers have registered after the
first discovery. */
+ private boolean initialReaderRegistrationPending;
+
+ /** Splits reported by readers on registration, pending redistribution. */
+ private final Map<Integer, List<DynamicKafkaSourceSplit>>
pendingReportedSplitsByReader =
+ new HashMap<>();
+
+ /** Readers whose metadata update events were deferred during recovery. */
+ private final Set<Integer> pendingMetadataUpdateReaders = new HashSet<>();
+
+ ReaderRecoveryGate(boolean restoredFromCheckpoint) {
+ this.initialReaderRegistrationPending = restoredFromCheckpoint;
+ }
+
+ /** Records splits a reader reported on registration; an empty report is
ignored. */
+ void recordReportedSplits(int subtaskId, List<DynamicKafkaSourceSplit>
reportedSplits) {
+ if (!reportedSplits.isEmpty()) {
+ pendingReportedSplitsByReader.put(subtaskId, new
ArrayList<>(reportedSplits));
+ }
+ }
+
+ /** Whether recovery gating is active and registrations must be deferred.
*/
+ boolean hasPendingRecovery() {
+ return initialReaderRegistrationPending ||
!pendingReportedSplitsByReader.isEmpty();
+ }
+
+ /**
+ * Whether metadata update events must be deferred instead of sent, given
the current reader
+ * registration completeness.
+ */
+ boolean shouldDeferMetadataUpdateEvents(boolean allReadersRegistered) {
+ return initialReaderRegistrationPending
+ || (!pendingReportedSplitsByReader.isEmpty() &&
!allReadersRegistered);
+ }
+
+ void deferMetadataUpdate(int readerId) {
+ pendingMetadataUpdateReaders.add(readerId);
+ }
+
+ void deferMetadataUpdates(Collection<Integer> readerIds) {
+ pendingMetadataUpdateReaders.addAll(readerIds);
+ }
+
+ /** Returns the deferred metadata update readers in ascending order and
clears them. */
+ List<Integer> drainDeferredMetadataUpdateReaders() {
+ List<Integer> readers = new ArrayList<>(pendingMetadataUpdateReaders);
+ Collections.sort(readers);
+ pendingMetadataUpdateReaders.clear();
+ return readers;
+ }
+
+ void markInitialRegistrationComplete() {
+ initialReaderRegistrationPending = false;
+ }
+
+ boolean hasReportedSplits() {
+ return !pendingReportedSplitsByReader.isEmpty();
+ }
+
+ /**
+ * Returns the reported splits ordered by reader id and clears the pending
state.
+ *
+ * <p>Note: the pending state is cleared eagerly, so the gate must not be
consulted for pending
+ * reported splits while reassigning (e.g. from {@code
handleNoMoreSplits}).
+ */
+ NavigableMap<Integer, List<DynamicKafkaSourceSplit>> drainReportedSplits()
{
+ NavigableMap<Integer, List<DynamicKafkaSourceSplit>>
reportedSplitsByReader =
+ new TreeMap<>(pendingReportedSplitsByReader);
+ pendingReportedSplitsByReader.clear();
+ return reportedSplitsByReader;
+ }
+}
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
new file mode 100644
index 00000000..e6456ed4
--- /dev/null
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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.enumerator;
+
+import
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
+import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
+
+import org.apache.kafka.common.TopicPartition;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.NavigableMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link ReaderRecoveryGate}. */
+class ReaderRecoveryGateTest {
+
+ @Test
+ void testFreshStartHasNoPendingRecovery() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+
+ assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ assertThat(gate.hasReportedSplits()).isFalse();
+ }
+
+ @Test
+ void testRestoredStartGatesUntilInitialRegistrationCompletes() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
+
+ assertThat(gate.hasPendingRecovery()).isTrue();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isTrue();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isTrue();
+
+ gate.markInitialRegistrationComplete();
+
+ assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ }
+
+ @Test
+ void
testCompletingInitialRegistrationDoesNotReleaseGateWithPendingReportedSplits() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
+ gate.recordReportedSplits(1, Collections.singletonList(split("topic",
0)));
+
+ gate.markInitialRegistrationComplete();
+
+ assertThat(gate.hasPendingRecovery()).isTrue();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isTrue();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+
+ gate.drainReportedSplits();
+
+ assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ }
+
+ @Test
+ void testReportedSplitsGateUntilAllReadersRegistered() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ gate.recordReportedSplits(1, Collections.singletonList(split("topic",
0)));
+
+ assertThat(gate.hasPendingRecovery()).isTrue();
+ assertThat(gate.hasReportedSplits()).isTrue();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isTrue();
+ assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ }
+
+ @Test
+ void testEmptyReportedSplitsAreIgnored() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ gate.recordReportedSplits(1, Collections.emptyList());
+
+ assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.hasReportedSplits()).isFalse();
+ }
+
+ @Test
+ void testRepeatedReportForSameReaderReplacesPreviousReport() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ DynamicKafkaSourceSplit firstReport = split("topic", 0);
+ DynamicKafkaSourceSplit secondReport = split("topic", 1);
+ gate.recordReportedSplits(1, Collections.singletonList(firstReport));
+ gate.recordReportedSplits(1, Collections.singletonList(secondReport));
+
+ // A reader that registers again keeps one entry holding its latest
report, so the
+ // duplicate-owner check in reassignReportedSplits is not tripped by
its own re-report.
+ NavigableMap<Integer, List<DynamicKafkaSourceSplit>> drained =
gate.drainReportedSplits();
+
+ assertThat(drained.keySet()).containsExactly(1);
+ assertThat(drained.get(1)).containsExactly(secondReport);
+ }
+
+ @Test
+ void testEmptyRepeatedReportRetainsPreviousReport() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ DynamicKafkaSourceSplit firstReport = split("topic", 0);
+ gate.recordReportedSplits(1, Collections.singletonList(firstReport));
+ gate.recordReportedSplits(1, Collections.emptyList());
+
+
assertThat(gate.drainReportedSplits().get(1)).containsExactly(firstReport);
+ }
+
+ @Test
+ void testDrainReportedSplitsReturnsReaderOrderAndClears() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ DynamicKafkaSourceSplit splitReader2 = split("topic", 2);
+ DynamicKafkaSourceSplit splitReader0 = split("topic", 0);
+ gate.recordReportedSplits(2, Collections.singletonList(splitReader2));
+ gate.recordReportedSplits(0, Collections.singletonList(splitReader0));
+
+ NavigableMap<Integer, List<DynamicKafkaSourceSplit>> drained =
gate.drainReportedSplits();
+
+ assertThat(drained.keySet()).containsExactly(0, 2);
+ assertThat(drained.get(0)).containsExactly(splitReader0);
+ assertThat(drained.get(2)).containsExactly(splitReader2);
+ assertThat(gate.hasReportedSplits()).isFalse();
+ assertThat(gate.drainReportedSplits()).isEmpty();
+ }
+
+ @Test
+ void testDrainDeferredMetadataUpdateReadersReturnsSortedAndClears() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
+ gate.deferMetadataUpdate(3);
+ gate.deferMetadataUpdates(Arrays.asList(1, 2, 3));
+
+
assertThat(gate.drainDeferredMetadataUpdateReaders()).containsExactly(1, 2, 3);
+ assertThat(gate.drainDeferredMetadataUpdateReaders()).isEmpty();
+ }
+
+ private static DynamicKafkaSourceSplit split(String topic, int partition) {
+ return new DynamicKafkaSourceSplit(
+ "cluster0", new KafkaPartitionSplit(new TopicPartition(topic,
partition), 0L));
+ }
+}