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 70fb6311 [FLINK-39944] Make dynamic Kafka metadata refresh resilient
(#274)
70fb6311 is described below
commit 70fb6311c1d5483cb8fc049909be5058b98cd296
Author: lnbest0707 <[email protected]>
AuthorDate: Wed Jun 24 11:48:42 2026 -0700
[FLINK-39944] Make dynamic Kafka metadata refresh resilient (#274)
This change isolates dynamic stream metadata discovery on a dedicated
stoppable worker and
marshals result handling back to the coordinator thread. Because metadata
discovery and
stale-cluster activity checks can now run from different workers, metadata
service calls are
serialized. Metadata updates are sent to readers before stale enumerator
close waits complete,
and shutdown closes the metadata service before waiting for discovery so
in-flight calls can
unblock. Asynchronous stale-enumerator close failures are retained and
surfaced from close.
---
.../enumerator/DynamicKafkaSourceEnumerator.java | 146 +++++++-
.../enumerator/StoppableKafkaEnumContextProxy.java | 23 +-
...ppableKafkaMetadataServiceDiscoveryContext.java | 149 ++++++++
.../SynchronizedKafkaMetadataService.java | 63 ++++
.../dynamic/source/DynamicKafkaSourceITTest.java | 19 +-
.../DynamicKafkaSourceEnumeratorTest.java | 383 +++++++++++++++++++++
...leKafkaMetadataServiceDiscoveryContextTest.java | 190 ++++++++++
.../SynchronizedKafkaMetadataServiceTest.java | 196 +++++++++++
8 files changed, 1148 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 cc3a34f7..a31840db 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
@@ -62,6 +62,10 @@ import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
/**
@@ -90,6 +94,10 @@ public class DynamicKafkaSourceEnumerator
private final Boundedness boundedness;
private final
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory
stoppableKafkaEnumContextProxyFactory;
+ private final StoppableKafkaMetadataServiceDiscoveryContext
+ kafkaMetadataServiceDiscoveryContext;
+ private final ExecutorService enumeratorClosingExecutor;
+ private final AtomicReference<Throwable>
asynchronousEnumeratorCloseFailure;
// options
private final long kafkaMetadataServiceDiscoveryIntervalMs;
@@ -123,7 +131,9 @@ public class DynamicKafkaSourceEnumerator
boundedness,
dynamicKafkaSourceEnumState,
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory
- .getDefaultFactory());
+ .getDefaultFactory(),
+ StoppableKafkaMetadataServiceDiscoveryContext
+
.StoppableKafkaMetadataServiceDiscoveryContextFactory.getDefaultFactory());
}
@VisibleForTesting
@@ -138,6 +148,35 @@ public class DynamicKafkaSourceEnumerator
DynamicKafkaSourceEnumState dynamicKafkaSourceEnumState,
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory
stoppableKafkaEnumContextProxyFactory) {
+ this(
+ kafkaStreamSubscriber,
+ kafkaMetadataService,
+ enumContext,
+ startingOffsetsInitializer,
+ stoppingOffsetInitializer,
+ properties,
+ boundedness,
+ dynamicKafkaSourceEnumState,
+ stoppableKafkaEnumContextProxyFactory,
+ StoppableKafkaMetadataServiceDiscoveryContext
+ .StoppableKafkaMetadataServiceDiscoveryContextFactory
+ .getSplitEnumeratorContextFactory());
+ }
+
+ DynamicKafkaSourceEnumerator(
+ KafkaStreamSubscriber kafkaStreamSubscriber,
+ KafkaMetadataService kafkaMetadataService,
+ SplitEnumeratorContext<DynamicKafkaSourceSplit> enumContext,
+ OffsetsInitializer startingOffsetsInitializer,
+ OffsetsInitializer stoppingOffsetInitializer,
+ Properties properties,
+ Boundedness boundedness,
+ DynamicKafkaSourceEnumState dynamicKafkaSourceEnumState,
+
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory
+ stoppableKafkaEnumContextProxyFactory,
+ StoppableKafkaMetadataServiceDiscoveryContext
+
.StoppableKafkaMetadataServiceDiscoveryContextFactory
+ kafkaMetadataServiceDiscoveryContextFactory) {
this.kafkaStreamSubscriber = kafkaStreamSubscriber;
this.boundedness = boundedness;
@@ -162,8 +201,16 @@ public class DynamicKafkaSourceEnumerator
this.kafkaMetadataServiceDiscoveryFailureCount = 0;
this.firstDiscoveryComplete = false;
- this.kafkaMetadataService = kafkaMetadataService;
+ this.kafkaMetadataService = new
SynchronizedKafkaMetadataService(kafkaMetadataService);
this.stoppableKafkaEnumContextProxyFactory =
stoppableKafkaEnumContextProxyFactory;
+ this.kafkaMetadataServiceDiscoveryContext =
+
kafkaMetadataServiceDiscoveryContextFactory.create(enumContext);
+ this.enumeratorClosingExecutor =
+ Executors.newSingleThreadExecutor(
+ runnable ->
+ createDaemonThread(
+ runnable,
"dynamic-kafka-enumerator-closing-worker"));
+ this.asynchronousEnumeratorCloseFailure = new AtomicReference<>();
this.splitAssignmentStrategy =
createSplitAssignmentStrategy(properties);
if
(!dynamicKafkaSourceEnumState.getClusterEnumeratorStates().isEmpty()) {
@@ -313,12 +360,15 @@ public class DynamicKafkaSourceEnumerator
}
if (kafkaMetadataServiceDiscoveryIntervalMs <= 0) {
- enumContext.callAsync(
- () ->
kafkaStreamSubscriber.getSubscribedStreams(kafkaMetadataService),
- this::onHandleSubscribedStreamsFetch);
+ logger.info("Scheduling one-time dynamic Kafka metadata refresh");
+ kafkaMetadataServiceDiscoveryContext.callAsync(
+ this::fetchSubscribedKafkaStreams,
this::onHandleSubscribedStreamsFetch);
} else {
- enumContext.callAsync(
- () ->
kafkaStreamSubscriber.getSubscribedStreams(kafkaMetadataService),
+ logger.info(
+ "Scheduling dynamic Kafka metadata refresh every {} ms",
+ kafkaMetadataServiceDiscoveryIntervalMs);
+ kafkaMetadataServiceDiscoveryContext.callAsync(
+ this::fetchSubscribedKafkaStreams,
this::onHandleSubscribedStreamsFetch,
0,
kafkaMetadataServiceDiscoveryIntervalMs);
@@ -346,7 +396,24 @@ public class DynamicKafkaSourceEnumerator
// --------------- private methods for metadata discovery ---------------
+ private Set<KafkaStream> fetchSubscribedKafkaStreams() {
+ logger.debug("Fetching subscribed Kafka streams for metadata refresh");
+ Set<KafkaStream> fetchedKafkaStreams =
+
kafkaStreamSubscriber.getSubscribedStreams(kafkaMetadataService);
+ logger.debug(
+ "Fetched {} subscribed Kafka streams for metadata refresh",
+ fetchedKafkaStreams.size());
+ return fetchedKafkaStreams;
+ }
+
+ private static Thread createDaemonThread(Runnable runnable, String
threadName) {
+ Thread thread = new Thread(runnable, threadName);
+ thread.setDaemon(true);
+ return thread;
+ }
+
private void onHandleSubscribedStreamsFetch(Set<KafkaStream>
fetchedKafkaStreams, Throwable t) {
+ logger.debug("Handling subscribed Kafka streams fetched by metadata
refresh");
firstDiscoveryComplete = true;
Set<KafkaStream> handledFetchKafkaStreams =
handleFetchSubscribedStreamsError(fetchedKafkaStreams, t);
@@ -397,13 +464,13 @@ public class DynamicKafkaSourceEnumerator
throw new RuntimeException("unable to snapshot state in metadata
change", e);
}
- logger.info("Closing enumerators due to metadata change");
-
- closeAllEnumeratorsAndContexts();
latestClusterTopicsMap = newClustersTopicsMap;
latestKafkaStreams = handledFetchKafkaStreams;
sendMetadataUpdateEventToAvailableReaders();
+ logger.info("Closing enumerators due to metadata change");
+
+ closeAllEnumeratorsAndContexts();
retainRemovedClusterEnumeratorStates(
dynamicKafkaSourceEnumState.getClusterEnumeratorStates(),
latestClusterTopicsMap.keySet());
@@ -587,17 +654,50 @@ public class DynamicKafkaSourceEnumerator
}
private void closeAllEnumeratorsAndContexts() {
- clusterEnumeratorMap.forEach(
+ Map<String, StoppableKafkaEnumContextProxy>
closingClusterEnumContextMap =
+ new HashMap<>(clusterEnumContextMap);
+ Map<String, SplitEnumerator<KafkaPartitionSplit, KafkaSourceEnumState>>
+ closingClusterEnumeratorMap = new
HashMap<>(clusterEnumeratorMap);
+ closingClusterEnumContextMap
+ .values()
+ .forEach(StoppableKafkaEnumContextProxy::prepareForClose);
+ clusterEnumContextMap.clear();
+ clusterEnumeratorMap.clear();
+
+ enumeratorClosingExecutor.execute(
+ () ->
+ closeEnumeratorsAndContexts(
+ closingClusterEnumContextMap,
closingClusterEnumeratorMap));
+ }
+
+ private void closeEnumeratorsAndContexts(
+ Map<String, StoppableKafkaEnumContextProxy>
closingClusterEnumContextMap,
+ Map<String, SplitEnumerator<KafkaPartitionSplit,
KafkaSourceEnumState>>
+ closingClusterEnumeratorMap) {
+ closingClusterEnumeratorMap.forEach(
(cluster, subEnumerator) -> {
try {
- clusterEnumContextMap.get(cluster).close();
+ closingClusterEnumContextMap.get(cluster).close();
subEnumerator.close();
} catch (Exception e) {
- throw new RuntimeException(e);
+ handleAsynchronousEnumeratorCloseFailure(e);
}
});
- clusterEnumContextMap.clear();
- clusterEnumeratorMap.clear();
+ }
+
+ private void handleAsynchronousEnumeratorCloseFailure(Exception e) {
+ asynchronousEnumeratorCloseFailure.compareAndSet(null, e);
+ try {
+ enumContext.runInCoordinatorThread(
+ () -> {
+ throw new RuntimeException(e);
+ });
+ } catch (Throwable coordinatorFailure) {
+ logger.warn(
+ "Unable to propagate asynchronous dynamic Kafka enumerator
close failure to "
+ + "the coordinator thread. The failure will be
rethrown during close.",
+ coordinatorFailure);
+ }
}
/**
@@ -754,6 +854,13 @@ public class DynamicKafkaSourceEnumerator
@Override
public void close() throws IOException {
try {
+ kafkaMetadataServiceDiscoveryContext.prepareForClose();
+
clusterEnumContextMap.values().forEach(StoppableKafkaEnumContextProxy::prepareForClose);
+
+ // Metadata service close may unblock an in-flight metadata
discovery call.
+ kafkaMetadataService.close();
+ kafkaMetadataServiceDiscoveryContext.close();
+
// close contexts first since they may have running tasks
for (StoppableKafkaEnumContextProxy subEnumContext :
clusterEnumContextMap.values()) {
subEnumContext.close();
@@ -764,7 +871,14 @@ public class DynamicKafkaSourceEnumerator
clusterEnumerator.getValue().close();
}
- kafkaMetadataService.close();
+ enumeratorClosingExecutor.shutdown();
+ enumeratorClosingExecutor.awaitTermination(Long.MAX_VALUE,
TimeUnit.MILLISECONDS);
+
+ Throwable asynchronousCloseFailure =
asynchronousEnumeratorCloseFailure.get();
+ if (asynchronousCloseFailure != null) {
+ throw new RuntimeException(
+ "Failed to close stale dynamic Kafka enumerator",
asynchronousCloseFailure);
+ }
} catch (Exception e) {
throw new RuntimeException(e);
}
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
index 752a5d6b..89843837 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
@@ -211,12 +211,19 @@ public class StoppableKafkaEnumContextProxy
@Override
public void close() throws Exception {
logger.info("Closing enum context for {}", kafkaClusterId);
+ prepareForClose();
if (subEnumeratorWorker != null) {
- // KafkaSubscriber worker thread will fail if admin client is
closed in the middle.
- // Swallow the error and set the context to closed state.
+ subEnumeratorWorker.awaitTermination(Long.MAX_VALUE,
TimeUnit.MILLISECONDS);
+ }
+ }
+
+ void prepareForClose() {
+ if (subEnumeratorWorker != null) {
+ // KafkaSubscriber worker thread may fail if KafkaSourceEnumerator
closes its Admin
+ // client while discovery is in flight. Mark the context closing
before stale
+ // enumerators are closed so those failures are swallowed and
callbacks are skipped.
isClosing = true;
subEnumeratorWorker.shutdown();
- subEnumeratorWorker.awaitTermination(Long.MAX_VALUE,
TimeUnit.MILLISECONDS);
}
}
@@ -226,6 +233,11 @@ public class StoppableKafkaEnumContextProxy
*/
protected <T> Callable<T> wrapCallAsyncCallable(Callable<T> callable) {
return () -> {
+ if (isClosing) {
+ throw new HandledFlinkKafkaException(
+ new KafkaException("Enumerator context is closing"),
kafkaClusterId);
+ }
+
try {
return callable.call();
} catch (Exception e) {
@@ -254,6 +266,11 @@ public class StoppableKafkaEnumContextProxy
protected <T> BiConsumer<T, Throwable> wrapCallAsyncCallableHandler(
BiConsumer<T, Throwable> mainHandler) {
return (result, t) -> {
+ if (isClosing) {
+ logger.debug("Skipping callback for closing enum context {}",
kafkaClusterId);
+ return;
+ }
+
// check if exception is handled
Optional<HandledFlinkKafkaException> throwable =
ExceptionUtils.findThrowable(t,
HandledFlinkKafkaException.class);
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaMetadataServiceDiscoveryContext.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaMetadataServiceDiscoveryContext.java
new file mode 100644
index 00000000..4410862d
--- /dev/null
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaMetadataServiceDiscoveryContext.java
@@ -0,0 +1,149 @@
+/*
+ * 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.api.connector.source.SplitEnumeratorContext;
+import
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+
+/**
+ * Runs dynamic Kafka stream metadata discovery outside Flink's shared source
coordinator worker.
+ *
+ * <p>The source coordinator worker is also used by per-cluster Kafka
partition discovery. Removed
+ * clusters can block that shared worker in Kafka Admin calls, so stream
metadata discovery needs an
+ * isolated worker to keep cluster removal reconciliation responsive.
+ */
+@Internal
+public class StoppableKafkaMetadataServiceDiscoveryContext implements
AutoCloseable {
+ private final SplitEnumeratorContext<DynamicKafkaSourceSplit> enumContext;
+ private final ScheduledExecutorService metadataDiscoveryWorker;
+ private volatile boolean isClosing;
+
+ public StoppableKafkaMetadataServiceDiscoveryContext(
+ SplitEnumeratorContext<DynamicKafkaSourceSplit> enumContext) {
+ this.enumContext = enumContext;
+ this.metadataDiscoveryWorker =
+ Executors.newScheduledThreadPool(
+ 1,
+ runnable ->
+ createDaemonThread(
+ runnable,
"dynamic-kafka-metadata-discovery-worker"));
+ this.isClosing = false;
+ }
+
+ public <T> void callAsync(Callable<T> callable, BiConsumer<T, Throwable>
handler) {
+ metadataDiscoveryWorker.execute(() -> invokeCallable(callable,
handler));
+ }
+
+ public <T> void callAsync(
+ Callable<T> callable,
+ BiConsumer<T, Throwable> handler,
+ long initialDelay,
+ long period) {
+ metadataDiscoveryWorker.scheduleAtFixedRate(
+ () -> invokeCallable(callable, handler),
+ initialDelay,
+ period,
+ TimeUnit.MILLISECONDS);
+ }
+
+ private <T> void invokeCallable(Callable<T> callable, BiConsumer<T,
Throwable> handler) {
+ T result = null;
+ Throwable throwable = null;
+ try {
+ result = callable.call();
+ } catch (Throwable t) {
+ throwable = t;
+ }
+
+ T fetchedResult = result;
+ Throwable fetchError = throwable;
+ runInCoordinatorThreadIfOpen(handler, fetchedResult, fetchError);
+ }
+
+ private synchronized <T> void runInCoordinatorThreadIfOpen(
+ BiConsumer<T, Throwable> handler, T result, Throwable t) {
+ if (!isClosing) {
+ enumContext.runInCoordinatorThread(() -> handleResult(handler,
result, t));
+ }
+ }
+
+ private <T> void handleResult(BiConsumer<T, Throwable> handler, T result,
Throwable t) {
+ if (!isClosing) {
+ handler.accept(result, t);
+ }
+ }
+
+ private static Thread createDaemonThread(Runnable runnable, String
threadName) {
+ Thread thread = new Thread(runnable, threadName);
+ thread.setDaemon(true);
+ return thread;
+ }
+
+ void prepareForClose() {
+ synchronized (this) {
+ isClosing = true;
+ metadataDiscoveryWorker.shutdownNow();
+ }
+ }
+
+ @Override
+ public void close() throws InterruptedException {
+ prepareForClose();
+ metadataDiscoveryWorker.awaitTermination(Long.MAX_VALUE,
TimeUnit.MILLISECONDS);
+ }
+
+ /** Factory for metadata discovery contexts. */
+ @Internal
+ public interface StoppableKafkaMetadataServiceDiscoveryContextFactory {
+ StoppableKafkaMetadataServiceDiscoveryContext create(
+ SplitEnumeratorContext<DynamicKafkaSourceSplit> enumContext);
+
+ static StoppableKafkaMetadataServiceDiscoveryContextFactory
getDefaultFactory() {
+ return StoppableKafkaMetadataServiceDiscoveryContext::new;
+ }
+
+ static StoppableKafkaMetadataServiceDiscoveryContextFactory
+ getSplitEnumeratorContextFactory() {
+ return enumContext ->
+ new
StoppableKafkaMetadataServiceDiscoveryContext(enumContext) {
+ @Override
+ public <T> void callAsync(
+ Callable<T> callable, BiConsumer<T, Throwable>
handler) {
+ enumContext.callAsync(callable, handler);
+ }
+
+ @Override
+ public <T> void callAsync(
+ Callable<T> callable,
+ BiConsumer<T, Throwable> handler,
+ long initialDelay,
+ long period) {
+ enumContext.callAsync(callable, handler,
initialDelay, period);
+ }
+ };
+ }
+ }
+}
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/SynchronizedKafkaMetadataService.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/SynchronizedKafkaMetadataService.java
new file mode 100644
index 00000000..2cf09cf0
--- /dev/null
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/SynchronizedKafkaMetadataService.java
@@ -0,0 +1,63 @@
+/*
+ * 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.metadata.KafkaMetadataService;
+import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Serializes Kafka metadata service calls across dynamic Kafka source worker
threads.
+ *
+ * <p>Dynamic stream metadata refresh and stale-cluster error handling run on
separate workers.
+ * Metadata service implementations predate that split and are not required to
be thread-safe.
+ */
+@Internal
+final class SynchronizedKafkaMetadataService implements KafkaMetadataService {
+ private final KafkaMetadataService delegate;
+
+ SynchronizedKafkaMetadataService(KafkaMetadataService delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public synchronized Set<KafkaStream> getAllStreams() {
+ return delegate.getAllStreams();
+ }
+
+ @Override
+ public synchronized Map<String, KafkaStream>
describeStreams(Collection<String> streamIds) {
+ return delegate.describeStreams(streamIds);
+ }
+
+ @Override
+ public synchronized boolean isClusterActive(String kafkaClusterId) {
+ return delegate.isClusterActive(kafkaClusterId);
+ }
+
+ @Override
+ public void close() throws Exception {
+ // Closing must be able to unblock an in-flight metadata call during
source shutdown.
+ delegate.close();
+ }
+}
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
index a28e4412..3bb77e7c 100644
---
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
@@ -277,7 +277,7 @@ class DynamicKafkaSourceITTest {
for (int readerId = 0; readerId < numSubtasks; readerId++) {
registerReader(context, enumerator, readerId);
}
- runAllOneTimeCallables(context);
+ waitForInitialSplitAssignments(context);
verifyAllSplitsAssignedOnce(
context.getSplitsAssignmentSequence(),
metadataService.getAllStreams());
@@ -571,7 +571,7 @@ class DynamicKafkaSourceITTest {
enumerator.start();
registerReader(context, enumerator, 0);
registerReader(context, enumerator, 1);
- runAllOneTimeCallables(context);
+ waitForInitialSplitAssignments(context);
List<DynamicKafkaSourceSplit> assignedSplits =
context.getSplitsAssignmentSequence().stream()
@@ -1415,6 +1415,21 @@ class DynamicKafkaSourceITTest {
}
}
+ private void waitForInitialSplitAssignments(
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context)
throws Exception {
+ CommonTestUtils.waitUtil(
+ () -> {
+ try {
+ runAllOneTimeCallables(context);
+ } catch (Throwable t) {
+ throw new RuntimeException(t);
+ }
+ return
!context.getSplitsAssignmentSequence().isEmpty();
+ },
+ Duration.ofSeconds(10),
+ "Initial dynamic Kafka split assignment did not complete");
+ }
+
private void verifyAllSplitsAssignedOnce(
List<SplitsAssignment<DynamicKafkaSourceSplit>> assignments,
Set<KafkaStream> kafkaStreams) {
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 4683037d..25f748de 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
@@ -39,6 +39,7 @@ import
org.apache.flink.connector.kafka.source.enumerator.initializer.NoStopping
import
org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
import org.apache.flink.connector.kafka.testutils.MockKafkaMetadataService;
+import org.apache.flink.core.testutils.CommonTestUtils;
import org.apache.flink.mock.Whitebox;
import
org.apache.flink.runtime.checkpoint.RoundRobinOperatorStateRepartitioner;
import org.apache.flink.runtime.state.OperatorStateHandle;
@@ -56,6 +57,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -68,6 +70,11 @@ import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@@ -1128,6 +1135,185 @@ public class DynamicKafkaSourceEnumeratorTest {
}
}
+ @Test
+ public void
testMetadataRefreshSendsReaderMetadataBeforeClosingStaleEnumerators()
+ throws Throwable {
+ KafkaStream initialStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ KafkaStream shrunkStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ String removedCluster =
DynamicKafkaSourceTestHelper.getKafkaClusterId(1);
+ shrunkStream.getClusterMetadataMap().remove(removedCluster);
+
+ MockKafkaMetadataService metadataService =
+ new
MockKafkaMetadataService(Collections.singleton(initialStream));
+ BlockingCloseKafkaEnumContextProxyFactory enumContextProxyFactory =
+ new BlockingCloseKafkaEnumContextProxyFactory();
+
+ Properties properties = new Properties();
+
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
"0");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "1");
+ ExecutorService refreshExecutor = Executors.newSingleThreadExecutor();
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
+ DynamicKafkaSourceEnumerator enumerator =
+ new DynamicKafkaSourceEnumerator(
+ new
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+ metadataService,
+ context,
+ OffsetsInitializer.committedOffsets(),
+ new NoStoppingOffsetsInitializer(),
+ properties,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ new DynamicKafkaSourceEnumState(),
+ enumContextProxyFactory)) {
+ enumerator.start();
+ context.runPeriodicCallable(0);
+ runAllOneTimeCallables(context);
+ mockRegisterReaderAndSendReaderStartupEvent(context, enumerator,
0);
+
+
metadataService.setKafkaStreams(Collections.singleton(shrunkStream));
+ Future<?> metadataRefresh =
+ refreshExecutor.submit(
+ () -> {
+ try {
+ context.runPeriodicCallable(0);
+ } catch (Throwable t) {
+ throw new RuntimeException(t);
+ }
+ });
+
+ assertThat(enumContextProxyFactory.awaitCloseStarted())
+ .as("metadata refresh should start closing stale
enumerators")
+ .isTrue();
+
+ MetadataUpdateEvent metadataUpdateEvent =
+ getLatestMetadataUpdateEventWithoutContextSync(context, 0);
+ KafkaStream latestReaderStream =
+ metadataUpdateEvent.getKafkaStreams().stream()
+ .findFirst()
+ .orElseThrow(
+ () -> new AssertionError("Missing metadata
update stream"));
+ assertThat(latestReaderStream.getClusterMetadataMap())
+ .as("metadata event sent to reader should remove stale
clusters before close")
+ .doesNotContainKey(removedCluster);
+
+ try {
+ assertThatCode(() -> metadataRefresh.get(10, TimeUnit.SECONDS))
+ .as("metadata refresh should not wait for stale
enumerator close")
+ .doesNotThrowAnyException();
+ } finally {
+ enumContextProxyFactory.allowClose();
+ }
+ } finally {
+ refreshExecutor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testCloseSurfacesAsynchronousStaleEnumeratorCloseFailure()
throws Throwable {
+ KafkaStream initialStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ KafkaStream shrunkStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ String removedCluster =
DynamicKafkaSourceTestHelper.getKafkaClusterId(1);
+ shrunkStream.getClusterMetadataMap().remove(removedCluster);
+
+ MockKafkaMetadataService metadataService =
+ new
MockKafkaMetadataService(Collections.singleton(initialStream));
+ ThrowingCloseKafkaEnumContextProxyFactory enumContextProxyFactory =
+ new ThrowingCloseKafkaEnumContextProxyFactory(removedCluster);
+
+ Properties properties = new Properties();
+
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
"0");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "1");
+
+ try (DroppingCoordinatorThreadContext context =
+ new DroppingCoordinatorThreadContext(NUM_SUBTASKS)) {
+ DynamicKafkaSourceEnumerator enumerator =
+ new DynamicKafkaSourceEnumerator(
+ new
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+ metadataService,
+ context,
+ OffsetsInitializer.committedOffsets(),
+ new NoStoppingOffsetsInitializer(),
+ properties,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ new DynamicKafkaSourceEnumState(),
+ enumContextProxyFactory);
+
+ enumerator.start();
+ context.runPeriodicCallable(0);
+ runAllOneTimeCallables(context);
+
+
metadataService.setKafkaStreams(Collections.singleton(shrunkStream));
+ context.runPeriodicCallable(0);
+ runAllOneTimeCallables(context);
+
+ assertThatThrownBy(enumerator::close)
+ .hasMessageContaining("Failed to close stale dynamic Kafka
enumerator")
+ .hasRootCauseMessage("test close failure");
+ }
+ }
+
+ @Test
+ public void
testProductionMetadataRefreshBypassesBlockedSourceCoordinatorAsyncCallable()
+ throws Throwable {
+ KafkaStream kafkaStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ BlockingDescribeStreamsKafkaMetadataService metadataService =
+ new
BlockingDescribeStreamsKafkaMetadataService(Collections.singleton(kafkaStream));
+ CountDownLatch sourceCoordinatorCallableStarted = new
CountDownLatch(1);
+ CountDownLatch allowSourceCoordinatorCallableToFinish = new
CountDownLatch(1);
+ ExecutorService sourceCoordinatorWorker =
Executors.newSingleThreadExecutor();
+
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
+ DynamicKafkaSourceEnumerator enumerator =
+ createProductionEnumerator(context, metadataService)) {
+ context.callAsync(
+ () -> {
+ sourceCoordinatorCallableStarted.countDown();
+
awaitUninterruptibly(allowSourceCoordinatorCallableToFinish);
+ return null;
+ },
+ (result, t) -> {});
+ Future<?> blockedSourceCoordinatorCallable =
+ sourceCoordinatorWorker.submit(
+ () -> {
+ try {
+ context.runNextOneTimeCallable();
+ } catch (Throwable t) {
+ throw new RuntimeException(t);
+ }
+ });
+ assertThat(sourceCoordinatorCallableStarted.await(10,
TimeUnit.SECONDS))
+ .as("source coordinator async callable should start")
+ .isTrue();
+
+ enumerator.start();
+ assertThat(metadataService.awaitDescribeStreamsStarted())
+ .as("production metadata discovery worker should fetch
streams")
+ .isTrue();
+ mockRegisterReaderAndSendReaderStartupEvent(context, enumerator,
0);
+
+ metadataService.allowDescribeStreams();
+ CommonTestUtils.waitUtil(
+ () -> hasLatestMetadataUpdateEvent(context, 0,
kafkaStream),
+ Duration.ofSeconds(10),
+ "Metadata refresh did not bypass blocked source
coordinator async callable");
+ assertThat(blockedSourceCoordinatorCallable.isDone())
+ .as("source coordinator async callable should still be
blocked")
+ .isFalse();
+
+ allowSourceCoordinatorCallableToFinish.countDown();
+ assertThatCode(() -> blockedSourceCoordinatorCallable.get(10,
TimeUnit.SECONDS))
+ .as("source coordinator async callable should finish after
release")
+ .doesNotThrowAnyException();
+ } finally {
+ metadataService.allowDescribeStreams();
+ allowSourceCoordinatorCallableToFinish.countDown();
+ sourceCoordinatorWorker.shutdownNow();
+ }
+ }
+
@Test
public void testAddSplitsBack() throws Throwable {
try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
@@ -1853,6 +2039,24 @@ public class DynamicKafkaSourceEnumeratorTest {
new TestKafkaEnumContextProxyFactory());
}
+ private DynamicKafkaSourceEnumerator createProductionEnumerator(
+ SplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+ KafkaMetadataService kafkaMetadataService) {
+ 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)),
+ kafkaMetadataService,
+ context,
+ OffsetsInitializer.earliest(),
+ new NoStoppingOffsetsInitializer(),
+ properties,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ new DynamicKafkaSourceEnumState());
+ }
+
private void mockRegisterReaderAndSendReaderStartupEvent(
MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
DynamicKafkaSourceEnumerator enumerator,
@@ -2120,6 +2324,21 @@ public class DynamicKafkaSourceEnumeratorTest {
}
}
+ private static void awaitUninterruptibly(CountDownLatch latch) {
+ boolean interrupted = false;
+ while (true) {
+ try {
+ latch.await();
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
private DynamicKafkaSourceEnumState getCheckpointState(KafkaStream
kafkaStream)
throws Throwable {
try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
@@ -2169,6 +2388,28 @@ public class DynamicKafkaSourceEnumeratorTest {
MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context, int
readerId)
throws Exception {
List<SourceEvent> sourceEvents =
context.getSentSourceEvent().get(readerId);
+ assertThat(sourceEvents)
+ .as("source events should have been sent to reader %s",
readerId)
+ .isNotNull();
+ return sourceEvents.stream()
+ .filter(MetadataUpdateEvent.class::isInstance)
+ .map(MetadataUpdateEvent.class::cast)
+ .reduce((first, second) -> second)
+ .orElseThrow(
+ () ->
+ new AssertionError(
+ String.format(
+ "metadata update event was not
sent to reader %s",
+ readerId)));
+ }
+
+ @SuppressWarnings("unchecked")
+ private MetadataUpdateEvent getLatestMetadataUpdateEventWithoutContextSync(
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context, int
readerId) {
+ Map<Integer, List<SourceEvent>> sentSourceEvents =
+ (Map<Integer, List<SourceEvent>>)
+ Whitebox.getInternalState(context, "sentSourceEvent");
+ List<SourceEvent> sourceEvents = sentSourceEvents.get(readerId);
assertThat(sourceEvents)
.as("reader %s should have received source events", readerId)
.isNotNull();
@@ -2184,6 +2425,19 @@ public class DynamicKafkaSourceEnumeratorTest {
readerId)));
}
+ private boolean hasLatestMetadataUpdateEvent(
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+ int readerId,
+ KafkaStream expectedKafkaStream) {
+ try {
+ return getLatestMetadataUpdateEventWithoutContextSync(context,
readerId)
+ .getKafkaStreams()
+ .equals(Collections.singleton(expectedKafkaStream));
+ } catch (AssertionError e) {
+ return false;
+ }
+ }
+
private ClusterMetadata copyClusterMetadataWithOverrides(
ClusterMetadata baseClusterMetadata, Consumer<Properties>
overrideConsumer) {
Properties copiedProperties = new Properties();
@@ -2242,4 +2496,133 @@ public class DynamicKafkaSourceEnumeratorTest {
period);
}
}
+
+ private static class BlockingCloseKafkaEnumContextProxyFactory
+ implements
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory {
+ private final CountDownLatch closeStarted = new CountDownLatch(1);
+ private final CountDownLatch allowClose = new CountDownLatch(1);
+
+ @Override
+ public StoppableKafkaEnumContextProxy create(
+ SplitEnumeratorContext<DynamicKafkaSourceSplit> enumContext,
+ String kafkaClusterId,
+ KafkaMetadataService kafkaMetadataService,
+ Runnable signalNoMoreSplitsCallback) {
+ return new BlockingCloseKafkaEnumContextProxy(
+ kafkaClusterId,
+ kafkaMetadataService,
+ (MockSplitEnumeratorContext<DynamicKafkaSourceSplit>)
enumContext,
+ closeStarted,
+ allowClose);
+ }
+
+ private boolean awaitCloseStarted() throws InterruptedException {
+ return closeStarted.await(10, TimeUnit.SECONDS);
+ }
+
+ private void allowClose() {
+ allowClose.countDown();
+ }
+ }
+
+ private static class BlockingCloseKafkaEnumContextProxy extends
TestKafkaEnumContextProxy {
+ private final CountDownLatch closeStarted;
+ private final CountDownLatch allowClose;
+
+ public BlockingCloseKafkaEnumContextProxy(
+ String kafkaClusterId,
+ KafkaMetadataService kafkaMetadataService,
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit>
enumContext,
+ CountDownLatch closeStarted,
+ CountDownLatch allowClose) {
+ super(kafkaClusterId, kafkaMetadataService, enumContext);
+ this.closeStarted = closeStarted;
+ this.allowClose = allowClose;
+ }
+
+ @Override
+ public void close() throws Exception {
+ closeStarted.countDown();
+ assertThat(allowClose.await(10, TimeUnit.SECONDS))
+ .as("test should allow stale enumerator close to complete")
+ .isTrue();
+ super.close();
+ }
+ }
+
+ private static class ThrowingCloseKafkaEnumContextProxyFactory
+ implements
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory {
+ private final String failingClusterId;
+
+ private ThrowingCloseKafkaEnumContextProxyFactory(String
failingClusterId) {
+ this.failingClusterId = failingClusterId;
+ }
+
+ @Override
+ public StoppableKafkaEnumContextProxy create(
+ SplitEnumeratorContext<DynamicKafkaSourceSplit> enumContext,
+ String kafkaClusterId,
+ KafkaMetadataService kafkaMetadataService,
+ Runnable signalNoMoreSplitsCallback) {
+ if (failingClusterId.equals(kafkaClusterId)) {
+ return new ThrowingCloseKafkaEnumContextProxy(
+ kafkaClusterId,
+ kafkaMetadataService,
+ (MockSplitEnumeratorContext<DynamicKafkaSourceSplit>)
enumContext);
+ }
+ return new TestKafkaEnumContextProxy(
+ kafkaClusterId,
+ kafkaMetadataService,
+ (MockSplitEnumeratorContext<DynamicKafkaSourceSplit>)
enumContext);
+ }
+ }
+
+ private static class ThrowingCloseKafkaEnumContextProxy extends
TestKafkaEnumContextProxy {
+ private ThrowingCloseKafkaEnumContextProxy(
+ String kafkaClusterId,
+ KafkaMetadataService kafkaMetadataService,
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit>
enumContext) {
+ super(kafkaClusterId, kafkaMetadataService, enumContext);
+ }
+
+ @Override
+ public void close() throws Exception {
+ throw new Exception("test close failure");
+ }
+ }
+
+ private static class DroppingCoordinatorThreadContext
+ extends MockSplitEnumeratorContext<DynamicKafkaSourceSplit> {
+ private DroppingCoordinatorThreadContext(int parallelism) {
+ super(parallelism);
+ }
+
+ @Override
+ public void runInCoordinatorThread(Runnable runnable) {}
+ }
+
+ private static class BlockingDescribeStreamsKafkaMetadataService
+ extends MockKafkaMetadataService {
+ private final CountDownLatch describeStreamsStarted = new
CountDownLatch(1);
+ private final CountDownLatch allowDescribeStreams = new
CountDownLatch(1);
+
+ private BlockingDescribeStreamsKafkaMetadataService(Set<KafkaStream>
kafkaStreams) {
+ super(kafkaStreams);
+ }
+
+ @Override
+ public Map<String, KafkaStream> describeStreams(Collection<String>
streamIds) {
+ describeStreamsStarted.countDown();
+ awaitUninterruptibly(allowDescribeStreams);
+ return super.describeStreams(streamIds);
+ }
+
+ private boolean awaitDescribeStreamsStarted() throws
InterruptedException {
+ return describeStreamsStarted.await(10, TimeUnit.SECONDS);
+ }
+
+ private void allowDescribeStreams() {
+ allowDescribeStreams.countDown();
+ }
+ }
}
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaMetadataServiceDiscoveryContextTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaMetadataServiceDiscoveryContextTest.java
new file mode 100644
index 00000000..45ab57e9
--- /dev/null
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaMetadataServiceDiscoveryContextTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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.api.connector.source.ReaderInfo;
+import org.apache.flink.api.connector.source.SourceEvent;
+import org.apache.flink.api.connector.source.SplitEnumeratorContext;
+import org.apache.flink.api.connector.source.SplitsAssignment;
+import
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
+import org.apache.flink.core.testutils.CommonTestUtils;
+import org.apache.flink.metrics.groups.SplitEnumeratorMetricGroup;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiConsumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** A test for {@link StoppableKafkaMetadataServiceDiscoveryContext}. */
+public class StoppableKafkaMetadataServiceDiscoveryContextTest {
+
+ @Test
+ public void testMetadataDiscoveryDoesNotUseSourceCoordinatorWorker()
throws Exception {
+ TrackingSplitEnumeratorContext enumContext = new
TrackingSplitEnumeratorContext();
+
+ AtomicReference<String> callbackResult = new AtomicReference<>();
+ try (StoppableKafkaMetadataServiceDiscoveryContext context =
+ new
StoppableKafkaMetadataServiceDiscoveryContext(enumContext)) {
+ context.callAsync(
+ () -> "metadata-refresh-complete",
+ (result, t) -> {
+ assertThat(t).isNull();
+ callbackResult.set(result);
+ });
+
+ CommonTestUtils.waitUtil(
+ () ->
"metadata-refresh-complete".equals(callbackResult.get()),
+ Duration.ofSeconds(5),
+ "Metadata refresh callback did not complete");
+ assertThat(callbackResult).hasValue("metadata-refresh-complete");
+ assertThat(enumContext.coordinatorThreadCallCount()).isEqualTo(1);
+ assertThat(enumContext.sourceCoordinatorAsyncCallCount()).isZero();
+ }
+ }
+
+ @Test
+ public void testCloseWaitsForMetadataDiscoveryWorker() throws Exception {
+ TrackingSplitEnumeratorContext enumContext = new
TrackingSplitEnumeratorContext();
+ CountDownLatch callableStarted = new CountDownLatch(1);
+ CountDownLatch allowCallableToFinish = new CountDownLatch(1);
+ ExecutorService closeExecutor = Executors.newSingleThreadExecutor();
+
+ try (StoppableKafkaMetadataServiceDiscoveryContext context =
+ new
StoppableKafkaMetadataServiceDiscoveryContext(enumContext)) {
+ context.callAsync(
+ () -> {
+ callableStarted.countDown();
+ awaitUninterruptibly(allowCallableToFinish);
+ return "metadata-refresh-complete";
+ },
+ (result, t) -> {});
+
+ assertThat(callableStarted.await(10, TimeUnit.SECONDS))
+ .as("metadata discovery callable should start")
+ .isTrue();
+
+ Future<?> closeFuture =
+ closeExecutor.submit(
+ () -> {
+ context.close();
+ return null;
+ });
+ assertThatThrownBy(() -> closeFuture.get(100,
TimeUnit.MILLISECONDS))
+ .as("close should wait for the metadata discovery worker")
+ .isInstanceOf(TimeoutException.class);
+
+ allowCallableToFinish.countDown();
+ assertThatCode(() -> closeFuture.get(10, TimeUnit.SECONDS))
+ .as("close should finish after the metadata discovery
worker exits")
+ .doesNotThrowAnyException();
+ assertThat(enumContext.coordinatorThreadCallCount()).isZero();
+ } finally {
+ allowCallableToFinish.countDown();
+ closeExecutor.shutdownNow();
+ }
+ }
+
+ private static void awaitUninterruptibly(CountDownLatch latch) {
+ boolean interrupted = false;
+ while (true) {
+ try {
+ latch.await();
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static class TrackingSplitEnumeratorContext
+ implements SplitEnumeratorContext<DynamicKafkaSourceSplit> {
+ private final AtomicInteger sourceCoordinatorAsyncCallCount = new
AtomicInteger();
+ private final AtomicInteger coordinatorThreadCallCount = new
AtomicInteger();
+
+ @Override
+ public SplitEnumeratorMetricGroup metricGroup() {
+ return null;
+ }
+
+ @Override
+ public void sendEventToSourceReader(int subtaskId, SourceEvent event)
{}
+
+ @Override
+ public int currentParallelism() {
+ return 1;
+ }
+
+ @Override
+ public Map<Integer, ReaderInfo> registeredReaders() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public void assignSplits(SplitsAssignment<DynamicKafkaSourceSplit>
newSplitAssignments) {}
+
+ @Override
+ public void signalNoMoreSplits(int subtask) {}
+
+ @Override
+ public <T> void callAsync(Callable<T> callable, BiConsumer<T,
Throwable> handler) {
+ sourceCoordinatorAsyncCallCount.incrementAndGet();
+ }
+
+ @Override
+ public <T> void callAsync(
+ Callable<T> callable,
+ BiConsumer<T, Throwable> handler,
+ long initialDelay,
+ long period) {
+ sourceCoordinatorAsyncCallCount.incrementAndGet();
+ }
+
+ @Override
+ public void runInCoordinatorThread(Runnable runnable) {
+ coordinatorThreadCallCount.incrementAndGet();
+ runnable.run();
+ }
+
+ private int sourceCoordinatorAsyncCallCount() {
+ return sourceCoordinatorAsyncCallCount.get();
+ }
+
+ private int coordinatorThreadCallCount() {
+ return coordinatorThreadCallCount.get();
+ }
+ }
+}
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/SynchronizedKafkaMetadataServiceTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/SynchronizedKafkaMetadataServiceTest.java
new file mode 100644
index 00000000..7896967e
--- /dev/null
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/SynchronizedKafkaMetadataServiceTest.java
@@ -0,0 +1,196 @@
+/*
+ * 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.metadata.KafkaMetadataService;
+import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+import
org.apache.flink.connector.kafka.dynamic.source.enumerator.subscriber.KafkaStreamSetSubscriber;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/** A test for {@link SynchronizedKafkaMetadataService}. */
+public class SynchronizedKafkaMetadataServiceTest {
+
+ @Test
+ public void testSubscribedStreamsAndClusterActivityChecksAreSerialized()
throws Exception {
+ BlockingKafkaMetadataService delegate = new
BlockingKafkaMetadataService();
+ KafkaMetadataService metadataService = new
SynchronizedKafkaMetadataService(delegate);
+ KafkaStreamSetSubscriber kafkaStreamSubscriber =
+ new KafkaStreamSetSubscriber(Collections.singleton("stream"));
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+
+ try {
+ Future<Set<KafkaStream>> subscribedStreams =
+ executor.submit(
+ () ->
kafkaStreamSubscriber.getSubscribedStreams(metadataService));
+ assertThat(delegate.awaitDescribeStreamsStarted())
+ .as("subscribed stream metadata fetch should start")
+ .isTrue();
+
+ Future<Boolean> isClusterActive =
+ executor.submit(() ->
metadataService.isClusterActive("cluster"));
+ assertThat(delegate.awaitIsClusterActiveStarted(100,
TimeUnit.MILLISECONDS))
+ .as("cluster activity check should wait for subscribed
stream fetch")
+ .isFalse();
+
+ delegate.allowDescribeStreams();
+ assertThat(subscribedStreams.get(10, TimeUnit.SECONDS)).isEmpty();
+ assertThat(isClusterActive.get(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(delegate.awaitIsClusterActiveStarted())
+ .as("cluster activity check should run after subscribed
stream fetch")
+ .isTrue();
+ assertThat(delegate.maxConcurrentCalls()).isEqualTo(1);
+ } finally {
+ delegate.allowDescribeStreams();
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testCloseCanUnblockInFlightMetadataCall() throws Exception {
+ BlockingKafkaMetadataService delegate = new
BlockingKafkaMetadataService();
+ KafkaMetadataService metadataService = new
SynchronizedKafkaMetadataService(delegate);
+ KafkaStreamSetSubscriber kafkaStreamSubscriber =
+ new KafkaStreamSetSubscriber(Collections.singleton("stream"));
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+
+ try {
+ Future<Set<KafkaStream>> subscribedStreams =
+ executor.submit(
+ () ->
kafkaStreamSubscriber.getSubscribedStreams(metadataService));
+ assertThat(delegate.awaitDescribeStreamsStarted())
+ .as("subscribed stream metadata fetch should start")
+ .isTrue();
+
+ Future<?> close =
+ executor.submit(
+ () -> {
+ metadataService.close();
+ return null;
+ });
+ assertThatCode(() -> close.get(10, TimeUnit.SECONDS))
+ .as("metadata service close should unblock the in-flight
fetch")
+ .doesNotThrowAnyException();
+ assertThat(subscribedStreams.get(10, TimeUnit.SECONDS)).isEmpty();
+ assertThat(delegate.awaitCloseCalled())
+ .as("delegate close should run while metadata fetch is in
flight")
+ .isTrue();
+ } finally {
+ delegate.allowDescribeStreams();
+ executor.shutdownNow();
+ }
+ }
+
+ private static class BlockingKafkaMetadataService implements
KafkaMetadataService {
+ private final CountDownLatch describeStreamsStarted = new
CountDownLatch(1);
+ private final CountDownLatch allowDescribeStreams = new
CountDownLatch(1);
+ private final CountDownLatch isClusterActiveStarted = new
CountDownLatch(1);
+ private final CountDownLatch closeCalled = new CountDownLatch(1);
+ private final AtomicInteger concurrentCalls = new AtomicInteger();
+ private final AtomicInteger maxConcurrentCalls = new AtomicInteger();
+
+ @Override
+ public Set<KafkaStream> getAllStreams() {
+ return runMetadataCall(Collections::emptySet);
+ }
+
+ @Override
+ public Map<String, KafkaStream> describeStreams(Collection<String>
streamIds) {
+ return runMetadataCall(
+ () -> {
+ describeStreamsStarted.countDown();
+ assertThat(allowDescribeStreams.await(10,
TimeUnit.SECONDS))
+ .as("test should release subscribed stream
metadata fetch")
+ .isTrue();
+ return Collections.emptyMap();
+ });
+ }
+
+ @Override
+ public boolean isClusterActive(String kafkaClusterId) {
+ return runMetadataCall(
+ () -> {
+ isClusterActiveStarted.countDown();
+ return true;
+ });
+ }
+
+ private <T> T runMetadataCall(CheckedSupplier<T> callable) {
+ int activeCalls = concurrentCalls.incrementAndGet();
+ maxConcurrentCalls.updateAndGet(currentMax -> Math.max(currentMax,
activeCalls));
+ try {
+ return callable.get();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ } finally {
+ concurrentCalls.decrementAndGet();
+ }
+ }
+
+ private boolean awaitDescribeStreamsStarted() throws
InterruptedException {
+ return describeStreamsStarted.await(10, TimeUnit.SECONDS);
+ }
+
+ private boolean awaitIsClusterActiveStarted() throws
InterruptedException {
+ return awaitIsClusterActiveStarted(10, TimeUnit.SECONDS);
+ }
+
+ private boolean awaitIsClusterActiveStarted(long timeout, TimeUnit
timeUnit)
+ throws InterruptedException {
+ return isClusterActiveStarted.await(timeout, timeUnit);
+ }
+
+ private void allowDescribeStreams() {
+ allowDescribeStreams.countDown();
+ }
+
+ private int maxConcurrentCalls() {
+ return maxConcurrentCalls.get();
+ }
+
+ @Override
+ public void close() {
+ closeCalled.countDown();
+ allowDescribeStreams();
+ }
+
+ private boolean awaitCloseCalled() throws InterruptedException {
+ return closeCalled.await(10, TimeUnit.SECONDS);
+ }
+ }
+
+ @FunctionalInterface
+ private interface CheckedSupplier<T> {
+ T get() throws Exception;
+ }
+}