lnbest0707 commented on code in PR #279:
URL: 
https://github.com/apache/flink-connector-kafka/pull/279#discussion_r3530968853


##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumerator.java:
##########
@@ -748,6 +796,19 @@ public void addReader(int subtaskId) {
         // assign pending splits from the sub enumerator
         clusterEnumeratorMap.forEach(
                 (cluster, subEnumerator) -> 
subEnumerator.addReader(subtaskId));
+        if (!retainedSplitOffsetHandoffs.isEmpty()) {
+            pruneExpiredRetainedSplitOffsetHandoffs();
+            // A reader can join while a re-added cluster is waiting for 
offset handoff. Send
+            // metadata first so the reader has reconciled its local retained 
state before it
+            // answers the request. Restart the attempt so delayed responses 
from the reader's
+            // previous attempt cannot count as its replacement report.
+            retainedSplitOffsetHandoffs
+                    .values()
+                    .forEach(handoff -> handoff.offsetsByReader.clear());

Review Comment:
   Yes, a repeatedly re-registering reader restarts the handoff. This is 
intentional: if a reader is unstable, we should not start the retained cluster 
from an incomplete or stale offset snapshot. Once all readers stabilize, the 
next handoff completes.
   And normally, in such case, it usually means the slot itself is unhealthy 
and would anyway block the process.



##########
docs/content/docs/connectors/datastream/dynamic-kafka.md:
##########
@@ -242,6 +242,8 @@ By default, metadata removal also removes that cluster's 
split offsets from subs
 To keep removed cluster offsets available for a later re-add or restore, set
 `stream-metadata-removed-cluster-retention-ms` to a positive duration. For 
example,
 `604800000` retains removed cluster state for seven days before the source 
stops checkpointing it.
+If the cluster is re-added, the source uses the retained offsets but computes 
fresh reader
+assignments instead of reusing their previous owners.

Review Comment:
   good point, will add



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumerator.java:
##########
@@ -796,26 +857,303 @@ private void retainRemovedClusterEnumeratorStates(
         }
 
         long retainedUntilMs = System.currentTimeMillis() + 
removedClusterStateRetentionMs;
-        activeClusterEnumeratorStates.entrySet().stream()
-                .filter(entry -> 
!activeKafkaClusterIds.contains(entry.getKey()))
-                .forEach(
-                        entry ->
-                                retainedClusterEnumeratorStates.put(
-                                        entry.getKey(),
-                                        new 
DynamicKafkaSourceEnumState.RetainedClusterState(
-                                                entry.getValue(), 
retainedUntilMs)));
+        for (Entry<String, KafkaSourceEnumState> entry : 
activeClusterEnumeratorStates.entrySet()) {
+            if (activeKafkaClusterIds.contains(entry.getKey())) {
+                continue;
+            }
+            retainedClusterEnumeratorStates.put(
+                    entry.getKey(),
+                    new DynamicKafkaSourceEnumState.RetainedClusterState(
+                            entry.getValue(), retainedUntilMs));
+            retainedSplitOffsetHandoffs.remove(entry.getKey());
+        }
     }
 
     private void pruneExpiredRetainedClusterEnumeratorStates() {
         if (removedClusterStateRetentionMs <= 0) {
             retainedClusterEnumeratorStates.clear();
+            retainedSplitOffsetHandoffs.clear();
             return;
         }
 
         long currentTimeMillis = System.currentTimeMillis();
         retainedClusterEnumeratorStates
                 .entrySet()
                 .removeIf(entry -> entry.getValue().getRetainedUntilMs() <= 
currentTimeMillis);
+        
retainedSplitOffsetHandoffs.keySet().retainAll(retainedClusterEnumeratorStates.keySet());
+    }
+
+    private void pruneExpiredRetainedSplitOffsetHandoffs() {
+        long currentTimeMillis = System.currentTimeMillis();
+        retainedSplitOffsetHandoffs
+                .entrySet()
+                .removeIf(
+                        entry -> {
+                            RetainedSplitOffsetHandoff handoff = 
entry.getValue();
+                            if (!handoff.isExpired(currentTimeMillis)) {
+                                return false;
+                            }
+                            logger.debug(
+                                    "Discarding timed out retained split 
offset handoff for cluster {}: handoffId={}",
+                                    entry.getKey(),
+                                    handoff.handoffId);
+                            handoff.offsetsByReader.clear();
+                            return true;
+                        });
+    }
+
+    private boolean isRetainedClusterReadyForAssignment(
+            String kafkaClusterId,
+            DynamicKafkaSourceEnumState.RetainedClusterState 
retainedClusterState) {
+        Set<String> activeTopics =
+                latestClusterTopicsMap.getOrDefault(kafkaClusterId, 
Collections.emptySet());
+        return 
filterStateByTopics(retainedClusterState.getKafkaSourceEnumState(), 
activeTopics)
+                .stream()
+                .noneMatch(
+                        splitStatus ->
+                                
splitStatus.assignmentStatus().equals(AssignmentStatus.ASSIGNED));
+    }
+
+    private void startRetainedSplitOffsetHandoff(String kafkaClusterId) {
+        if (retainedSplitOffsetHandoffs.containsKey(kafkaClusterId)) {
+            return;
+        }
+
+        // Keep the attempt bounded without making a fast metadata refresh 
interval shorter than a
+        // reader source-event round trip.
+        long handoffTimeoutMs =
+                Math.max(
+                        kafkaMetadataServiceDiscoveryIntervalMs,
+                        RETAINED_SPLIT_OFFSET_HANDOFF_MIN_TIMEOUT_MS);
+        long deadlineMs = System.currentTimeMillis() + handoffTimeoutMs;
+        RetainedSplitOffsetHandoff handoff =
+                new 
RetainedSplitOffsetHandoff(++nextRetainedSplitOffsetHandoffId, deadlineMs);
+        retainedSplitOffsetHandoffs.put(kafkaClusterId, handoff);
+        scheduleRetainedSplitOffsetHandoffRetryIfNeeded();
+        for (int readerId : enumContext.registeredReaders().keySet()) {
+            sendRetainedSplitOffsetRequestToReader(kafkaClusterId, handoff, 
readerId);
+        }
+    }
+
+    private void scheduleRetainedSplitOffsetHandoffRetryIfNeeded() {
+        if (kafkaMetadataServiceDiscoveryIntervalMs > 0
+                || retainedSplitOffsetHandoffRetryScheduled) {
+            return;
+        }
+
+        // One-time metadata discovery has no later refresh to discard an 
expired handoff. Keep a
+        // single lightweight retry loop after the first handoff; it does not 
fetch metadata.
+        retainedSplitOffsetHandoffRetryScheduled = true;
+        kafkaMetadataServiceDiscoveryContext.<Void>callAsync(
+                () -> null,
+                (ignored, t) -> {
+                    if (t != null) {
+                        throw new RuntimeException("Retained split offset 
handoff retry failed", t);
+                    }
+                    pruneExpiredRetainedClusterEnumeratorStates();
+                    pruneExpiredRetainedSplitOffsetHandoffs();
+                    maybeStartReadyRetainedClusterEnumerators();
+                },
+                RETAINED_SPLIT_OFFSET_HANDOFF_MIN_TIMEOUT_MS,
+                RETAINED_SPLIT_OFFSET_HANDOFF_MIN_TIMEOUT_MS);
+    }
+
+    private void sendPendingRetainedSplitOffsetRequestsToReader(int readerId) {
+        retainedSplitOffsetHandoffs.forEach(
+                (kafkaClusterId, handoff) ->
+                        sendRetainedSplitOffsetRequestToReader(kafkaClusterId, 
handoff, readerId));
+    }
+
+    private void sendRetainedSplitOffsetRequestToReader(
+            String kafkaClusterId, RetainedSplitOffsetHandoff handoff, int 
readerId) {
+        RequestRetainedSplitOffsetsEvent requestEvent =
+                new RequestRetainedSplitOffsetsEvent(handoff.handoffId, 
kafkaClusterId);
+        logger.debug(
+                "Requesting retained split offsets from reader {}: {}", 
readerId, requestEvent);
+        enumContext.sendEventToSourceReader(readerId, requestEvent);
+    }
+
+    private void handleRetainedSplitOffsetsEvent(
+            int subtaskId, RetainedSplitOffsetsEvent 
retainedSplitOffsetsEvent) {
+        if (!enumContext.registeredReaders().containsKey(subtaskId)) {
+            logger.debug("Ignoring retained split offsets from unavailable 
reader {}", subtaskId);
+            return;
+        }
+        pruneExpiredRetainedClusterEnumeratorStates();
+        String kafkaClusterId = retainedSplitOffsetsEvent.getKafkaClusterId();
+        RetainedSplitOffsetHandoff handoff = 
retainedSplitOffsetHandoffs.get(kafkaClusterId);
+        if (handoff == null || handoff.handoffId != 
retainedSplitOffsetsEvent.getHandoffId()) {
+            logger.debug(
+                    "Ignoring stale retained split offsets from reader {}: {}",
+                    subtaskId,
+                    retainedSplitOffsetsEvent);
+            return;
+        }
+        if (handoff.isExpired(System.currentTimeMillis())) {
+            logger.debug(
+                    "Ignoring retained split offsets from timed out handoff 
{}: {}",
+                    handoff.handoffId,
+                    retainedSplitOffsetsEvent);
+            clearRetainedSplitOffsetHandoff(kafkaClusterId, handoff);
+            return;
+        }
+
+        handoff.offsetsByReader.put(subtaskId, 
retainedSplitOffsetsEvent.getRetainedSplitOffsets());
+        if (handoff.offsetsByReader.size() >= 
enumContext.currentParallelism()) {
+            applyRetainedSplitOffsetHandoff(kafkaClusterId, handoff);
+        }
+        maybeStartReadyRetainedClusterEnumerators();
+    }
+
+    private void applyRetainedSplitOffsetHandoff(
+            String kafkaClusterId, RetainedSplitOffsetHandoff handoff) {
+        DynamicKafkaSourceEnumState.RetainedClusterState retainedClusterState =
+                retainedClusterEnumeratorStates.get(kafkaClusterId);
+        if (retainedClusterState == null) {
+            clearRetainedSplitOffsetHandoff(kafkaClusterId, handoff);
+            return;
+        }
+
+        KafkaSourceEnumState kafkaSourceEnumState = 
retainedClusterState.getKafkaSourceEnumState();
+        Map<String, Long> retainedSplitOffsets = handoff.mergedOffsets();
+        Set<String> activeTopics =
+                latestClusterTopicsMap.getOrDefault(kafkaClusterId, 
Collections.emptySet());
+        Set<SplitAndAssignmentStatus> updatedSplits = new HashSet<>();
+        for (SplitAndAssignmentStatus splitStatus : 
kafkaSourceEnumState.splits()) {
+            if (!activeTopics.contains(splitStatus.split().getTopic())) {
+                updatedSplits.add(splitStatus);
+                continue;
+            }
+            if 
(splitStatus.assignmentStatus().equals(AssignmentStatus.ASSIGNED)) {
+                Long retainedSplitOffset =
+                        retainedSplitOffsets.get(
+                                toDynamicSplitId(kafkaClusterId, 
splitStatus.split()));
+                if (retainedSplitOffset == null) {
+                    // No reader retains this offset anymore; let normal 
discovery recreate it.

Review Comment:
   Good catch! Yes, your analysis is correct, it would go back to `earliest` in 
such case. And just to be clear, this branch would rarely be hit when -- topic 
and cluster are retained in the enumerator checkpoint but missed in reader 
checkpoint. This would only happen when the checkpoint is somehow corrupted or 
in extreme time mismatch between TM/JM.
   Falling completely back to `splitStatus.split()` is not safe because 
enumerator state only contains the original assigned starting offset, not the 
reader’s latest checkpointed progress. I’ll change this branch to keep the 
partition as an uninitialized/MIGRATED unassigned split so 
KafkaSourceEnumerator resolves it through the configured starting-offset 
initializer.
   Once with the above source side patch, it should not be conflict with the 
sink behavior.
   
   For the similar corner case, if reader has the retained offsets in 
checkpoint but enumerator does not have it, we would treat then as "new" 
cluster and use initializer to determine the starting point. That behavior 
should be correct as we should already pass the TTL in such case.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to