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

MartijnVisser pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-kafka.git


The following commit(s) were added to refs/heads/main by this push:
     new e79fe983 [FLINK-40469] Checkpoint during recovery window silently 
loses partitions (#295)
e79fe983 is described below

commit e79fe9837f15c672172d43848e3d328ff96fe51a
Author: Aleksandr Savonin <[email protected]>
AuthorDate: Sun Sep 20 15:27:08 2026 +0200

    [FLINK-40469] Checkpoint during recovery window silently loses partitions 
(#295)
    
    [FLINK-40469][Connectors/Kafka] Checkpoint reported splits pending recovery 
reassignment
    
    Under SupportsSplitReassignmentOnRecovery, restored readers hold no
    splits and report them to DynamicKafkaSourceEnumerator, which defers
    reassignment until the first metadata discovery completes and all
    readers register. A checkpoint completing inside that window captured
    no record of the reported splits: the readers' state was empty and the
    sub enumerator states still marked the partitions assigned. Restoring
    from such a checkpoint silently stopped consuming those partitions.
    
    Checkpoint the pending reports in DynamicKafkaSourceEnumState and
    restore them in the enumerator constructor, remapping reader ids onto
    the current parallelism and merging a reader's registration report with
    any restored entry per split id. The merge is needed because a restored
    sub enumerator can assign a newly discovered partition to a reader whose
    earlier report is still pending, so one checkpoint can hold both.
    
    DynamicKafkaSourceEnumStateSerializer goes to VERSION_4, append-only:
    V1 to V3 checkpoints restore unchanged with an empty pending map.
    Checkpoints written by V4 cannot be read by older connector versions.
    
    Generated-by: Codex CLI 0.155.0-alpha.2.6
    Generated-by: Codex CLI 0.155.0-alpha.9
---
 AGENTS.md                                          |   2 +-
 .../enumerator/DynamicKafkaSourceEnumState.java    |  30 +-
 .../DynamicKafkaSourceEnumStateSerializer.java     |  63 ++-
 .../enumerator/DynamicKafkaSourceEnumerator.java   |  45 ++-
 .../source/enumerator/ReaderRecoveryGate.java      |  39 +-
 .../DynamicKafkaSourceEnumStateSerializerTest.java |  98 ++++-
 .../DynamicKafkaSourceEnumeratorRecoveryTest.java  | 435 +++++++++++++++++++++
 .../source/enumerator/ReaderRecoveryGateTest.java  |  38 +-
 .../DynamicKafkaSourceEnumStateTestUtils.java      |  64 +++
 9 files changed, 793 insertions(+), 21 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index c048ee5c..47ed9b5d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -122,7 +122,7 @@ This file provides guidance for AI coding agents working 
with the Apache Flink K
 
 ### Changing checkpointed state
 
-Splits, enumerator state, writer state and committables are written by 
`SimpleVersionedSerializer` implementations. Current versions: 
`KafkaPartitionSplitSerializer` 0, `KafkaSourceEnumStateSerializer` 4, 
`KafkaWriterStateSerializer` 2, `KafkaCommittableSerializer` 1, 
`DynamicKafkaSourceSplitSerializer` 2, `DynamicKafkaSourceEnumStateSerializer` 
3.
+Splits, enumerator state, writer state and committables are written by 
`SimpleVersionedSerializer` implementations. Current versions: 
`KafkaPartitionSplitSerializer` 0, `KafkaSourceEnumStateSerializer` 4, 
`KafkaWriterStateSerializer` 2, `KafkaCommittableSerializer` 1, 
`DynamicKafkaSourceSplitSerializer` 2, `DynamicKafkaSourceEnumStateSerializer` 
4.
 
 1. Bump the version and keep a read path for every older version
 2. Enum ordinals written to state (for example `TransactionOwnership`) must 
not be reordered
diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
index bb474d8c..d5eab749 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
@@ -20,10 +20,12 @@ package 
org.apache.flink.connector.kafka.dynamic.source.enumerator;
 
 import org.apache.flink.annotation.Internal;
 import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
 import org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumState;
 
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -36,26 +38,27 @@ public class DynamicKafkaSourceEnumState {
     private final Set<KafkaStream> kafkaStreams;
     private final Map<String, KafkaSourceEnumState> clusterEnumeratorStates;
     private final Map<String, RetainedClusterState> 
retainedClusterEnumeratorStates;
+    private final Map<Integer, List<DynamicKafkaSourceSplit>> 
pendingReportedSplitsByReader;
 
     public DynamicKafkaSourceEnumState() {
-        this.kafkaStreams = new HashSet<>();
-        this.clusterEnumeratorStates = new HashMap<>();
-        this.retainedClusterEnumeratorStates = new HashMap<>();
+        this(new HashSet<>(), new HashMap<>(), new HashMap<>(), new 
HashMap<>());
     }
 
     public DynamicKafkaSourceEnumState(
             Set<KafkaStream> kafkaStreams,
             Map<String, KafkaSourceEnumState> clusterEnumeratorStates) {
-        this(kafkaStreams, clusterEnumeratorStates, new HashMap<>());
+        this(kafkaStreams, clusterEnumeratorStates, new HashMap<>(), new 
HashMap<>());
     }
 
     public DynamicKafkaSourceEnumState(
             Set<KafkaStream> kafkaStreams,
             Map<String, KafkaSourceEnumState> clusterEnumeratorStates,
-            Map<String, RetainedClusterState> retainedClusterEnumeratorStates) 
{
+            Map<String, RetainedClusterState> retainedClusterEnumeratorStates,
+            Map<Integer, List<DynamicKafkaSourceSplit>> 
pendingReportedSplitsByReader) {
         this.kafkaStreams = kafkaStreams;
         this.clusterEnumeratorStates = clusterEnumeratorStates;
         this.retainedClusterEnumeratorStates = retainedClusterEnumeratorStates;
+        this.pendingReportedSplitsByReader = pendingReportedSplitsByReader;
     }
 
     public Set<KafkaStream> getKafkaStreams() {
@@ -70,6 +73,23 @@ public class DynamicKafkaSourceEnumState {
         return retainedClusterEnumeratorStates;
     }
 
+    /**
+     * Splits reported by readers on registration that were not yet reassigned 
when the checkpoint
+     * was taken. Readers hold no splits at that point and the sub enumerator 
states already mark
+     * the partitions as assigned, so until reassignment this map is the only 
record of the
+     * reader-reported offsets.
+     *
+     * <p>Only reports the enumerator has already received need to be 
checkpointed. Per the FLIP-537
+     * contract, the coordinator receives a restarting reader's report before 
any subsequent
+     * checkpoint request: the report is sent before the reader's task turns 
{@code RUNNING}, and no
+     * checkpoint can be triggered until it is. A checkpoint that overlaps a 
reader failure can only
+     * complete if the source task acknowledged it before failing, making it a 
consistent
+     * pre-failure snapshot.
+     */
+    public Map<Integer, List<DynamicKafkaSourceSplit>> 
getPendingReportedSplitsByReader() {
+        return pendingReportedSplitsByReader;
+    }
+
     /** Kafka enumerator state that stays checkpointed after its cluster 
becomes inactive. */
     public static class RetainedClusterState {
         private final KafkaSourceEnumState kafkaSourceEnumState;
diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
index 1cec2a18..269da01a 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
@@ -21,6 +21,8 @@ package 
org.apache.flink.connector.kafka.dynamic.source.enumerator;
 import org.apache.flink.annotation.Internal;
 import org.apache.flink.connector.kafka.dynamic.metadata.ClusterMetadata;
 import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
+import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplitSerializer;
 import org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumState;
 import 
org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumStateSerializer;
 import 
org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
@@ -37,8 +39,10 @@ import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
@@ -51,16 +55,19 @@ public class DynamicKafkaSourceEnumStateSerializer
     private static final int VERSION_1 = 1;
     private static final int VERSION_2 = 2;
     private static final int VERSION_3 = 3;
+    private static final int VERSION_4 = 4;
 
     private final KafkaSourceEnumStateSerializer 
kafkaSourceEnumStateSerializer;
+    private final DynamicKafkaSourceSplitSerializer splitSerializer;
 
     public DynamicKafkaSourceEnumStateSerializer() {
         this.kafkaSourceEnumStateSerializer = new 
KafkaSourceEnumStateSerializer();
+        this.splitSerializer = new DynamicKafkaSourceSplitSerializer();
     }
 
     @Override
     public int getVersion() {
-        return VERSION_3;
+        return VERSION_4;
     }
 
     @Override
@@ -88,6 +95,8 @@ public class DynamicKafkaSourceEnumStateSerializer
                         
retainedClusterEnumeratorState.getValue().getKafkaSourceEnumState(), out);
             }
 
+            
writePendingReportedSplits(state.getPendingReportedSplitsByReader(), out);
+
             return baos.toByteArray();
         }
     }
@@ -95,7 +104,7 @@ public class DynamicKafkaSourceEnumStateSerializer
     @Override
     public DynamicKafkaSourceEnumState deserialize(int version, byte[] 
serialized)
             throws IOException {
-        if (version != VERSION_1 && version != VERSION_2 && version != 
VERSION_3) {
+        if (version < VERSION_1 || version > getVersion()) {
             throw new IOException(
                     String.format(
                             "The bytes are serialized with version %d, "
@@ -116,7 +125,7 @@ public class DynamicKafkaSourceEnumStateSerializer
 
             Map<String, DynamicKafkaSourceEnumState.RetainedClusterState>
                     retainedClusterEnumeratorStates = new HashMap<>();
-            if (version == VERSION_3) {
+            if (version >= VERSION_3) {
                 int retainedClusterEnumeratorStateMapSize = in.readInt();
                 for (int i = 0; i < retainedClusterEnumeratorStateMapSize; 
i++) {
                     String kafkaClusterId = in.readUTF();
@@ -130,9 +139,55 @@ public class DynamicKafkaSourceEnumStateSerializer
                 }
             }
 
+            Map<Integer, List<DynamicKafkaSourceSplit>> 
pendingReportedSplitsByReader =
+                    version >= VERSION_4 ? readPendingReportedSplits(in) : new 
HashMap<>();
+
             return new DynamicKafkaSourceEnumState(
-                    kafkaStreams, clusterEnumeratorStates, 
retainedClusterEnumeratorStates);
+                    kafkaStreams,
+                    clusterEnumeratorStates,
+                    retainedClusterEnumeratorStates,
+                    pendingReportedSplitsByReader);
+        }
+    }
+
+    private void writePendingReportedSplits(
+            Map<Integer, List<DynamicKafkaSourceSplit>> 
pendingReportedSplitsByReader,
+            DataOutputStream out)
+            throws IOException {
+        out.writeInt(splitSerializer.getVersion());
+        out.writeInt(pendingReportedSplitsByReader.size());
+        for (Map.Entry<Integer, List<DynamicKafkaSourceSplit>> readerSplits :
+                pendingReportedSplitsByReader.entrySet()) {
+            out.writeInt(readerSplits.getKey());
+            out.writeInt(readerSplits.getValue().size());
+            for (DynamicKafkaSourceSplit split : readerSplits.getValue()) {
+                byte[] splitBytes = splitSerializer.serialize(split);
+                // DynamicKafkaSourceSplitSerializer consumes all remaining 
bytes on
+                // deserialization, so each split must be length-prefixed.
+                out.writeInt(splitBytes.length);
+                out.write(splitBytes);
+            }
+        }
+    }
+
+    private Map<Integer, List<DynamicKafkaSourceSplit>> 
readPendingReportedSplits(
+            DataInputStream in) throws IOException {
+        Map<Integer, List<DynamicKafkaSourceSplit>> 
pendingReportedSplitsByReader = new HashMap<>();
+        int splitSerializerVersion = in.readInt();
+        int readerCount = in.readInt();
+        for (int i = 0; i < readerCount; i++) {
+            int readerId = in.readInt();
+            int splitCount = in.readInt();
+            List<DynamicKafkaSourceSplit> splits = new ArrayList<>(splitCount);
+            for (int j = 0; j < splitCount; j++) {
+                int splitByteSize = in.readInt();
+                splits.add(
+                        splitSerializer.deserialize(
+                                splitSerializerVersion, readNBytes(in, 
splitByteSize)));
+            }
+            pendingReportedSplitsByReader.put(readerId, splits);
         }
+        return pendingReportedSplitsByReader;
     }
 
     private void writeClusterEnumeratorStates(
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 ea38c9e6..6cc98d91 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
@@ -218,6 +218,8 @@ public class DynamicKafkaSourceEnumerator
         this.splitAssignmentStrategy = 
createSplitAssignmentStrategy(properties);
         this.readerRecoveryGate =
                 new 
ReaderRecoveryGate(hasRestoredEnumeratorState(dynamicKafkaSourceEnumState));
+        
restorePendingReportedSplits(dynamicKafkaSourceEnumState.getPendingReportedSplitsByReader())
+                .forEach(readerRecoveryGate::recordReportedSplits);
 
         if 
(!dynamicKafkaSourceEnumState.getClusterEnumeratorStates().isEmpty()) {
             logger.info("Dynamic Kafka source restored from checkpointed 
enumerator state");
@@ -291,6 +293,32 @@ public class DynamicKafkaSourceEnumerator
         splitAssignmentStrategy.onMetadataRefresh(activeSplitIds);
     }
 
+    /**
+     * Restores reported splits that a checkpoint captured before recovery 
reassignment ran. Reader
+     * ids from a checkpoint taken at a different parallelism are remapped 
onto the current one, and
+     * entries that collapse onto the same reader are merged.
+     */
+    private Map<Integer, List<DynamicKafkaSourceSplit>> 
restorePendingReportedSplits(
+            Map<Integer, List<DynamicKafkaSourceSplit>> 
restoredPendingReportedSplits) {
+        Map<Integer, List<DynamicKafkaSourceSplit>> remappedSplitsByReader = 
new HashMap<>();
+        if (restoredPendingReportedSplits.isEmpty()) {
+            return remappedSplitsByReader;
+        }
+        int parallelism = enumContext.currentParallelism();
+        for (Entry<Integer, List<DynamicKafkaSourceSplit>> readerSplits :
+                restoredPendingReportedSplits.entrySet()) {
+            int readerId = Math.floorMod(readerSplits.getKey(), parallelism);
+            remappedSplitsByReader
+                    .computeIfAbsent(readerId, ignored -> new ArrayList<>())
+                    .addAll(readerSplits.getValue());
+        }
+        logger.info(
+                "Restored {} reported splits that were pending reassignment 
when the checkpoint"
+                        + " was taken",
+                
restoredPendingReportedSplits.values().stream().mapToInt(List::size).sum());
+        return remappedSplitsByReader;
+    }
+
     private Set<KafkaStream> 
refreshRestoredClusterPropertiesFromMetadataService(
             Set<KafkaStream> restoredKafkaStreams) {
         Set<KafkaStream> fetchedKafkaStreams =
@@ -923,7 +951,8 @@ public class DynamicKafkaSourceEnumerator
     private static boolean hasRestoredEnumeratorState(
             DynamicKafkaSourceEnumState dynamicKafkaSourceEnumState) {
         return 
!dynamicKafkaSourceEnumState.getClusterEnumeratorStates().isEmpty()
-                || 
!dynamicKafkaSourceEnumState.getRetainedClusterEnumeratorStates().isEmpty();
+                || 
!dynamicKafkaSourceEnumState.getRetainedClusterEnumeratorStates().isEmpty()
+                || 
!dynamicKafkaSourceEnumState.getPendingReportedSplitsByReader().isEmpty();
     }
 
     private static class ReportedSplit {
@@ -967,10 +996,22 @@ public class DynamicKafkaSourceEnumerator
             }
         }
 
+        Map<Integer, List<DynamicKafkaSourceSplit>> 
pendingReportedSplitsByReader =
+                readerRecoveryGate.snapshotReportedSplits();
+        if (isCheckpointSnapshot && !pendingReportedSplitsByReader.isEmpty()) {
+            logger.debug(
+                    "Checkpoint {} includes pending reported splits of readers 
{}",
+                    checkpointId,
+                    pendingReportedSplitsByReader.keySet());
+        }
+
+        // See DynamicKafkaSourceEnumState#getPendingReportedSplitsByReader() 
for why the
+        // pending splits are checkpointed.
         return new DynamicKafkaSourceEnumState(
                 latestKafkaStreams,
                 subEnumeratorStateByCluster,
-                new HashMap<>(retainedClusterEnumeratorStates));
+                new HashMap<>(retainedClusterEnumeratorStates),
+                pendingReportedSplitsByReader);
     }
 
     private void retainRemovedClusterEnumeratorStates(
diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
index 5707a078..14bc6930 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGate.java
@@ -21,11 +21,14 @@ package 
org.apache.flink.connector.kafka.dynamic.source.enumerator;
 import org.apache.flink.annotation.Internal;
 import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.NavigableMap;
@@ -72,11 +75,43 @@ class ReaderRecoveryGate {
         this.initialReaderRegistrationPending = restoredFromCheckpoint;
     }
 
-    /** Records splits a reader reported on registration; an empty report is 
ignored. */
+    /** Merges a reader's registration report with pending splits; an empty 
report is ignored. */
     void recordReportedSplits(int subtaskId, List<DynamicKafkaSourceSplit> 
reportedSplits) {
         if (!reportedSplits.isEmpty()) {
-            pendingReportedSplitsByReader.put(subtaskId, new 
ArrayList<>(reportedSplits));
+            pendingReportedSplitsByReader.put(
+                    subtaskId,
+                    mergeReportedSplits(
+                            pendingReportedSplitsByReader.get(subtaskId), 
reportedSplits));
+        }
+    }
+
+    /** Returns a copy of the pending reports without draining them. */
+    Map<Integer, List<DynamicKafkaSourceSplit>> snapshotReportedSplits() {
+        Map<Integer, List<DynamicKafkaSourceSplit>> snapshot = new HashMap<>();
+        pendingReportedSplitsByReader.forEach(
+                (readerId, splits) -> snapshot.put(readerId, new 
ArrayList<>(splits)));
+        return snapshot;
+    }
+
+    /**
+     * Sub-enumerators can assign newly discovered splits while reported 
splits await reassignment.
+     * A checkpoint may therefore contain both pending reports and reader-held 
splits, which must be
+     * merged on restore. Preferring the current report for overlapping split 
ids is defensive.
+     */
+    private static List<DynamicKafkaSourceSplit> mergeReportedSplits(
+            @Nullable List<DynamicKafkaSourceSplit> previousReportedSplits,
+            List<DynamicKafkaSourceSplit> reportedSplits) {
+        if (previousReportedSplits == null || 
previousReportedSplits.isEmpty()) {
+            return new ArrayList<>(reportedSplits);
+        }
+        Map<String, DynamicKafkaSourceSplit> mergedBySplitId = new 
LinkedHashMap<>();
+        for (DynamicKafkaSourceSplit split : previousReportedSplits) {
+            mergedBySplitId.put(split.splitId(), split);
+        }
+        for (DynamicKafkaSourceSplit split : reportedSplits) {
+            mergedBySplitId.put(split.splitId(), split);
         }
+        return new ArrayList<>(mergedBySplitId.values());
     }
 
     /** Whether recovery gating is active and registrations must be deferred. 
*/
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
index d79e9323..51ca3108 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
@@ -20,6 +20,7 @@ package 
org.apache.flink.connector.kafka.dynamic.source.enumerator;
 
 import org.apache.flink.connector.kafka.dynamic.metadata.ClusterMetadata;
 import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+import 
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
 import 
org.apache.flink.connector.kafka.dynamic.source.testutils.DynamicKafkaSourceEnumStateTestUtils;
 import org.apache.flink.connector.kafka.source.enumerator.AssignmentStatus;
 import org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumState;
@@ -27,6 +28,7 @@ import 
org.apache.flink.connector.kafka.source.enumerator.SplitAndAssignmentStat
 import 
org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
 import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import org.apache.kafka.clients.CommonClientConfigs;
@@ -112,7 +114,25 @@ public class DynamicKafkaSourceEnumStateSerializerTest {
                                                 ImmutableSet.of(
                                                         
getSplitAssignment("topic6", 0, ASSIGNED)),
                                                 true),
-                                        123L)));
+                                        123L)),
+                        ImmutableMap.of(
+                                0,
+                                ImmutableList.of(
+                                        new DynamicKafkaSourceSplit(
+                                                "cluster0",
+                                                new KafkaPartitionSplit(
+                                                        new 
TopicPartition("topic0", 0), 100))),
+                                2,
+                                ImmutableList.of(
+                                        new DynamicKafkaSourceSplit(
+                                                "cluster1",
+                                                new KafkaPartitionSplit(
+                                                        new 
TopicPartition("topic2", 0), 5)),
+                                        new DynamicKafkaSourceSplit(
+                                                "retained-cluster",
+                                                new KafkaPartitionSplit(
+                                                        new 
TopicPartition("topic6", 0), 7),
+                                                456L))));
 
         DynamicKafkaSourceEnumState dynamicKafkaSourceEnumStateAfterSerde =
                 dynamicKafkaSourceEnumStateSerializer.deserialize(
@@ -212,6 +232,82 @@ public class DynamicKafkaSourceEnumStateSerializerTest {
         assertThat(clusterMetadata.getStoppingOffsetsInitializer()).isNull();
     }
 
+    @Test
+    public void testDeserializeV2State() throws Exception {
+        DynamicKafkaSourceEnumStateSerializer 
dynamicKafkaSourceEnumStateSerializer =
+                new DynamicKafkaSourceEnumStateSerializer();
+        KafkaSourceEnumState clusterState =
+                new KafkaSourceEnumState(
+                        ImmutableSet.of(
+                                getSplitAssignment("topic0", 0, ASSIGNED),
+                                getSplitAssignment("topic0", 1, UNASSIGNED)),
+                        true);
+
+        byte[] serializedState =
+                DynamicKafkaSourceEnumStateTestUtils.serializeV2State(
+                        "stream0",
+                        "cluster0",
+                        ImmutableSet.of("topic0"),
+                        "cluster0:9092",
+                        clusterState);
+
+        DynamicKafkaSourceEnumState dynamicKafkaSourceEnumState =
+                dynamicKafkaSourceEnumStateSerializer.deserialize(2, 
serializedState);
+
+        assertThat(dynamicKafkaSourceEnumState.getClusterEnumeratorStates())
+                .containsOnlyKeys("cluster0");
+        
assertThat(dynamicKafkaSourceEnumState.getClusterEnumeratorStates().get("cluster0"))
+                .usingRecursiveComparison()
+                .isEqualTo(clusterState);
+        KafkaStream kafkaStream = 
dynamicKafkaSourceEnumState.getKafkaStreams().iterator().next();
+        assertThat(kafkaStream.getStreamId()).isEqualTo("stream0");
+        
assertThat(dynamicKafkaSourceEnumState.getRetainedClusterEnumeratorStates()).isEmpty();
+        
assertThat(dynamicKafkaSourceEnumState.getPendingReportedSplitsByReader()).isEmpty();
+    }
+
+    @Test
+    public void testDeserializeV3State() throws Exception {
+        DynamicKafkaSourceEnumStateSerializer 
dynamicKafkaSourceEnumStateSerializer =
+                new DynamicKafkaSourceEnumStateSerializer();
+        KafkaSourceEnumState clusterState =
+                new KafkaSourceEnumState(
+                        ImmutableSet.of(getSplitAssignment("topic0", 0, 
ASSIGNED)), true);
+        KafkaSourceEnumState retainedClusterState =
+                new KafkaSourceEnumState(
+                        ImmutableSet.of(getSplitAssignment("topic6", 0, 
ASSIGNED)), true);
+
+        byte[] serializedState =
+                DynamicKafkaSourceEnumStateTestUtils.serializeV3State(
+                        "stream0",
+                        "cluster0",
+                        ImmutableSet.of("topic0"),
+                        "cluster0:9092",
+                        clusterState,
+                        "retained-cluster",
+                        123L,
+                        retainedClusterState);
+
+        DynamicKafkaSourceEnumState dynamicKafkaSourceEnumState =
+                dynamicKafkaSourceEnumStateSerializer.deserialize(3, 
serializedState);
+
+        assertThat(dynamicKafkaSourceEnumState.getClusterEnumeratorStates())
+                .containsOnlyKeys("cluster0");
+        
assertThat(dynamicKafkaSourceEnumState.getClusterEnumeratorStates().get("cluster0"))
+                .usingRecursiveComparison()
+                .isEqualTo(clusterState);
+        
assertThat(dynamicKafkaSourceEnumState.getRetainedClusterEnumeratorStates())
+                .containsOnlyKeys("retained-cluster");
+        DynamicKafkaSourceEnumState.RetainedClusterState retainedState =
+                dynamicKafkaSourceEnumState
+                        .getRetainedClusterEnumeratorStates()
+                        .get("retained-cluster");
+        assertThat(retainedState.getRetainedUntilMs()).isEqualTo(123L);
+        assertThat(retainedState.getKafkaSourceEnumState())
+                .usingRecursiveComparison()
+                .isEqualTo(retainedClusterState);
+        
assertThat(dynamicKafkaSourceEnumState.getPendingReportedSplitsByReader()).isEmpty();
+    }
+
     private static SplitAndAssignmentStatus getSplitAssignment(
             String topic, int partition, AssignmentStatus assignStatus) {
         return new SplitAndAssignmentStatus(
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
index 9eb84b3a..88adb63f 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumeratorRecoveryTest.java
@@ -238,6 +238,441 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
                 .isTrue();
     }
 
+    @Test
+    public void 
testCheckpointDuringPendingRecoveryPreservesSplitsAndReportedOffsets()
+            throws Throwable {
+        int parallelism = 2;
+        String streamId = "stream";
+        String clusterId = "cluster-0";
+        String topic = "topic";
+        List<DynamicKafkaSourceSplit> enumeratorStateSplits = 
createSplits(clusterId, topic, 2);
+        List<DynamicKafkaSourceSplit> reportedSplits = new ArrayList<>();
+        // use distinct offsets to verify that recovery preserves the 
reader-reported positions
+        // instead of the stale EARLIEST offsets
+        for (int partition = 0; partition < 2; partition++) {
+            reportedSplits.add(
+                    new DynamicKafkaSourceSplit(
+                            clusterId,
+                            new KafkaPartitionSplit(
+                                    new TopicPartition(topic, partition), 42 + 
partition)));
+        }
+        KafkaStream kafkaStream = createKafkaStream(streamId, clusterId, 
topic);
+        Properties properties = createGlobalModeProperties();
+
+        DynamicKafkaSourceEnumState inWindowCheckpoint;
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new 
MockKafkaMetadataService(Collections.singleton(kafkaStream)),
+                                context,
+                                properties,
+                                createRestoredState(
+                                        kafkaStream, clusterId, 
enumeratorStateSplits))) {
+            enumerator.start();
+            context.registerReader(ReaderInfo.createReaderInfo(0, 
"location-0", reportedSplits));
+            enumerator.addReader(0);
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(1, "location-1", 
Collections.emptyList()));
+            enumerator.addReader(1);
+
+            assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+            inWindowCheckpoint = enumerator.snapshotState(1L);
+        }
+
+        DynamicKafkaSourceEnumState restoredCheckpoint = 
serdeRoundTrip(inWindowCheckpoint);
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new 
MockKafkaMetadataService(Collections.singleton(kafkaStream)),
+                                context,
+                                properties,
+                                restoredCheckpoint)) {
+            enumerator.start();
+            registerReadersWithoutReportedSplits(context, enumerator, 
parallelism);
+            context.runNextOneTimeCallable();
+
+            assertAssignedSplitIdsExactly(context, reportedSplits);
+            Map<Integer, Long> startingOffsetByPartition = new HashMap<>();
+            for (DynamicKafkaSourceSplit split : 
collectAssignedSplits(context)) {
+                startingOffsetByPartition.put(
+                        split.getKafkaPartitionSplit().getPartition(),
+                        split.getKafkaPartitionSplit().getStartingOffset());
+            }
+            assertThat(startingOffsetByPartition)
+                    .as("assigned splits must carry the reader-reported 
offsets")
+                    .containsEntry(0, 42L)
+                    .containsEntry(1, 43L);
+        }
+    }
+
+    @Test
+    public void testCheckpointDuringPendingRecoveryKeepsRetainedSplit() throws 
Throwable {
+        int parallelism = 2;
+        String streamId = "stream";
+        String activeClusterId = "active-cluster";
+        String activeTopic = "active-topic";
+        DynamicKafkaSourceSplit activeSplit = createSplits(activeClusterId, 
activeTopic, 1).get(0);
+        DynamicKafkaSourceSplit removedSplit =
+                createSplits("removed-cluster", "removed-topic", 1).get(0);
+        KafkaStream kafkaStream = createKafkaStream(streamId, activeClusterId, 
activeTopic);
+        Properties properties = createGlobalModeProperties();
+        properties.setProperty(
+                
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+                "60000");
+
+        DynamicKafkaSourceEnumState inWindowCheckpoint;
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new 
MockKafkaMetadataService(Collections.singleton(kafkaStream)),
+                                context,
+                                properties,
+                                createRestoredState(
+                                        kafkaStream,
+                                        activeClusterId,
+                                        
Collections.singletonList(activeSplit)))) {
+            enumerator.start();
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(
+                            0, "location-0", List.of(activeSplit, 
removedSplit)));
+            enumerator.addReader(0);
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(1, "location-1", 
Collections.emptyList()));
+            enumerator.addReader(1);
+
+            assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+            inWindowCheckpoint = enumerator.snapshotState(1L);
+        }
+
+        DynamicKafkaSourceEnumState restoredCheckpoint = 
serdeRoundTrip(inWindowCheckpoint);
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new 
MockKafkaMetadataService(Collections.singleton(kafkaStream)),
+                                context,
+                                properties,
+                                restoredCheckpoint)) {
+            enumerator.start();
+            registerReadersWithoutReportedSplits(context, enumerator, 
parallelism);
+            context.runNextOneTimeCallable();
+
+            assertAssignedSplitIdsExactly(context, List.of(activeSplit, 
removedSplit));
+            DynamicKafkaSourceSplit retainedSplit =
+                    collectAssignedSplits(context).stream()
+                            .filter(split -> 
split.splitId().equals(removedSplit.splitId()))
+                            .findFirst()
+                            .orElseThrow(AssertionError::new);
+            assertThat(retainedSplit.isRetained()).isTrue();
+        }
+    }
+
+    @Test
+    public void 
testRestoredPendingReportedSplitsMergeWhenDownscaleCollapsesReaderIds()
+            throws Throwable {
+        int parallelism = 3;
+        String streamId = "stream";
+        String clusterId = "cluster-0";
+        String topic = "topic";
+        List<DynamicKafkaSourceSplit> activeSplits = createSplits(clusterId, 
topic, 4);
+        DynamicKafkaSourceSplit removedClusterSplit =
+                createSplits("removed-cluster", "removed-topic", 1).get(0);
+        List<DynamicKafkaSourceSplit> reportedByReader4 =
+                new ArrayList<>(activeSplits.subList(2, 4));
+        reportedByReader4.add(removedClusterSplit);
+        List<DynamicKafkaSourceSplit> allReportedSplits = new 
ArrayList<>(activeSplits);
+        allReportedSplits.add(removedClusterSplit);
+        KafkaStream kafkaStream = createKafkaStream(streamId, clusterId, 
topic);
+        Properties properties = createGlobalModeProperties();
+        properties.setProperty(
+                
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+                "60000");
+        // readers 1 and 4 from a parallelism-5 checkpoint both map to reader 
floorMod(4, 3) = 1
+        Map<Integer, List<DynamicKafkaSourceSplit>> pendingSplitsByReader = 
new HashMap<>();
+        pendingSplitsByReader.put(1, activeSplits.subList(0, 2));
+        pendingSplitsByReader.put(4, reportedByReader4);
+        DynamicKafkaSourceEnumState inWindowCheckpoint =
+                new DynamicKafkaSourceEnumState(
+                        Collections.singleton(kafkaStream),
+                        Collections.singletonMap(
+                                clusterId,
+                                new KafkaSourceEnumState(
+                                        unwrapSplits(activeSplits), 
Collections.emptyList(), true)),
+                        Collections.emptyMap(),
+                        pendingSplitsByReader);
+
+        DynamicKafkaSourceEnumState restoredCheckpoint = 
serdeRoundTrip(inWindowCheckpoint);
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new 
MockKafkaMetadataService(Collections.singleton(kafkaStream)),
+                                context,
+                                properties,
+                                restoredCheckpoint)) {
+            enumerator.start();
+            registerReadersWithoutReportedSplits(context, enumerator, 
parallelism);
+            context.runNextOneTimeCallable();
+
+            Integer retainedSplitReader = null;
+            for (SplitsAssignment<DynamicKafkaSourceSplit> assignment :
+                    context.getSplitsAssignmentSequence()) {
+                for (Map.Entry<Integer, List<DynamicKafkaSourceSplit>> entry :
+                        assignment.assignment().entrySet()) {
+                    assertThat(entry.getKey()).isBetween(0, parallelism - 1);
+                    for (DynamicKafkaSourceSplit split : entry.getValue()) {
+                        if 
(split.splitId().equals(removedClusterSplit.splitId())) {
+                            assertThat(split.isRetained()).isTrue();
+                            retainedSplitReader = entry.getKey();
+                        }
+                    }
+                }
+            }
+            assertAssignedSplitIdsExactly(context, allReportedSplits);
+            // the retained split returns to its reporting reader, remapped as 
floorMod(4, 3)
+            assertThat(retainedSplitReader).isEqualTo(1);
+        }
+    }
+
+    @Test
+    public void testRestoredPendingSplitsMergeWithFreshReport() throws 
Throwable {
+        int parallelism = 2;
+        String streamId = "stream";
+        String clusterId = "cluster-0";
+        String topic = "topic";
+        // partition 0 is reported both as restored pending (offset 10) and 
fresh (offset 42),
+        // partition 1 only as restored pending, partition 2 only as fresh
+        DynamicKafkaSourceSplit restoredOverlappingSplit =
+                new DynamicKafkaSourceSplit(
+                        clusterId, new KafkaPartitionSplit(new 
TopicPartition(topic, 0), 10));
+        DynamicKafkaSourceSplit restoredOnlySplit =
+                new DynamicKafkaSourceSplit(
+                        clusterId, new KafkaPartitionSplit(new 
TopicPartition(topic, 1), 7));
+        DynamicKafkaSourceSplit freshOverlappingSplit =
+                new DynamicKafkaSourceSplit(
+                        clusterId, new KafkaPartitionSplit(new 
TopicPartition(topic, 0), 42));
+        DynamicKafkaSourceSplit freshOnlySplit =
+                new DynamicKafkaSourceSplit(
+                        clusterId, new KafkaPartitionSplit(new 
TopicPartition(topic, 2), 5));
+        KafkaStream kafkaStream = createKafkaStream(streamId, clusterId, 
topic);
+        DynamicKafkaSourceEnumState restoredCheckpoint =
+                serdeRoundTrip(
+                        new DynamicKafkaSourceEnumState(
+                                Collections.singleton(kafkaStream),
+                                Collections.singletonMap(
+                                        clusterId,
+                                        new KafkaSourceEnumState(
+                                                unwrapSplits(
+                                                        List.of(
+                                                                
restoredOverlappingSplit,
+                                                                
restoredOnlySplit,
+                                                                
freshOnlySplit)),
+                                                Collections.emptyList(),
+                                                true)),
+                                Collections.emptyMap(),
+                                Collections.singletonMap(
+                                        1, List.of(restoredOverlappingSplit, 
restoredOnlySplit))));
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new 
MockKafkaMetadataService(Collections.singleton(kafkaStream)),
+                                context,
+                                createGlobalModeProperties(),
+                                restoredCheckpoint)) {
+            enumerator.start();
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(0, "location-0", 
Collections.emptyList()));
+            enumerator.addReader(0);
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(
+                            1, "location-1", List.of(freshOverlappingSplit, 
freshOnlySplit)));
+            enumerator.addReader(1);
+            context.runNextOneTimeCallable();
+
+            assertAssignedSplitIdsExactly(
+                    context, List.of(freshOverlappingSplit, restoredOnlySplit, 
freshOnlySplit));
+            Map<Integer, Long> startingOffsetByPartition = new HashMap<>();
+            for (DynamicKafkaSourceSplit split : 
collectAssignedSplits(context)) {
+                startingOffsetByPartition.put(
+                        split.getKafkaPartitionSplit().getPartition(),
+                        split.getKafkaPartitionSplit().getStartingOffset());
+            }
+            assertThat(startingOffsetByPartition)
+                    .as("fresh report wins per split id, restored-only splits 
keep their offsets")
+                    .containsEntry(0, 42L)
+                    .containsEntry(1, 7L)
+                    .containsEntry(2, 5L);
+        }
+    }
+
+    @Test
+    public void 
testChangedMetadataAfterInWindowCheckpointDropsRemovedTopicSplits()
+            throws Throwable {
+        int parallelism = 2;
+        String streamId = "stream";
+        String clusterId = "cluster-0";
+        String keptTopic = "kept-topic";
+        String removedTopic = "removed-topic";
+        List<DynamicKafkaSourceSplit> keptSplits = createSplits(clusterId, 
keptTopic, 2);
+        List<DynamicKafkaSourceSplit> reportedSplits = new 
ArrayList<>(keptSplits);
+        reportedSplits.addAll(createSplits(clusterId, removedTopic, 1));
+        KafkaStream restoredKafkaStream =
+                createKafkaStream(streamId, clusterId, Set.of(keptTopic, 
removedTopic));
+        KafkaStream currentKafkaStream = createKafkaStream(streamId, 
clusterId, keptTopic);
+        Properties properties = createGlobalModeProperties();
+        // retention enabled: the split is dropped because its topic was 
removed while the
+        // cluster stayed active, not because retention is off
+        properties.setProperty(
+                
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+                "60000");
+
+        DynamicKafkaSourceEnumState inWindowCheckpoint;
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new MockKafkaMetadataService(
+                                        
Collections.singleton(restoredKafkaStream)),
+                                context,
+                                properties,
+                                createRestoredState(
+                                        restoredKafkaStream, clusterId, 
reportedSplits))) {
+            enumerator.start();
+            context.registerReader(ReaderInfo.createReaderInfo(0, 
"location-0", reportedSplits));
+            enumerator.addReader(0);
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(1, "location-1", 
Collections.emptyList()));
+            enumerator.addReader(1);
+
+            assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+            inWindowCheckpoint = enumerator.snapshotState(1L);
+        }
+
+        DynamicKafkaSourceEnumState restoredCheckpoint = 
serdeRoundTrip(inWindowCheckpoint);
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new MockKafkaMetadataService(
+                                        
Collections.singleton(currentKafkaStream)),
+                                context,
+                                properties,
+                                restoredCheckpoint)) {
+            enumerator.start();
+            registerReadersWithoutReportedSplits(context, enumerator, 
parallelism);
+            context.runNextOneTimeCallable();
+
+            assertAssignedSplitIdsExactly(context, keptSplits);
+        }
+    }
+
+    @Test
+    public void 
testPendingOnlyRestoredStateDefersMetadataUpdatesUntilReassignment()
+            throws Throwable {
+        int parallelism = 2;
+        String streamId = "stream";
+        String clusterId = "cluster-0";
+        String topic = "topic";
+        List<DynamicKafkaSourceSplit> splits = createSplits(clusterId, topic, 
2);
+        KafkaStream kafkaStream = createKafkaStream(streamId, clusterId, 
topic);
+        // the pending splits' cluster appears in neither the cluster nor the 
retained states
+        DynamicKafkaSourceEnumState restoredCheckpoint =
+                serdeRoundTrip(
+                        new DynamicKafkaSourceEnumState(
+                                Collections.singleton(kafkaStream),
+                                Collections.emptyMap(),
+                                Collections.emptyMap(),
+                                Collections.singletonMap(0, splits)));
+
+        try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+                        new MockSplitEnumeratorContext<>(parallelism);
+                DynamicKafkaSourceEnumerator enumerator =
+                        createEnumerator(
+                                streamId,
+                                new 
MockKafkaMetadataService(Collections.singleton(kafkaStream)),
+                                context,
+                                createGlobalModeProperties(),
+                                restoredCheckpoint)) {
+            enumerator.start();
+            registerReadersWithoutReportedSplits(context, enumerator, 
parallelism);
+            enumerator.handleSourceEvent(0, new GetMetadataUpdateEvent());
+
+            // all readers are registered, but the restored pending splits are 
not reassigned
+            // yet: the metadata update must stay deferred
+            assertThat(context.getSentSourceEvent().getOrDefault(0, 
Collections.emptyList()))
+                    .isEmpty();
+
+            context.runNextOneTimeCallable();
+
+            assertAssignedSplitIdsExactly(context, splits);
+            assertThat(context.getSentSourceEvent().get(0))
+                    .hasSize(1)
+                    .allMatch(MetadataUpdateEvent.class::isInstance);
+        }
+    }
+
+    private static void registerReadersWithoutReportedSplits(
+            MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+            DynamicKafkaSourceEnumerator enumerator,
+            int parallelism) {
+        for (int reader = 0; reader < parallelism; reader++) {
+            context.registerReader(
+                    ReaderInfo.createReaderInfo(
+                            reader, "location-" + reader, 
Collections.emptyList()));
+            enumerator.addReader(reader);
+        }
+    }
+
+    private static DynamicKafkaSourceEnumState 
serdeRoundTrip(DynamicKafkaSourceEnumState state)
+            throws Exception {
+        DynamicKafkaSourceEnumStateSerializer serializer =
+                new DynamicKafkaSourceEnumStateSerializer();
+        return serializer.deserialize(serializer.getVersion(), 
serializer.serialize(state));
+    }
+
+    /**
+     * Asserts the assigned splits match the expected splits by id, including 
multiplicity, so a
+     * duplicate assignment of the same split cannot go unnoticed.
+     */
+    private static void assertAssignedSplitIdsExactly(
+            MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+            List<DynamicKafkaSourceSplit> expectedSplits) {
+        List<String> assignedSplitIds = new ArrayList<>();
+        for (DynamicKafkaSourceSplit split : collectAssignedSplits(context)) {
+            assignedSplitIds.add(split.splitId());
+        }
+        List<String> expectedSplitIds = new ArrayList<>();
+        for (DynamicKafkaSourceSplit split : expectedSplits) {
+            expectedSplitIds.add(split.splitId());
+        }
+        
assertThat(assignedSplitIds).containsExactlyInAnyOrderElementsOf(expectedSplitIds);
+    }
+
+    private static List<DynamicKafkaSourceSplit> collectAssignedSplits(
+            MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context) {
+        List<DynamicKafkaSourceSplit> assignedSplits = new ArrayList<>();
+        for (SplitsAssignment<DynamicKafkaSourceSplit> assignment :
+                context.getSplitsAssignmentSequence()) {
+            for (List<DynamicKafkaSourceSplit> readerSplits : 
assignment.assignment().values()) {
+                assignedSplits.addAll(readerSplits);
+            }
+        }
+        return assignedSplits;
+    }
+
     private static DynamicKafkaSourceEnumState createRestoredState(
             KafkaStream kafkaStream, String clusterId, 
List<DynamicKafkaSourceSplit> activeSplits) {
         return new DynamicKafkaSourceEnumState(
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
index e6456ed4..cf3d5376 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/ReaderRecoveryGateTest.java
@@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.NavigableMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -97,19 +98,44 @@ class ReaderRecoveryGateTest {
     }
 
     @Test
-    void testRepeatedReportForSameReaderReplacesPreviousReport() {
+    void testRepeatedReportMergesSplitsAndPrefersLatestOffsets() {
         ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
         DynamicKafkaSourceSplit firstReport = split("topic", 0);
         DynamicKafkaSourceSplit secondReport = split("topic", 1);
-        gate.recordReportedSplits(1, Collections.singletonList(firstReport));
-        gate.recordReportedSplits(1, Collections.singletonList(secondReport));
+        DynamicKafkaSourceSplit updatedSplit =
+                new DynamicKafkaSourceSplit(
+                        "cluster0", new KafkaPartitionSplit(new 
TopicPartition("topic", 0), 42L));
+        DynamicKafkaSourceSplit previousOnlySplit = split("topic", 2);
+        gate.recordReportedSplits(1, Arrays.asList(firstReport, 
previousOnlySplit));
+        gate.recordReportedSplits(1, Arrays.asList(updatedSplit, 
secondReport));
 
-        // A reader that registers again keeps one entry holding its latest 
report, so the
-        // duplicate-owner check in reassignReportedSplits is not tripped by 
its own re-report.
         NavigableMap<Integer, List<DynamicKafkaSourceSplit>> drained = 
gate.drainReportedSplits();
 
         assertThat(drained.keySet()).containsExactly(1);
-        assertThat(drained.get(1)).containsExactly(secondReport);
+        assertThat(drained.get(1))
+                .containsExactlyInAnyOrder(updatedSplit, previousOnlySplit, 
secondReport);
+    }
+
+    @Test
+    void testSnapshotReportedSplitsIsIndependentAndDoesNotDrain() {
+        ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+        DynamicKafkaSourceSplit firstReport = split("topic", 0);
+        DynamicKafkaSourceSplit secondReport = split("topic", 1);
+        gate.recordReportedSplits(1, Collections.singletonList(firstReport));
+
+        Map<Integer, List<DynamicKafkaSourceSplit>> snapshot = 
gate.snapshotReportedSplits();
+        assertThat(gate.hasReportedSplits()).isTrue();
+        snapshot.get(1).clear();
+        snapshot.clear();
+        
assertThat(gate.snapshotReportedSplits().get(1)).containsExactly(firstReport);
+
+        snapshot = gate.snapshotReportedSplits();
+        gate.recordReportedSplits(1, Collections.singletonList(secondReport));
+        assertThat(gate.drainReportedSplits().get(1))
+                .containsExactlyInAnyOrder(firstReport, secondReport);
+        assertThat(gate.snapshotReportedSplits()).isEmpty();
+        assertThat(snapshot).containsOnlyKeys(1);
+        assertThat(snapshot.get(1)).containsExactly(firstReport);
     }
 
     @Test
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/testutils/DynamicKafkaSourceEnumStateTestUtils.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/testutils/DynamicKafkaSourceEnumStateTestUtils.java
index f6038784..08feb30b 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/testutils/DynamicKafkaSourceEnumStateTestUtils.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/testutils/DynamicKafkaSourceEnumStateTestUtils.java
@@ -18,6 +18,7 @@
 
 package org.apache.flink.connector.kafka.dynamic.source.testutils;
 
+import org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumState;
 import 
org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumStateSerializer;
 
 import java.io.ByteArrayOutputStream;
@@ -50,4 +51,67 @@ public final class DynamicKafkaSourceEnumStateTestUtils {
             return baos.toByteArray();
         }
     }
+
+    public static byte[] serializeV2State(
+            String streamId,
+            String clusterId,
+            Set<String> topics,
+            String bootstrapServers,
+            KafkaSourceEnumState clusterState)
+            throws IOException {
+        KafkaSourceEnumStateSerializer kafkaSourceEnumStateSerializer =
+                new KafkaSourceEnumStateSerializer();
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                DataOutputStream out = new DataOutputStream(baos)) {
+            // kafka streams in the V2 layout
+            out.writeInt(1);
+            out.writeUTF(streamId);
+            out.writeInt(1);
+            out.writeUTF(clusterId);
+            out.writeInt(topics.size());
+            for (String topic : topics) {
+                out.writeUTF(topic);
+            }
+            out.writeUTF(bootstrapServers);
+            out.writeBoolean(false); // no starting offsets initializer
+            out.writeBoolean(false); // no stopping offsets initializer
+            out.writeInt(kafkaSourceEnumStateSerializer.getVersion());
+            // cluster enumerator states
+            out.writeInt(1);
+            out.writeUTF(clusterId);
+            
writeLengthPrefixed(kafkaSourceEnumStateSerializer.serialize(clusterState), 
out);
+            return baos.toByteArray();
+        }
+    }
+
+    public static byte[] serializeV3State(
+            String streamId,
+            String clusterId,
+            Set<String> topics,
+            String bootstrapServers,
+            KafkaSourceEnumState clusterState,
+            String retainedClusterId,
+            long retainedUntilMs,
+            KafkaSourceEnumState retainedClusterState)
+            throws IOException {
+        KafkaSourceEnumStateSerializer kafkaSourceEnumStateSerializer =
+                new KafkaSourceEnumStateSerializer();
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                DataOutputStream out = new DataOutputStream(baos)) {
+            out.write(
+                    serializeV2State(streamId, clusterId, topics, 
bootstrapServers, clusterState));
+            // retained cluster enumerator states (new in V3)
+            out.writeInt(1);
+            out.writeUTF(retainedClusterId);
+            out.writeLong(retainedUntilMs);
+            writeLengthPrefixed(
+                    
kafkaSourceEnumStateSerializer.serialize(retainedClusterState), out);
+            return baos.toByteArray();
+        }
+    }
+
+    private static void writeLengthPrefixed(byte[] bytes, DataOutputStream 
out) throws IOException {
+        out.writeInt(bytes.length);
+        out.write(bytes);
+    }
 }

Reply via email to