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 d43f3ae6 [FLINK-39940] DynamicKafkaSource should refresh restored 
cluster properties from KafkaMetadataService (#266)
d43f3ae6 is described below

commit d43f3ae66531d39a12102cac18123603ca73eb0e
Author: bowenli86 <[email protected]>
AuthorDate: Wed Jun 17 11:38:00 2026 -0700

    [FLINK-39940] DynamicKafkaSource should refresh restored cluster properties 
from KafkaMetadataService (#266)
    
    DynamicKafkaSource restores KafkaStream metadata from the checkpointed 
enumerator state when a job is resumed from a checkpoint or savepoint. Before 
this change, restored sub-enumerators were rebuilt from the checkpointed 
cluster properties without first refreshing them from KafkaMetadataService. 
That means non-bootstrap Kafka client properties such as authentication or 
security settings can be stale at restore time, and the job can fail before the 
next periodic metadata refresh has a [...]
    
    The restore path should use the latest metadata-service properties 
immediately, while still preserving the checkpointed bootstrap.servers for 
restored clusters so the enumerator reconnects to the same restored cluster 
identity. Restore-time metadata lookup failures should also fail fast instead 
of being deferred until a later discovery cycle.
    
    This PR also adds DEBUG-level startup-offset logs for checkpoint snapshot 
and restore to make restore issues easier to diagnose.
---
 .../enumerator/DynamicKafkaSourceEnumerator.java   | 130 ++++++-
 .../DynamicKafkaSourceEnumeratorTest.java          | 395 +++++++++++++++++++--
 2 files changed, 488 insertions(+), 37 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 73c0a143..ba20fa98 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
@@ -41,6 +41,7 @@ import 
org.apache.flink.connector.kafka.source.enumerator.subscriber.KafkaSubscr
 import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
 import org.apache.flink.util.Preconditions;
 
+import org.apache.kafka.clients.CommonClientConfigs;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.common.KafkaException;
 import org.slf4j.Logger;
@@ -50,7 +51,9 @@ import javax.annotation.Nullable;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -158,10 +161,18 @@ public class DynamicKafkaSourceEnumerator
         this.stoppableKafkaEnumContextProxyFactory = 
stoppableKafkaEnumContextProxyFactory;
         this.splitAssignmentStrategy = 
createSplitAssignmentStrategy(properties);
 
+        if 
(!dynamicKafkaSourceEnumState.getClusterEnumeratorStates().isEmpty()) {
+            logger.info("Dynamic Kafka source restored from checkpointed 
enumerator state");
+        }
+
         // handle checkpoint state and rebuild contexts
         this.clusterEnumeratorMap = new HashMap<>();
         this.clusterEnumContextMap = new HashMap<>();
         this.latestKafkaStreams = 
dynamicKafkaSourceEnumState.getKafkaStreams();
+        if (!this.latestKafkaStreams.isEmpty()) {
+            this.latestKafkaStreams =
+                    
refreshRestoredClusterPropertiesFromMetadataService(this.latestKafkaStreams);
+        }
 
         Map<String, Properties> clusterProperties = new HashMap<>();
         Map<String, OffsetsInitializer> clusterStartingOffsets = new 
HashMap<>();
@@ -186,9 +197,18 @@ public class DynamicKafkaSourceEnumerator
         Set<String> activeSplitIds = new HashSet<>();
         for (Entry<String, KafkaSourceEnumState> clusterEnumState :
                 
dynamicKafkaSourceEnumState.getClusterEnumeratorStates().entrySet()) {
+            String clusterId = clusterEnumState.getKey();
+            KafkaSourceEnumState state = clusterEnumState.getValue();
+            if (!state.assignedSplits().isEmpty() || 
!state.unassignedSplits().isEmpty()) {
+                logger.debug(
+                        "Restored enumerator startup offsets for cluster {} 
assigned={} unassigned={}",
+                        clusterId,
+                        summarizeSplitOffsets(state.assignedSplits()),
+                        summarizeSplitOffsets(state.unassignedSplits()));
+            }
             this.latestClusterTopicsMap.put(
-                    clusterEnumState.getKey(),
-                    clusterEnumState.getValue().assignedSplits().stream()
+                    clusterId,
+                    state.assignedSplits().stream()
                             .map(KafkaPartitionSplit::getTopic)
                             .collect(Collectors.toSet()));
             clusterEnumState
@@ -197,21 +217,81 @@ public class DynamicKafkaSourceEnumerator
                     .forEach(
                             splitStatus ->
                                     activeSplitIds.add(
-                                            toDynamicSplitId(
-                                                    clusterEnumState.getKey(),
-                                                    splitStatus.split())));
+                                            toDynamicSplitId(clusterId, 
splitStatus.split())));
 
             createEnumeratorWithAssignedTopicPartitions(
-                    clusterEnumState.getKey(),
-                    this.latestClusterTopicsMap.get(clusterEnumState.getKey()),
-                    clusterEnumState.getValue(),
-                    clusterProperties.get(clusterEnumState.getKey()),
-                    clusterStartingOffsets.get(clusterEnumState.getKey()),
-                    clusterStoppingOffsets.get(clusterEnumState.getKey()));
+                    clusterId,
+                    this.latestClusterTopicsMap.get(clusterId),
+                    state,
+                    clusterProperties.get(clusterId),
+                    clusterStartingOffsets.get(clusterId),
+                    clusterStoppingOffsets.get(clusterId));
         }
         splitAssignmentStrategy.onMetadataRefresh(activeSplitIds);
     }
 
+    private Set<KafkaStream> 
refreshRestoredClusterPropertiesFromMetadataService(
+            Set<KafkaStream> restoredKafkaStreams) {
+        Set<KafkaStream> fetchedKafkaStreams =
+                
kafkaStreamSubscriber.getSubscribedStreams(kafkaMetadataService);
+
+        Map<String, Properties> fetchedClusterPropertiesById =
+                extractClusterPropertiesById(fetchedKafkaStreams);
+        Set<KafkaStream> mergedKafkaStreams = new HashSet<>();
+        for (KafkaStream restoredKafkaStream : restoredKafkaStreams) {
+            Map<String, ClusterMetadata> mergedClusterMetadataMap = new 
HashMap<>();
+            for (Entry<String, ClusterMetadata> restoredClusterEntry :
+                    restoredKafkaStream.getClusterMetadataMap().entrySet()) {
+                String kafkaClusterId = restoredClusterEntry.getKey();
+                ClusterMetadata restoredClusterMetadata = 
restoredClusterEntry.getValue();
+
+                Properties mergedProperties = new Properties();
+                Properties fetchedProperties = 
fetchedClusterPropertiesById.get(kafkaClusterId);
+                if (fetchedProperties != null) {
+                    KafkaPropertiesUtil.copyProperties(fetchedProperties, 
mergedProperties);
+                }
+
+                String restoredBootstrapServers =
+                        restoredClusterMetadata
+                                .getProperties()
+                                
.getProperty(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG);
+                if (restoredBootstrapServers != null) {
+                    mergedProperties.setProperty(
+                            CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, 
restoredBootstrapServers);
+                }
+                if (mergedProperties.isEmpty()) {
+                    KafkaPropertiesUtil.copyProperties(
+                            restoredClusterMetadata.getProperties(), 
mergedProperties);
+                }
+
+                mergedClusterMetadataMap.put(
+                        kafkaClusterId,
+                        new ClusterMetadata(
+                                restoredClusterMetadata.getTopics(),
+                                mergedProperties,
+                                
restoredClusterMetadata.getStartingOffsetsInitializer(),
+                                
restoredClusterMetadata.getStoppingOffsetsInitializer()));
+            }
+            mergedKafkaStreams.add(
+                    new KafkaStream(restoredKafkaStream.getStreamId(), 
mergedClusterMetadataMap));
+        }
+
+        return mergedKafkaStreams;
+    }
+
+    private static Map<String, Properties> extractClusterPropertiesById(
+            Set<KafkaStream> kafkaStreams) {
+        Map<String, Properties> clusterPropertiesById = new HashMap<>();
+        for (KafkaStream kafkaStream : kafkaStreams) {
+            for (Entry<String, ClusterMetadata> clusterEntry :
+                    kafkaStream.getClusterMetadataMap().entrySet()) {
+                clusterPropertiesById.put(
+                        clusterEntry.getKey(), 
clusterEntry.getValue().getProperties());
+            }
+        }
+        return clusterPropertiesById;
+    }
+
     /**
      * Discover Kafka clusters and initialize sub enumerators. Bypass kafka 
metadata service
      * discovery if there exists prior state. Exceptions with initializing 
Kafka source are treated
@@ -563,18 +643,40 @@ public class DynamicKafkaSourceEnumerator
     @Override
     public DynamicKafkaSourceEnumState snapshotState(long checkpointId) throws 
Exception {
         Map<String, KafkaSourceEnumState> subEnumeratorStateByCluster = new 
HashMap<>();
+        boolean isCheckpointSnapshot = checkpointId >= 0;
 
         // populate map for all assigned splits
         for (Entry<String, SplitEnumerator<KafkaPartitionSplit, 
KafkaSourceEnumState>>
                 clusterEnumerator : clusterEnumeratorMap.entrySet()) {
-            subEnumeratorStateByCluster.put(
-                    clusterEnumerator.getKey(),
-                    clusterEnumerator.getValue().snapshotState(checkpointId));
+            KafkaSourceEnumState state = 
clusterEnumerator.getValue().snapshotState(checkpointId);
+            subEnumeratorStateByCluster.put(clusterEnumerator.getKey(), state);
+            if (isCheckpointSnapshot) {
+                logger.debug(
+                        "Checkpoint {} cluster {} enumerator startup offsets 
for assigned splits {}",
+                        checkpointId,
+                        clusterEnumerator.getKey(),
+                        summarizeSplitOffsets(state.assignedSplits()));
+                logger.debug(
+                        "Checkpoint {} cluster {} enumerator startup offsets 
for unassigned splits {}",
+                        checkpointId,
+                        clusterEnumerator.getKey(),
+                        summarizeSplitOffsets(state.unassignedSplits()));
+            }
         }
 
         return new DynamicKafkaSourceEnumState(latestKafkaStreams, 
subEnumeratorStateByCluster);
     }
 
+    private static String 
summarizeSplitOffsets(Collection<KafkaPartitionSplit> splits) {
+        if (splits.isEmpty()) {
+            return "[]";
+        }
+        return splits.stream()
+                .sorted(Comparator.comparing(split -> 
split.getTopicPartition().toString()))
+                .map(split -> split.getTopicPartition() + "=" + 
split.getStartingOffset())
+                .collect(Collectors.joining(",", "[", "]"));
+    }
+
     @Override
     public void handleSourceEvent(int subtaskId, SourceEvent sourceEvent) {
         Preconditions.checkArgument(
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 40e0dd00..e6867d32 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
@@ -29,6 +29,7 @@ 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.DynamicKafkaSourceOptions;
 import org.apache.flink.connector.kafka.dynamic.source.GetMetadataUpdateEvent;
+import org.apache.flink.connector.kafka.dynamic.source.MetadataUpdateEvent;
 import 
org.apache.flink.connector.kafka.dynamic.source.enumerator.subscriber.KafkaStreamSetSubscriber;
 import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
 import org.apache.flink.connector.kafka.source.KafkaSourceOptions;
@@ -44,6 +45,7 @@ import org.apache.flink.runtime.state.OperatorStateHandle;
 import org.apache.flink.runtime.state.OperatorStreamStateHandle;
 import org.apache.flink.runtime.state.memory.ByteStreamStateHandle;
 import 
org.apache.flink.streaming.connectors.kafka.DynamicKafkaSourceTestHelper;
+import org.apache.flink.testutils.logging.LoggerAuditingExtension;
 
 import com.google.common.collect.ImmutableSet;
 import org.apache.kafka.clients.CommonClientConfigs;
@@ -52,6 +54,7 @@ import org.apache.kafka.common.TopicPartition;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -61,6 +64,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Properties;
 import java.util.Set;
 import java.util.concurrent.Callable;
@@ -72,6 +76,7 @@ import java.util.stream.Collectors;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.slf4j.event.Level.DEBUG;
 
 /** A test for {@link DynamicKafkaSourceEnumerator}. */
 public class DynamicKafkaSourceEnumeratorTest {
@@ -80,6 +85,12 @@ public class DynamicKafkaSourceEnumeratorTest {
     private static final int NUM_SPLITS_PER_CLUSTER = 3;
     private static final int NUM_RECORDS_PER_SPLIT = 5;
     private static final String SOURCE_READER_SPLIT_STATE_NAME = 
"source-reader-splits";
+    private static final String REFRESHED_CLUSTER_PROPERTY_KEY = 
"refreshed.cluster.property";
+    private static final String REFRESHED_CLUSTER_PROPERTY_VALUE = 
"from-metadata-service";
+
+    @RegisterExtension
+    public final LoggerAuditingExtension dynamicEnumeratorLogger =
+            new LoggerAuditingExtension(DynamicKafkaSourceEnumerator.class, 
DEBUG);
 
     @BeforeAll
     public static void beforeAll() throws Throwable {
@@ -175,9 +186,7 @@ public class DynamicKafkaSourceEnumeratorTest {
     }
 
     @Test
-    public void
-            
testStartupWithKafkaMetadataServiceFailure_withContinuousDiscoveryAndCheckpointState()
-                    throws Throwable {
+    public void testRestoreWithKafkaMetadataServiceFailure_surfacesFailure() 
throws Throwable {
         // init enumerator with checkpoint state
         final DynamicKafkaSourceEnumState dynamicKafkaSourceEnumState = 
getCheckpointState();
         Properties properties = new Properties();
@@ -185,26 +194,22 @@ public class DynamicKafkaSourceEnumeratorTest {
                 
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "1");
         
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
 "0");
         try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
-                        new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
-                DynamicKafkaSourceEnumerator enumerator =
-                        new DynamicKafkaSourceEnumerator(
-                                new 
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
-                                new MockKafkaMetadataService(true),
-                                context,
-                                OffsetsInitializer.committedOffsets(),
-                                new NoStoppingOffsetsInitializer(),
-                                properties,
-                                Boundedness.CONTINUOUS_UNBOUNDED,
-                                dynamicKafkaSourceEnumState,
-                                new TestKafkaEnumContextProxyFactory())) {
-            enumerator.start();
-
-            assertThat(context.getPeriodicCallables()).hasSize(1);
-            // no exception
-            context.runPeriodicCallable(0);
-
-            assertThatThrownBy(() -> context.runPeriodicCallable(0))
-                    .hasRootCause(new RuntimeException("Mock exception"));
+                new MockSplitEnumeratorContext<>(NUM_SUBTASKS)) {
+            assertThatThrownBy(
+                            () ->
+                                    new DynamicKafkaSourceEnumerator(
+                                            new KafkaStreamSetSubscriber(
+                                                    
Collections.singleton(TOPIC)),
+                                            new MockKafkaMetadataService(true),
+                                            context,
+                                            
OffsetsInitializer.committedOffsets(),
+                                            new NoStoppingOffsetsInitializer(),
+                                            properties,
+                                            Boundedness.CONTINUOUS_UNBOUNDED,
+                                            dynamicKafkaSourceEnumState,
+                                            new 
TestKafkaEnumContextProxyFactory()))
+                    .isInstanceOf(RuntimeException.class)
+                    .hasMessage("Mock exception");
         }
     }
 
@@ -475,6 +480,124 @@ public class DynamicKafkaSourceEnumeratorTest {
         }
     }
 
+    @Test
+    public void testSnapshotStateLogsAssignedAndUnassignedOffsets() throws 
Throwable {
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
+                DynamicKafkaSourceEnumerator enumerator = 
createEnumerator(context)) {
+            enumerator.start();
+
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
0);
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
1);
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
2);
+            runAllOneTimeCallables(context);
+
+            enumerator.snapshotState(7L);
+
+            // Snapshot logs in dynamic Kafka source encode special offset 
sentinels:
+            // -2 = EARLIEST_OFFSET in KafkaPartitionSplit (not a real 
partition offset).
+            assertThat(dynamicEnumeratorLogger.getMessages())
+                    .as(
+                            "checkpoint snapshot should label enumerator 
startup offsets for assigned splits")
+                    .anyMatch(
+                            message ->
+                                    message.equals(
+                                            "Checkpoint 7 cluster 
kafka-cluster-0 enumerator startup offsets for assigned splits "
+                                                    + "["
+                                                    + TOPIC
+                                                    + "-0=-2,"
+                                                    + TOPIC
+                                                    + "-1=-2,"
+                                                    + TOPIC
+                                                    + "-2=-2]"))
+                    .as(
+                            "checkpoint snapshot should label enumerator 
startup offsets for unassigned splits")
+                    .anyMatch(
+                            message ->
+                                    message.equals(
+                                            "Checkpoint 7 cluster 
kafka-cluster-0 enumerator startup offsets for unassigned splits []"))
+                    .as(
+                            "checkpoint snapshot should include equivalent 
logs for second cluster in the same format")
+                    .anyMatch(
+                            message ->
+                                    message.equals(
+                                            "Checkpoint 7 cluster 
kafka-cluster-1 enumerator startup offsets for assigned splits "
+                                                    + "["
+                                                    + TOPIC
+                                                    + "-0=-2,"
+                                                    + TOPIC
+                                                    + "-1=-2,"
+                                                    + TOPIC
+                                                    + "-2=-2]"))
+                    .as(
+                            "checkpoint snapshot should include empty 
unassigned offsets for second cluster")
+                    .anyMatch(
+                            message ->
+                                    message.equals(
+                                            "Checkpoint 7 cluster 
kafka-cluster-1 enumerator startup offsets for unassigned splits []"));
+        }
+    }
+
+    @Test
+    public void testRestoreLogsCheckpointedOffsets() throws Throwable {
+        Properties properties = new Properties();
+        properties.setProperty(
+                
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "0");
+        
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
 "0");
+
+        DynamicKafkaSourceEnumState checkpointState = getCheckpointState();
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                new MockSplitEnumeratorContext<>(NUM_SUBTASKS)) {
+            try (DynamicKafkaSourceEnumerator restoredEnumerator =
+                    new DynamicKafkaSourceEnumerator(
+                            new 
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+                            new MockKafkaMetadataService(
+                                    Collections.singleton(
+                                            
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC))),
+                            context,
+                            OffsetsInitializer.earliest(),
+                            new NoStoppingOffsetsInitializer(),
+                            properties,
+                            Boundedness.CONTINUOUS_UNBOUNDED,
+                            checkpointState,
+                            new TestKafkaEnumContextProxyFactory())) {
+                restoredEnumerator.start();
+
+                assertThat(dynamicEnumeratorLogger.getMessages())
+                        .as("restore path should emit checkpoint state logs")
+                        .anyMatch(
+                                message ->
+                                        message.contains(
+                                                "Dynamic Kafka source restored 
from checkpointed enumerator state"))
+                        .as(
+                                "restore path should label restored enumerator 
startup offsets for first active cluster")
+                        .anyMatch(
+                                message ->
+                                        message.equals(
+                                                "Restored enumerator startup 
offsets for cluster kafka-cluster-0 assigned=["
+                                                        + TOPIC
+                                                        + "-0=-2,"
+                                                        + TOPIC
+                                                        + "-1=-2,"
+                                                        + TOPIC
+                                                        + "-2=-2] 
unassigned=[]"))
+                        .as(
+                                "restore path should label restored enumerator 
startup offsets for second active cluster")
+                        .anyMatch(
+                                message ->
+                                        message.equals(
+                                                "Restored enumerator startup 
offsets for cluster kafka-cluster-1 assigned=["
+                                                        + TOPIC
+                                                        + "-0=-2,"
+                                                        + TOPIC
+                                                        + "-1=-2,"
+                                                        + TOPIC
+                                                        + "-2=-2] 
unassigned=[]"));
+            }
+        }
+    }
+
     @Test
     public void testEnumeratorStateDoesNotContainStaleTopicPartitions() throws 
Throwable {
         final String topic2 = TOPIC + "_2";
@@ -653,6 +776,201 @@ public class DynamicKafkaSourceEnumeratorTest {
         }
     }
 
+    @Test
+    public void 
testRestoreRefreshesNonBootstrapClusterPropertiesFromMetadataService()
+            throws Throwable {
+        KafkaStream baseStream = 
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+        DynamicKafkaSourceEnumState checkpointState = 
getCheckpointState(baseStream);
+
+        DynamicKafkaSourceEnumStateSerializer serializer =
+                new DynamicKafkaSourceEnumStateSerializer();
+        DynamicKafkaSourceEnumState serializedRestoredState =
+                serializer.deserialize(
+                        serializer.getVersion(), 
serializer.serialize(checkpointState));
+
+        KafkaStream restoredStream =
+                serializedRestoredState.getKafkaStreams().stream()
+                        .findFirst()
+                        .orElseThrow(() -> new AssertionError("Missing 
restored stream"));
+        Map<String, String> restoredBootstrapByCluster = new HashMap<>();
+        for (Entry<String, ClusterMetadata> entry :
+                restoredStream.getClusterMetadataMap().entrySet()) {
+            restoredBootstrapByCluster.put(
+                    entry.getKey(),
+                    entry.getValue()
+                            .getProperties()
+                            
.getProperty(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG));
+        }
+
+        Map<String, ClusterMetadata> refreshedClusterMetadataMap = new 
HashMap<>();
+        for (Entry<String, ClusterMetadata> entry :
+                restoredStream.getClusterMetadataMap().entrySet()) {
+            Properties refreshedProperties = new Properties();
+            refreshedProperties.setProperty(
+                    CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG,
+                    "placeholder-from-managed-config");
+            refreshedProperties.setProperty(
+                    REFRESHED_CLUSTER_PROPERTY_KEY, 
REFRESHED_CLUSTER_PROPERTY_VALUE);
+            refreshedClusterMetadataMap.put(
+                    entry.getKey(),
+                    new ClusterMetadata(
+                            entry.getValue().getTopics(),
+                            refreshedProperties,
+                            entry.getValue().getStartingOffsetsInitializer(),
+                            entry.getValue().getStoppingOffsetsInitializer()));
+        }
+        KafkaStream refreshedStream =
+                new KafkaStream(restoredStream.getStreamId(), 
refreshedClusterMetadataMap);
+
+        Properties properties = new Properties();
+        
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
 "0");
+        properties.setProperty(
+                
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "0");
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
+                DynamicKafkaSourceEnumerator enumerator =
+                        new DynamicKafkaSourceEnumerator(
+                                new 
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+                                new MockKafkaMetadataService(
+                                        
Collections.singleton(refreshedStream)),
+                                context,
+                                OffsetsInitializer.committedOffsets(),
+                                new NoStoppingOffsetsInitializer(),
+                                properties,
+                                Boundedness.CONTINUOUS_UNBOUNDED,
+                                serializedRestoredState,
+                                new TestKafkaEnumContextProxyFactory())) {
+            enumerator.start();
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
0);
+
+            MetadataUpdateEvent metadataUpdateEvent = 
getLatestMetadataUpdateEvent(context, 0);
+            KafkaStream updatedStream =
+                    metadataUpdateEvent.getKafkaStreams().stream()
+                            .findFirst()
+                            .orElseThrow(
+                                    () -> new AssertionError("Missing metadata 
update stream"));
+            for (Entry<String, ClusterMetadata> entry :
+                    updatedStream.getClusterMetadataMap().entrySet()) {
+                assertThat(
+                                entry.getValue()
+                                        .getProperties()
+                                        
.getProperty(REFRESHED_CLUSTER_PROPERTY_KEY))
+                        .as(
+                                "restored reader metadata should include 
refreshed non-bootstrap properties")
+                        .isEqualTo(REFRESHED_CLUSTER_PROPERTY_VALUE);
+                assertThat(
+                                entry.getValue()
+                                        .getProperties()
+                                        
.getProperty(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG))
+                        .as("restored bootstrap should remain checkpoint 
value")
+                        
.isEqualTo(restoredBootstrapByCluster.get(entry.getKey()));
+            }
+
+            Map<String, ?> clusterEnumeratorMap =
+                    (Map<String, ?>) Whitebox.getInternalState(enumerator, 
"clusterEnumeratorMap");
+            for (Entry<String, ?> clusterEnumeratorEntry : 
clusterEnumeratorMap.entrySet()) {
+                Properties clusterEnumeratorProperties =
+                        (Properties)
+                                Whitebox.getInternalState(
+                                        clusterEnumeratorEntry.getValue(), 
"properties");
+                
assertThat(clusterEnumeratorProperties.getProperty(REFRESHED_CLUSTER_PROPERTY_KEY))
+                        .as("restored sub-enumerator should use refreshed 
non-bootstrap properties")
+                        .isEqualTo(REFRESHED_CLUSTER_PROPERTY_VALUE);
+                assertThat(
+                                clusterEnumeratorProperties.getProperty(
+                                        
CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG))
+                        .as("restored sub-enumerator should preserve 
checkpoint bootstrap servers")
+                        
.isEqualTo(restoredBootstrapByCluster.get(clusterEnumeratorEntry.getKey()));
+            }
+        }
+    }
+
+    @Test
+    public void 
testRestoreReconcilesRemovedAndAddedClustersFromLatestMetadata() throws 
Throwable {
+        KafkaStream baseStream = 
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+        DynamicKafkaSourceEnumState checkpointState = 
getCheckpointState(baseStream);
+
+        DynamicKafkaSourceEnumStateSerializer serializer =
+                new DynamicKafkaSourceEnumStateSerializer();
+        DynamicKafkaSourceEnumState restoredState =
+                serializer.deserialize(
+                        serializer.getVersion(), 
serializer.serialize(checkpointState));
+
+        String cluster0 = DynamicKafkaSourceTestHelper.getKafkaClusterId(0);
+        String removedCluster = 
DynamicKafkaSourceTestHelper.getKafkaClusterId(1);
+        String addedCluster = removedCluster + "-replacement";
+
+        Map<String, ClusterMetadata> latestClusterMetadataMap = new 
HashMap<>();
+        latestClusterMetadataMap.put(
+                cluster0,
+                copyClusterMetadataWithOverrides(
+                        
Objects.requireNonNull(baseStream.getClusterMetadataMap().get(cluster0)),
+                        props ->
+                                props.setProperty(
+                                        REFRESHED_CLUSTER_PROPERTY_KEY,
+                                        REFRESHED_CLUSTER_PROPERTY_VALUE)));
+        latestClusterMetadataMap.put(
+                addedCluster,
+                copyClusterMetadataWithOverrides(
+                        Objects.requireNonNull(
+                                
baseStream.getClusterMetadataMap().get(removedCluster)),
+                        props ->
+                                props.setProperty(
+                                        REFRESHED_CLUSTER_PROPERTY_KEY,
+                                        REFRESHED_CLUSTER_PROPERTY_VALUE)));
+
+        KafkaStream latestMetadataStream =
+                new KafkaStream(baseStream.getStreamId(), 
latestClusterMetadataMap);
+
+        Properties properties = new Properties();
+        
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
 "0");
+        properties.setProperty(
+                
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "1");
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
+                DynamicKafkaSourceEnumerator enumerator =
+                        new DynamicKafkaSourceEnumerator(
+                                new 
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+                                new MockKafkaMetadataService(
+                                        
Collections.singleton(latestMetadataStream)),
+                                context,
+                                OffsetsInitializer.committedOffsets(),
+                                new NoStoppingOffsetsInitializer(),
+                                properties,
+                                Boundedness.CONTINUOUS_UNBOUNDED,
+                                restoredState,
+                                new TestKafkaEnumContextProxyFactory())) {
+            enumerator.start();
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
0);
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
1);
+            mockRegisterReaderAndSendReaderStartupEvent(context, enumerator, 
2);
+            runAllOneTimeCallables(context);
+
+            context.runPeriodicCallable(0);
+            runAllOneTimeCallables(context);
+
+            enumerator.handleSourceEvent(0, new GetMetadataUpdateEvent());
+            MetadataUpdateEvent metadataUpdateEvent = 
getLatestMetadataUpdateEvent(context, 0);
+            KafkaStream latestReaderStream =
+                    metadataUpdateEvent.getKafkaStreams().stream()
+                            .findFirst()
+                            .orElseThrow(
+                                    () -> new AssertionError("Missing metadata 
update stream"));
+            assertThat(latestReaderStream.getClusterMetadataMap().keySet())
+                    .as("restored readers should converge to latest cluster 
set after discovery")
+                    .containsExactlyInAnyOrder(cluster0, addedCluster);
+            assertThat(latestReaderStream.getClusterMetadataMap())
+                    .doesNotContainKey(removedCluster);
+
+            DynamicKafkaSourceEnumState postRefreshState = 
enumerator.snapshotState(-1);
+            assertThat(postRefreshState.getClusterEnumeratorStates().keySet())
+                    .as("restored enumerator state should drop removed cluster 
and add new cluster")
+                    .containsExactlyInAnyOrder(cluster0, addedCluster);
+            assertThat(postRefreshState.getClusterEnumeratorStates())
+                    .doesNotContainKey(removedCluster);
+        }
+    }
+
     @Test
     public void testAddSplitsBack() throws Throwable {
         try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
@@ -1690,6 +2008,37 @@ public class DynamicKafkaSourceEnumeratorTest {
         }
     }
 
+    private MetadataUpdateEvent getLatestMetadataUpdateEvent(
+            MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context, int 
readerId)
+            throws Exception {
+        List<SourceEvent> sourceEvents = 
context.getSentSourceEvent().get(readerId);
+        assertThat(sourceEvents)
+                .as("reader %s should have received source events", readerId)
+                .isNotNull();
+        return sourceEvents.stream()
+                .filter(MetadataUpdateEvent.class::isInstance)
+                .map(MetadataUpdateEvent.class::cast)
+                .reduce((first, second) -> second)
+                .orElseThrow(
+                        () ->
+                                new AssertionError(
+                                        String.format(
+                                                "reader %s did not receive 
metadata update event",
+                                                readerId)));
+    }
+
+    private ClusterMetadata copyClusterMetadataWithOverrides(
+            ClusterMetadata baseClusterMetadata, Consumer<Properties> 
overrideConsumer) {
+        Properties copiedProperties = new Properties();
+        copiedProperties.putAll(baseClusterMetadata.getProperties());
+        overrideConsumer.accept(copiedProperties);
+        return new ClusterMetadata(
+                baseClusterMetadata.getTopics(),
+                copiedProperties,
+                baseClusterMetadata.getStartingOffsetsInitializer(),
+                baseClusterMetadata.getStoppingOffsetsInitializer());
+    }
+
     private static class TestKafkaEnumContextProxyFactory
             implements 
StoppableKafkaEnumContextProxy.StoppableKafkaEnumContextProxyFactory {
 


Reply via email to