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 9d114deb [FLINK-40710][Connectors/Kafka] Preserve Dynamic Kafka
partition ownership during reader recovery (#323)
9d114deb is described below
commit 9d114deb30c6bfe7f074f657ef3ef964b028d7e2
Author: bowenli86 <[email protected]>
AuthorDate: Thu Oct 22 19:22:15 2026 -0700
[FLINK-40710][Connectors/Kafka] Preserve Dynamic Kafka partition ownership
during reader recovery (#323)
* [FLINK-40710][Connectors/Kafka] Preserve Dynamic Kafka partition
ownership during reader recovery
Use cluster and topic-partition keys for current ownership so retained
splits cannot collide with active partitions from another cluster. Batch
initial recovery returns once per cluster to avoid quadratic pending scans.
---
.../c0d94764-76a0-4c50-b617-70b1754c4612 | 1 -
.../enumerator/DynamicKafkaSourceEnumerator.java | 177 +++-
.../source/enumerator/ReaderRecoveryGate.java | 75 +-
.../enumerator/StoppableKafkaEnumContextProxy.java | 20 +-
.../source/enumerator/KafkaSourceEnumerator.java | 34 +-
.../DynamicKafkaSourceEnumeratorRecoveryTest.java | 1087 ++++++++++++++++++++
.../DynamicKafkaSourceEnumeratorTest.java | 11 +-
.../source/enumerator/ReaderRecoveryGateTest.java | 102 +-
...ceEnumeratorPendingOwnershipRegressionTest.java | 149 +++
...aSourceEnumeratorRecoveryDiscoveryRaceTest.java | 230 +++++
10 files changed, 1804 insertions(+), 82 deletions(-)
diff --git
a/flink-connector-kafka/archunit-violations/c0d94764-76a0-4c50-b617-70b1754c4612
b/flink-connector-kafka/archunit-violations/c0d94764-76a0-4c50-b617-70b1754c4612
index 0a64cb1b..2429b474 100644
---
a/flink-connector-kafka/archunit-violations/c0d94764-76a0-4c50-b617-70b1754c4612
+++
b/flink-connector-kafka/archunit-violations/c0d94764-76a0-4c50-b617-70b1754c4612
@@ -46,7 +46,6 @@ Method
<org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumStateS
Method
<org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumerator.deepCopyProperties(java.util.Properties,
java.util.Properties)> is annotated with
<org.apache.flink.annotation.VisibleForTesting> in
(KafkaSourceEnumerator.java:0)
Method
<org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumerator.getPartitionChange(java.util.Set,
boolean)> is annotated with <org.apache.flink.annotation.VisibleForTesting> in
(KafkaSourceEnumerator.java:0)
Method
<org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumerator.getPendingPartitionSplitAssignment()>
is annotated with <org.apache.flink.annotation.VisibleForTesting> in
(KafkaSourceEnumerator.java:0)
-Method
<org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumerator.getSplitOwner(org.apache.kafka.common.TopicPartition,
int)> is annotated with <org.apache.flink.annotation.VisibleForTesting> in
(KafkaSourceEnumerator.java:0)
Method
<org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumerator.topicIntegrityCheckEnabled()>
is annotated with <org.apache.flink.annotation.VisibleForTesting> in
(KafkaSourceEnumerator.java:0)
Method
<org.apache.flink.connector.kafka.source.reader.KafkaPartitionSplitReader.consumer()>
is annotated with <org.apache.flink.annotation.VisibleForTesting> in
(KafkaPartitionSplitReader.java:0)
Method
<org.apache.flink.connector.kafka.source.reader.KafkaPartitionSplitReader.getPollTimeout()>
is annotated with <org.apache.flink.annotation.VisibleForTesting> in
(KafkaPartitionSplitReader.java:0)
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 60a2fe9b..487a7400 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
@@ -26,6 +26,7 @@ import org.apache.flink.api.connector.source.SourceEvent;
import org.apache.flink.api.connector.source.SplitEnumerator;
import org.apache.flink.api.connector.source.SplitEnumeratorContext;
import org.apache.flink.api.connector.source.SplitsAssignment;
+import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.connector.kafka.dynamic.metadata.ClusterMetadata;
import org.apache.flink.connector.kafka.dynamic.metadata.KafkaMetadataService;
import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
@@ -47,6 +48,7 @@ import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.OffsetResetStrategy;
import org.apache.kafka.common.KafkaException;
+import org.apache.kafka.common.TopicPartition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -117,6 +119,8 @@ public class DynamicKafkaSourceEnumerator
private final ReaderRecoveryGate readerRecoveryGate;
private final Set<Integer> readersWithNoMoreSplits = new HashSet<>();
private boolean splitAssignmentInProgress;
+ // Reader checkpoint reports may refer to owners from before recovery
redistribution.
+ private final Map<Tuple2<String, TopicPartition>, Integer>
currentSplitOwners = new HashMap<>();
public DynamicKafkaSourceEnumerator(
KafkaStreamSubscriber kafkaStreamSubscriber,
@@ -413,7 +417,7 @@ public class DynamicKafkaSourceEnumerator
private void handleNoMoreSplits() {
// A cluster callback may run before other clusters have assigned
their splits.
- if (splitAssignmentInProgress ||
readerRecoveryGate.hasPendingRecovery()) {
+ if (splitAssignmentInProgress ||
readerRecoveryGate.isReconciliationPending()) {
return;
}
if (Boundedness.BOUNDED.equals(boundedness)) {
@@ -520,6 +524,7 @@ public class DynamicKafkaSourceEnumerator
// create enumerators
Set<String> activeSplitIds = new HashSet<>();
+ Set<Tuple2<String, TopicPartition>> knownPartitions = new HashSet<>();
for (Entry<String, Set<String>> activeClusterTopics :
latestClusterTopicsMap.entrySet()) {
String kafkaClusterId = activeClusterTopics.getKey();
KafkaSourceEnumState kafkaSourceEnumState =
@@ -539,9 +544,14 @@ public class DynamicKafkaSourceEnumerator
Set<SplitAndAssignmentStatus> partitions =
filterStateByTopics(kafkaSourceEnumState,
activeClusterTopics.getValue());
partitions.forEach(
- splitStatus ->
- activeSplitIds.add(
- toDynamicSplitId(kafkaClusterId,
splitStatus.split())));
+ splitStatus -> {
+ activeSplitIds.add(
+ toDynamicSplitId(kafkaClusterId,
splitStatus.split()));
+ knownPartitions.add(
+ Tuple2.of(
+ kafkaClusterId,
+
splitStatus.split().getTopicPartition()));
+ });
newKafkaSourceEnumState =
new KafkaSourceEnumState(
@@ -564,6 +574,21 @@ public class DynamicKafkaSourceEnumerator
}
splitAssignmentStrategy.onMetadataRefresh(activeSplitIds);
+ // Readers retain removed-cluster splits and reactivate them without a
new assignment.
+ retainedClusterEnumeratorStates.forEach(
+ (clusterId, retainedState) ->
+ retainedState
+ .getKafkaSourceEnumState()
+ .splits()
+ .forEach(
+ splitStatus ->
+ knownPartitions.add(
+ Tuple2.of(
+ clusterId,
+ splitStatus
+
.split()
+
.getTopicPartition()))));
+ currentSplitOwners.keySet().retainAll(knownPartitions);
startAllEnumerators();
tryCompletePendingReaderRegistration();
}
@@ -638,8 +663,21 @@ public class DynamicKafkaSourceEnumerator
kafkaClusterId,
kafkaMetadataService,
signalNoMoreSplitsCallback);
- KafkaSourceEnumerator.SplitOwnerSelector splitOwnerSelector =
+
context.setReaderReadyForAssignment(readerRecoveryGate::isReaderReadyForAssignment);
+ context.setSplitAssignmentListener(
+ (reader, split) ->
currentSplitOwners.put(toPartitionKey(split), reader));
+ KafkaSourceEnumerator.SplitOwnerSelector strategyOwnerSelector =
splitAssignmentStrategy.createSplitOwnerSelector(kafkaClusterId);
+ KafkaSourceEnumerator.SplitOwnerSelector splitOwnerSelector =
+ (split, numReaders) ->
+ currentSplitOwners.computeIfAbsent(
+ Tuple2.of(kafkaClusterId,
split.getTopicPartition()),
+ ignored ->
+ strategyOwnerSelector == null
+ ?
KafkaSourceEnumerator.getSplitOwner(
+
split.getTopicPartition(), numReaders)
+ :
strategyOwnerSelector.getSplitOwner(
+ split, numReaders));
SplitEnumeratorContext<KafkaPartitionSplit> assignmentContext =
splitAssignmentStrategy.createAssignmentContext(kafkaClusterId, context);
@@ -759,10 +797,26 @@ public class DynamicKafkaSourceEnumerator
@Override
public void addSplitsBack(List<DynamicKafkaSourceSplit> splits, int
subtaskId) {
logger.debug("Adding splits back for {}", subtaskId);
+ List<DynamicKafkaSourceSplit> ownedSplits =
+ splits.stream()
+ .filter(split -> isCurrentSplitOwner(split, subtaskId))
+ .collect(Collectors.toList());
+ List<DynamicKafkaSourceSplit> returnedRetainedSplits = new
ArrayList<>();
+ long currentTimeMillis = System.currentTimeMillis();
+ for (DynamicKafkaSourceSplit split : ownedSplits) {
+ if (!isClusterActive(split.getKafkaClusterId())) {
+ DynamicKafkaSourceSplit retainedSplit =
+ getRetainedReportedSplit(split, currentTimeMillis);
+ if (retainedSplit != null) {
+ returnedRetainedSplits.add(retainedSplit);
+ }
+ }
+ }
+ readerRecoveryGate.recordReturnedSplits(subtaskId,
returnedRetainedSplits);
runWithSplitAssignmentInProgress(
() -> {
- splitAssignmentStrategy.onSplitsBack(splits, subtaskId);
- addSplitsBackToClusterEnumerators(splits, subtaskId,
false);
+ splitAssignmentStrategy.onSplitsBack(ownedSplits,
subtaskId);
+ addSplitsBackToClusterEnumerators(ownedSplits, subtaskId,
false);
});
handleNoMoreSplits();
}
@@ -803,17 +857,24 @@ public class DynamicKafkaSourceEnumerator
public void addReader(int subtaskId) {
logger.debug("Adding reader {}", subtaskId);
readersWithNoMoreSplits.remove(subtaskId);
+ readerRecoveryGate.startReaderRegistration(subtaskId);
ReaderInfo readerInfo = enumContext.registeredReaders().get(subtaskId);
- if (readerInfo != null) {
- readerRecoveryGate.recordReportedSplits(
- subtaskId, readerInfo.getReportedSplitsOnRegistration());
- }
+ List<DynamicKafkaSourceSplit> restoredSplits =
+ readerInfo == null
+ ? Collections.emptyList()
+ : readerInfo.getReportedSplitsOnRegistration();
+ readerRecoveryGate.recordReportedSplits(subtaskId, restoredSplits);
if (tryCompletePendingReaderRegistration()) {
return;
}
- runWithSplitAssignmentInProgress(() ->
addReaderToClusterEnumerators(subtaskId));
+ runWithSplitAssignmentInProgress(
+ () -> {
+ reassignReportedSplitsFromRestartedReader(subtaskId,
restoredSplits);
+ readerRecoveryGate.completeReaderRegistration(subtaskId);
+ addReaderToClusterEnumerators(subtaskId);
+ });
handleNoMoreSplits();
}
@@ -828,12 +889,13 @@ public class DynamicKafkaSourceEnumerator
// Draining the gate clears its pending state before reassignment
finishes.
runWithSplitAssignmentInProgress(
() -> {
- readerRecoveryGate.markInitialRegistrationComplete();
if (readerRecoveryGate.hasReportedSplits()) {
reassignReportedSplits();
- } else {
- flushPendingSplitAssignmentsForRegisteredReaders();
}
+ // Returned splits must replace pending copies before any
child can flush
+ // assignments.
+ readerRecoveryGate.markInitialRegistrationComplete();
+ flushPendingSplitAssignmentsForRegisteredReaders();
});
handleNoMoreSplits();
flushPendingMetadataUpdateEvents();
@@ -907,21 +969,87 @@ public class DynamicKafkaSourceEnumerator
.map(reportedSplit -> reportedSplit.split)
.collect(Collectors.toList());
splitAssignmentStrategy.onRecoveredSplits(activeSplits,
enumContext.currentParallelism());
+ activeSplits.forEach(split ->
currentSplitOwners.remove(toPartitionKey(split)));
+
+ if (!activeSplits.isEmpty()) {
+ // With the initial gate closed, registeredReaders() is empty, so
the reader ID
+ // has no effect here. The flush after reconciliation covers all
readers. Enqueue
+ // one batch per cluster to avoid scanning pending copies once per
reported split.
+ int reportingReader =
activeReportedSplits.values().iterator().next().readerId;
+ addSplitsBackToClusterEnumerators(activeSplits, reportingReader,
true);
+ }
+
+ assignRetainedSplits(retainedSplitsByReader);
+ }
- for (ReportedSplit reportedSplit : activeReportedSplits.values()) {
- addSplitsBackToClusterEnumerators(
- Collections.singletonList(reportedSplit.split),
reportedSplit.readerId, true);
+ private void reassignReportedSplitsFromRestartedReader(
+ int readerId, List<DynamicKafkaSourceSplit> restoredSplits) {
+ List<DynamicKafkaSourceSplit> activeSplits = new ArrayList<>();
+ List<DynamicKafkaSourceSplit> retainedSplits = new ArrayList<>();
+ long currentTimeMillis = System.currentTimeMillis();
+ Map<Tuple2<String, TopicPartition>, DynamicKafkaSourceSplit>
reportedSplits =
+ new HashMap<>();
+ restoredSplits.forEach(split ->
reportedSplits.put(toPartitionKey(split), split));
+ // Returned assignments may have moved to this reader since its
restored checkpoint.
+ readerRecoveryGate
+ .drainReportedSplits(readerId)
+ .forEach(split -> reportedSplits.put(toPartitionKey(split),
split));
+ List<DynamicKafkaSourceSplit> orderedSplits = new
ArrayList<>(reportedSplits.values());
+ orderedSplits.sort(
+ Comparator.comparing(DynamicKafkaSourceSplit::splitId)
+
.thenComparing(DynamicKafkaSourceSplit::getKafkaClusterId));
+ for (DynamicKafkaSourceSplit split : orderedSplits) {
+ if (!isCurrentSplitOwner(split, readerId)) {
+ continue;
+ }
+ if (isSplitActive(split)) {
+ // Active metadata also reactivates retained reader reports.
Reconcile their
+ // physical ownership before sending a historical shadow back
to a reader.
+ activeSplits.add(split.clearRetention());
+ } else {
+ DynamicKafkaSourceSplit retainedSplit =
+ getRetainedReportedSplit(split, currentTimeMillis);
+ if (retainedSplit != null) {
+ retainedSplits.add(retainedSplit);
+ }
+ }
}
+ splitAssignmentStrategy.onSplitsBack(activeSplits, readerId);
+ addSplitsBackToClusterEnumerators(activeSplits, readerId, true);
+ if (!retainedSplits.isEmpty()) {
+ assignRetainedSplits(Collections.singletonMap(readerId,
retainedSplits));
+ }
+ }
- flushPendingSplitAssignmentsForRegisteredReaders();
+ private void assignRetainedSplits(Map<Integer,
List<DynamicKafkaSourceSplit>> splitsByReader) {
+ if (splitsByReader.isEmpty()) {
+ return;
+ }
+ // Direct retained assignments bypass the sub-enumerator's assignment
listener.
+ splitsByReader.forEach(
+ (reader, splits) ->
+ splits.forEach(
+ split ->
currentSplitOwners.put(toPartitionKey(split), reader)));
+ enumContext.assignSplits(new SplitsAssignment<>(splitsByReader));
+ }
- if (!retainedSplitsByReader.isEmpty()) {
- enumContext.assignSplits(new
SplitsAssignment<>(retainedSplitsByReader));
+ private boolean isCurrentSplitOwner(DynamicKafkaSourceSplit split, int
readerId) {
+ Integer currentOwner = currentSplitOwners.get(toPartitionKey(split));
+ if (currentOwner == null || currentOwner == readerId) {
+ return true;
}
+ // Historical ownership is not a transfer request. The current owner's
returned
+ // assignments or its checkpoint report restore the split at the
correct position.
+ logger.debug(
+ "Ignoring split {} from historical reader {}; current owner is
{}",
+ split.splitId(),
+ readerId,
+ currentOwner);
+ return false;
}
private boolean shouldDeferMetadataUpdateEvents() {
- return
readerRecoveryGate.shouldDeferMetadataUpdateEvents(allReadersRegistered());
+ return readerRecoveryGate.shouldDeferMetadataUpdateEvents();
}
private void flushPendingMetadataUpdateEvents() {
@@ -1150,6 +1278,11 @@ public class DynamicKafkaSourceEnumerator
}
}
+ private static Tuple2<String, TopicPartition>
toPartitionKey(DynamicKafkaSourceSplit split) {
+ return Tuple2.of(
+ split.getKafkaClusterId(),
split.getKafkaPartitionSplit().getTopicPartition());
+ }
+
private static String toDynamicSplitId(String kafkaClusterId,
KafkaPartitionSplit split) {
return kafkaClusterId + "-" + split.splitId();
}
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 14bc6930..4cc8656e 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
@@ -38,19 +38,9 @@ import java.util.TreeMap;
/**
* Tracks the recovery-time reader registration state of the {@link
DynamicKafkaSourceEnumerator}.
*
- * <p>The gate is armed by two recovery triggers:
- *
- * <ul>
- * <li><b>Enumerator restore from checkpoint</b>: armed at construction
({@code
- * restoredFromCheckpoint = true}). Split assignment and metadata update
events must be
- * deferred until the first metadata discovery has completed and every
reader has
- * (re-)registered, so that restored reader splits can be redistributed
consistently.
- * <li><b>Reader re-registration after partial failover</b>: armed on a
running enumerator when a
- * re-registering reader reports checkpointed splits ({@link
#recordReportedSplits} with
- * non-empty splits while initial reader registration is already
complete). Metadata update
- * events are deferred until all readers have registered again and
reported splits are
- * redistributed.
- * </ul>
+ * <p>After enumerator restore, assignments and metadata updates wait for the
complete reader cohort
+ * and the first metadata discovery. A local reader restart only waits for
that reader's checkpoint
+ * report to be reconciled with the surviving enumerator's current ownership.
*
* <p>This class owns that gating state; the enumerator remains responsible
for acting on it.
*
@@ -64,10 +54,13 @@ class ReaderRecoveryGate {
/** Set on restore; cleared once all readers have registered after the
first discovery. */
private boolean initialReaderRegistrationPending;
- /** Splits reported by readers on registration, pending redistribution. */
+ /** Reported or returned splits awaiting recovery reassignment. */
private final Map<Integer, List<DynamicKafkaSourceSplit>>
pendingReportedSplitsByReader =
new HashMap<>();
+ /** Readers whose checkpoint reports are being reconciled before pending
assignments flush. */
+ private final Set<Integer> readersAwaitingSplitReconciliation = new
HashSet<>();
+
/** Readers whose metadata update events were deferred during recovery. */
private final Set<Integer> pendingMetadataUpdateReaders = new HashSet<>();
@@ -75,9 +68,9 @@ class ReaderRecoveryGate {
this.initialReaderRegistrationPending = restoredFromCheckpoint;
}
- /** Merges a reader's registration report with pending splits; an empty
report is ignored. */
+ /** Merges non-empty initial recovery reports with pending splits; empty
reports are ignored. */
void recordReportedSplits(int subtaskId, List<DynamicKafkaSourceSplit>
reportedSplits) {
- if (!reportedSplits.isEmpty()) {
+ if (initialReaderRegistrationPending && !reportedSplits.isEmpty()) {
pendingReportedSplitsByReader.put(
subtaskId,
mergeReportedSplits(
@@ -85,6 +78,16 @@ class ReaderRecoveryGate {
}
}
+ /** Retains splits returned while their reader is inactive until recovery
reassignment. */
+ void recordReturnedSplits(int readerId, List<DynamicKafkaSourceSplit>
returnedSplits) {
+ if (!returnedSplits.isEmpty()) {
+ pendingReportedSplitsByReader.put(
+ readerId,
+ mergeReportedSplits(
+ pendingReportedSplitsByReader.get(readerId),
returnedSplits));
+ }
+ }
+
/** Returns a copy of the pending reports without draining them. */
Map<Integer, List<DynamicKafkaSourceSplit>> snapshotReportedSplits() {
Map<Integer, List<DynamicKafkaSourceSplit>> snapshot = new HashMap<>();
@@ -116,16 +119,12 @@ class ReaderRecoveryGate {
/** Whether recovery gating is active and registrations must be deferred.
*/
boolean hasPendingRecovery() {
- return initialReaderRegistrationPending ||
!pendingReportedSplitsByReader.isEmpty();
+ return initialReaderRegistrationPending;
}
- /**
- * Whether metadata update events must be deferred instead of sent, given
the current reader
- * registration completeness.
- */
- boolean shouldDeferMetadataUpdateEvents(boolean allReadersRegistered) {
- return initialReaderRegistrationPending
- || (!pendingReportedSplitsByReader.isEmpty() &&
!allReadersRegistered);
+ /** Whether metadata updates must wait for checkpoint report
reconciliation. */
+ boolean shouldDeferMetadataUpdateEvents() {
+ return initialReaderRegistrationPending ||
!readersAwaitingSplitReconciliation.isEmpty();
}
void deferMetadataUpdate(int readerId) {
@@ -144,19 +143,43 @@ class ReaderRecoveryGate {
return readers;
}
+ void startReaderRegistration(int readerId) {
+ readersAwaitingSplitReconciliation.add(readerId);
+ }
+
+ void completeReaderRegistration(int readerId) {
+ readersAwaitingSplitReconciliation.remove(readerId);
+ }
+
+ boolean isReaderReadyForAssignment(int readerId) {
+ return !initialReaderRegistrationPending
+ && !readersAwaitingSplitReconciliation.contains(readerId);
+ }
+
+ boolean isReconciliationPending() {
+ return initialReaderRegistrationPending ||
!readersAwaitingSplitReconciliation.isEmpty();
+ }
+
void markInitialRegistrationComplete() {
initialReaderRegistrationPending = false;
+ readersAwaitingSplitReconciliation.clear();
}
boolean hasReportedSplits() {
return !pendingReportedSplitsByReader.isEmpty();
}
+ /** Returns and removes only the pending splits for the recovering reader.
*/
+ List<DynamicKafkaSourceSplit> drainReportedSplits(int readerId) {
+ List<DynamicKafkaSourceSplit> reportedSplits =
+ pendingReportedSplitsByReader.remove(readerId);
+ return reportedSplits == null ? Collections.emptyList() :
reportedSplits;
+ }
+
/**
* Returns the reported splits ordered by reader id and clears the pending
state.
*
- * <p>Note: the pending state is cleared eagerly, so the gate must not be
consulted for pending
- * reported splits while reassigning (e.g. from {@code
handleNoMoreSplits}).
+ * <p>The initial registration gate remains closed while these reports are
reassigned.
*/
NavigableMap<Integer, List<DynamicKafkaSourceSplit>> drainReportedSplits()
{
NavigableMap<Integer, List<DynamicKafkaSourceSplit>>
reportedSplitsByReader =
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
index 89843837..a591b322 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/StoppableKafkaEnumContextProxy.java
@@ -46,6 +46,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
+import java.util.function.IntPredicate;
import java.util.stream.Collectors;
/**
@@ -74,6 +75,9 @@ public class StoppableKafkaEnumContextProxy
private final Runnable signalNoMoreSplitsCallback;
private boolean noMoreSplits = false;
private volatile boolean isClosing;
+ private IntPredicate readerReadyForAssignment = reader -> true;
+ private BiConsumer<Integer, DynamicKafkaSourceSplit>
splitAssignmentListener =
+ (reader, split) -> {};
/**
* Constructor for the enumerator context.
@@ -116,7 +120,18 @@ public class StoppableKafkaEnumContextProxy
@Override
public Map<Integer, ReaderInfo> registeredReaders() {
- return enumContext.registeredReaders();
+ return enumContext.registeredReaders().entrySet().stream()
+ .filter(entry -> readerReadyForAssignment.test(entry.getKey()))
+ .collect(Collectors.toMap(Map.Entry::getKey,
Map.Entry::getValue));
+ }
+
+ void setReaderReadyForAssignment(IntPredicate readerReadyForAssignment) {
+ this.readerReadyForAssignment = readerReadyForAssignment;
+ }
+
+ void setSplitAssignmentListener(
+ BiConsumer<Integer, DynamicKafkaSourceSplit>
splitAssignmentListener) {
+ this.splitAssignmentListener = splitAssignmentListener;
}
/** Wrap splits with cluster metadata. */
@@ -147,6 +162,9 @@ public class StoppableKafkaEnumContextProxy
.collect(Collectors.toList())));
if (!readerToSplitsMap.isEmpty()) {
+ readerToSplitsMap.forEach(
+ (reader, splits) ->
+ splits.forEach(split ->
splitAssignmentListener.accept(reader, split)));
enumContext.assignSplits(new
SplitsAssignment<>(readerToSplitsMap));
}
}
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumerator.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumerator.java
index 2f936706..e66a73c3 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumerator.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumerator.java
@@ -459,12 +459,23 @@ public class KafkaSourceEnumerator
LOG.debug("Partition discovery is disabled.");
noMoreNewPartitionSplits = true;
}
+ List<KafkaPartitionSplit> newPartitionSplits = new ArrayList<>();
for (KafkaPartitionSplit split :
partitionSplitChange.newPartitionSplits) {
- unassignedSplits.put(split.getTopicPartition(), split);
+ TopicPartition partition = split.getTopicPartition();
+ KafkaPartitionSplit pendingSplit = unassignedSplits.get(partition);
+ // A checkpoint split can arrive while initialization runs on a
worker. Its
+ // position takes precedence over a late discovery result; only
MIGRATED needs init.
+ if (assignedSplits.containsKey(partition)
+ || (pendingSplit != null && !pendingSplit.isMigrated())) {
+ LOG.debug("Ignoring stale initialization for known partition
{}", partition);
+ continue;
+ }
+ unassignedSplits.put(partition, split);
+ newPartitionSplits.add(split);
}
LOG.info("Partition split changes: {}", partitionSplitChange);
// TODO: Handle removed partitions.
-
addPartitionSplitChangeToPendingAssignments(partitionSplitChange.newPartitionSplits);
+ addPartitionSplitChangeToPendingAssignments(newPartitionSplits);
assignPendingPartitionSplits(context.registeredReaders().keySet());
}
@@ -472,7 +483,21 @@ public class KafkaSourceEnumerator
private void addPartitionSplitChangeToPendingAssignments(
Collection<KafkaPartitionSplit> newPartitionSplits) {
int numReaders = context.currentParallelism();
- List<KafkaPartitionSplit> sortedSplits = new
ArrayList<>(newPartitionSplits);
+ Map<TopicPartition, KafkaPartitionSplit> splitsByPartition = new
HashMap<>();
+ newPartitionSplits.forEach(
+ split -> splitsByPartition.put(split.getTopicPartition(),
split));
+ // Split equality includes offsets. Replace pending physical copies
across all owners
+ // once per batch; if a batch repeats a partition, its last supplied
position wins.
+ pendingPartitionSplitAssignment
+ .values()
+ .forEach(
+ pending ->
+ pending.removeIf(
+ previous ->
+ splitsByPartition.containsKey(
+
previous.getTopicPartition())));
+ pendingPartitionSplitAssignment.values().removeIf(Set::isEmpty);
+ List<KafkaPartitionSplit> sortedSplits = new
ArrayList<>(splitsByPartition.values());
sortedSplits.sort(
Comparator.comparing(
(KafkaPartitionSplit split) ->
split.getTopicPartition().topic())
@@ -626,8 +651,7 @@ public class KafkaSourceEnumerator
* @param numReaders the total number of readers.
* @return the id of the subtask that owns the split.
*/
- @VisibleForTesting
- static int getSplitOwner(TopicPartition tp, int numReaders) {
+ public static int getSplitOwner(TopicPartition tp, int numReaders) {
int startIndex = ((tp.topic().hashCode() * 31) & 0x7FFFFFFF) %
numReaders;
// here, the assumption is that the id of Kafka partitions are always
ascending
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 88adb63f..d20c2116 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
@@ -35,15 +35,23 @@ import
org.apache.flink.connector.kafka.dynamic.source.enumerator.subscriber.Kaf
import
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
import org.apache.flink.connector.kafka.source.KafkaSourceOptions;
import org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumState;
+import
org.apache.flink.connector.kafka.source.enumerator.KafkaSourceEnumerator;
import
org.apache.flink.connector.kafka.source.enumerator.initializer.NoStoppingOffsetsInitializer;
import
org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
import org.apache.flink.connector.kafka.testutils.MockKafkaMetadataService;
+import org.apache.flink.mock.Whitebox;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.common.TopicPartition;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -54,6 +62,8 @@ import java.util.Properties;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -624,6 +634,1083 @@ public class DynamicKafkaSourceEnumeratorRecoveryTest {
}
}
+ @Test
+ public void
testLocalFailureBeforeInitialDiscoveryPreservesReportedSplits() throws
Throwable {
+ String streamId = "stream";
+ String clusterId = "cluster-0";
+ String topic = "topic";
+ List<DynamicKafkaSourceSplit> checkpointSplits =
+ List.of(
+ new DynamicKafkaSourceSplit(
+ clusterId,
+ new KafkaPartitionSplit(new
TopicPartition(topic, 0), 42L)),
+ new DynamicKafkaSourceSplit(
+ clusterId,
+ new KafkaPartitionSplit(new
TopicPartition(topic, 1), 43L)));
+ KafkaStream stream = createKafkaStream(streamId, clusterId, topic);
+
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(2);
+ DynamicKafkaSourceEnumerator enumerator =
+ createEnumerator(
+ streamId,
+ new
MockKafkaMetadataService(Collections.singleton(stream)),
+ context,
+ createGlobalModeProperties(),
+ createRestoredState(stream, clusterId,
checkpointSplits))) {
+ enumerator.start();
+ registerReaderAndReportRestoredSplits(context, enumerator, 0,
checkpointSplits);
+ registerReaderAndReportRestoredSplits(context, enumerator, 1,
Collections.emptyList());
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+
+ // No post-checkpoint assignment exists to return before metadata
discovery completes.
+ context.unregisterReader(0);
+ enumerator.addSplitsBack(Collections.emptyList(), 0);
+ registerReaderAndReportRestoredSplits(context, enumerator, 0,
Collections.emptyList());
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+
+ context.runNextOneTimeCallable();
+
+ assertExactCheckpointOffsets(context, 0, checkpointSplits);
+ assertSinglePhysicalOwners(context, 0, checkpointSplits.size());
+ }
+ }
+
+ @Test
+ public void testInitialRestoreKeepsOneOwnerBeforeAnyLocalFailure() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, false);
+ }
+
+ @Test
+ public void testInitialRestoreDiscoveryBeforeRegistrationKeepsOneOwner()
throws Throwable {
+ verifyNumericCheckpointRecovery(true, false);
+ }
+
+ @Test
+ public void testLocalFailureBeforeNewCheckpointKeepsOneOwner() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, true);
+ }
+
+ @Test
+ public void testLocalFailureWithReverseRegistrationKeepsOneOwner() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, true, 3, true, false, false,
false);
+ }
+
+ @Test
+ public void testLocalFailureWithInterleavedReturnsKeepsOneOwner() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, true, 3, true, true, false,
false);
+ }
+
+ @Test
+ public void testOnlyReaderZeroFailsWithoutMovingSurvivingSplits() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, true, 1, false, false, false,
false);
+ }
+
+ @Test
+ public void testOnlyReaderOneFailsWithoutMovingSurvivingSplits() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, true, 2, false, false, false,
false);
+ }
+
+ @Test
+ public void
testPerClusterLocalFailureDoesNotReclaimSurvivingReaderSplits() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, true, 1, false, false, false,
false, "per_cluster");
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 2})
+ public void
testLocalFailureOfReactivatedRetainedSplitsKeepsCurrentOwners(int
failedReaderMask)
+ throws Throwable {
+ verifyNumericCheckpointRecovery(
+ false, true, failedReaderMask, false, false, false, false,
"global", true);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ public void
testPerClusterLocalFailureAfterRetainedClusterReactivationKeepsRestoredOwner(
+ boolean bounded) throws Throwable {
+ String clusterId = "cluster-0";
+ KafkaStream inactiveStream = new KafkaStream("stream",
Collections.emptyMap());
+ KafkaStream activeStream = createKafkaStream("stream", clusterId,
"topic");
+ // After rescaling, the retained reader can differ from the
per-cluster hash owner.
+ DynamicKafkaSourceSplit checkpointSplit =
+ new DynamicKafkaSourceSplit(
+ clusterId,
+ new KafkaPartitionSplit(new TopicPartition("topic",
0), 123L, 1000L));
+ DynamicKafkaSourceSplit retainedSplit =
checkpointSplit.retainUntil(Long.MAX_VALUE);
+ assertThat(
+ KafkaSourceEnumerator.getSplitOwner(
+
checkpointSplit.getKafkaPartitionSplit().getTopicPartition(), 2))
+ .isEqualTo(1);
+
+ Properties properties = createBaseProperties();
+ properties.setProperty(
+ DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE.key(),
"per_cluster");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "1");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+ "60000");
+ DynamicKafkaSourceEnumState restoredState =
+ new DynamicKafkaSourceEnumState(
+ Collections.singleton(inactiveStream),
+ Collections.emptyMap(),
+ Collections.singletonMap(
+ clusterId,
+ new
DynamicKafkaSourceEnumState.RetainedClusterState(
+ new KafkaSourceEnumState(
+
List.of(checkpointSplit.getKafkaPartitionSplit()),
+ Collections.emptyList(),
+ true),
+ Long.MAX_VALUE)),
+ Collections.emptyMap());
+ List<String> events = new ArrayList<>();
+ try (MockKafkaMetadataService metadataService =
+ new
MockKafkaMetadataService(Collections.singleton(inactiveStream));
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new
MockSplitEnumeratorContext<DynamicKafkaSourceSplit>(2) {
+ @Override
+ public void assignSplits(
+ SplitsAssignment<DynamicKafkaSourceSplit>
assignment) {
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+ splits.forEach(
+ split ->
+
events.add(
+
"assign:"
+
+ reader
+
+ ":"
+
+ split
+
.splitId())));
+ super.assignSplits(assignment);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int reader) {
+ events.add("done:" + reader);
+ super.signalNoMoreSplits(reader);
+ }
+ };
+ DynamicKafkaSourceEnumerator enumerator =
+ new DynamicKafkaSourceEnumerator(
+ new
KafkaStreamSetSubscriber(Collections.singleton("stream")),
+ metadataService,
+ context,
+ OffsetsInitializer.earliest(),
+ bounded
+ ? OffsetsInitializer.latest()
+ : new NoStoppingOffsetsInitializer(),
+ properties,
+ bounded ? Boundedness.BOUNDED :
Boundedness.CONTINUOUS_UNBOUNDED,
+ restoredState,
+ new NoOpKafkaEnumContextProxyFactory())) {
+ enumerator.start();
+ registerReaderAndReportRestoredSplits(context, enumerator, 0,
List.of(retainedSplit));
+ registerReaderAndReportRestoredSplits(context, enumerator, 1,
Collections.emptyList());
+ context.runPeriodicCallable(0);
+
+ assertExactCheckpointOffsets(context, 0, List.of(retainedSplit));
+ context.getSplitsAssignmentSequence()
+ .forEach(assignment ->
assertThat(assignment.assignment()).containsOnlyKeys(0));
+ if (bounded) {
+ assertBoundedAssignmentsBeforeCompletion(events, 0, 1);
+ assertThat(events.stream().filter(event ->
event.equals("done:0"))).hasSize(1);
+ assertThat(events.stream().filter(event ->
event.equals("done:1"))).hasSize(1);
+ }
+
+ int assignmentsBeforeReactivation =
context.getSplitsAssignmentSequence().size();
+
metadataService.setKafkaStreams(Collections.singleton(activeStream));
+ context.runPeriodicCallable(0);
+ KafkaSourceEnumerator subEnumerator =
+ (KafkaSourceEnumerator)
+ ((Map<?, ?>)
+ Whitebox.getInternalState(
+ enumerator,
"clusterEnumeratorMap"))
+ .get(clusterId);
+ completePartitionDiscovery(subEnumerator);
+ assertThat(context.getSplitsAssignmentSequence())
+ .hasSize(assignmentsBeforeReactivation);
+
+ for (int attempt = 0; attempt < 2; attempt++) {
+ events.clear();
+ int priorAssignments =
context.getSplitsAssignmentSequence().size();
+ context.unregisterReader(0);
+ enumerator.addSplitsBack(List.of(checkpointSplit), 0);
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, 0, List.of(retainedSplit));
+
+ assertExactCheckpointOffsets(context, priorAssignments,
List.of(checkpointSplit));
+ context.getSplitsAssignmentSequence().stream()
+ .skip(priorAssignments)
+ .forEach(
+ assignment ->
+
assertThat(assignment.assignment()).containsOnlyKeys(0));
+ if (bounded) {
+ assertBoundedAssignmentsBeforeCompletion(events, 0, 1);
+ assertThat(events.stream().filter(event ->
event.equals("done:0"))).hasSize(1);
+ assertThat(events).doesNotContain("done:1");
+ } else {
+ assertThat(events).noneMatch(event ->
event.startsWith("done:"));
+ }
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("collidingSplitOwners")
+ public void
testLocalFailureKeepsDistinctOwnersWhenActiveAndRetainedSplitIdsCollide(
+ boolean bounded, int retainedReader) throws Throwable {
+ DynamicKafkaSourceSplit activeSplit =
+ new DynamicKafkaSourceSplit(
+ "a", new KafkaPartitionSplit(new TopicPartition("b-c",
0), 123L, 1000L));
+ DynamicKafkaSourceSplit retainedSplit =
+ new DynamicKafkaSourceSplit(
+ "a-b",
+ new KafkaPartitionSplit(new
TopicPartition("c", 0), 456L, 2000L))
+ .retainUntil(Long.MAX_VALUE);
+ assertThat(activeSplit.splitId()).isEqualTo(retainedSplit.splitId());
+ assertThat(
+ KafkaSourceEnumerator.getSplitOwner(
+
activeSplit.getKafkaPartitionSplit().getTopicPartition(), 2))
+ .isZero();
+ KafkaStream activeStream = createKafkaStream("stream", "a", "b-c");
+ DynamicKafkaSourceEnumState restoredState =
+ createRestoredState(activeStream, "a", List.of(activeSplit));
+ Properties properties = createBaseProperties();
+ properties.setProperty(
+ DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE.key(),
"per_cluster");
+ List<String> events = new ArrayList<>();
+ Map<Integer, List<DynamicKafkaSourceSplit>> reports =
+ Map.of(
+ 0,
+ retainedReader == 0
+ ? List.of(activeSplit, retainedSplit)
+ : List.of(activeSplit),
+ 1,
+ retainedReader == 1 ? List.of(retainedSplit) :
Collections.emptyList());
+
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new
MockSplitEnumeratorContext<DynamicKafkaSourceSplit>(2) {
+ @Override
+ public void assignSplits(
+ SplitsAssignment<DynamicKafkaSourceSplit>
assignment) {
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+ splits.forEach(
+ split ->
+
events.add(
+
"assign:"
+
+ reader
+
+ ":"
+
+ split
+
.splitId())));
+ super.assignSplits(assignment);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int reader) {
+ events.add("done:" + reader);
+ super.signalNoMoreSplits(reader);
+ }
+ };
+ DynamicKafkaSourceEnumerator enumerator =
+ new DynamicKafkaSourceEnumerator(
+ new
KafkaStreamSetSubscriber(Collections.singleton("stream")),
+ new
MockKafkaMetadataService(Collections.singleton(activeStream)),
+ context,
+ OffsetsInitializer.earliest(),
+ bounded
+ ? OffsetsInitializer.latest()
+ : new NoStoppingOffsetsInitializer(),
+ properties,
+ bounded ? Boundedness.BOUNDED :
Boundedness.CONTINUOUS_UNBOUNDED,
+ restoredState,
+ new NoOpKafkaEnumContextProxyFactory())) {
+ enumerator.start();
+ registerReaderAndReportRestoredSplits(context, enumerator, 0,
reports.get(0));
+ registerReaderAndReportRestoredSplits(context, enumerator, 1,
reports.get(1));
+ context.runNextOneTimeCallable();
+ completePartitionDiscovery(
+ (KafkaSourceEnumerator)
+ ((Map<?, ?>)
+ Whitebox.getInternalState(
+ enumerator,
"clusterEnumeratorMap"))
+ .get("a"));
+
+ assertExactCheckpointOffsets(context, 0, List.of(activeSplit,
retainedSplit));
+ reports.forEach(
+ (reader, expected) ->
+ assertThat(
+
context.getSplitsAssignmentSequence().stream()
+ .flatMap(
+ assignment ->
+ assignment
+
.assignment()
+
.getOrDefault(
+
reader,
+
Collections
+
.emptyList())
+
.stream()))
+
.containsExactlyInAnyOrderElementsOf(expected));
+
+ int priorAssignments =
context.getSplitsAssignmentSequence().size();
+ events.clear();
+ context.unregisterReader(retainedReader);
+ enumerator.addSplitsBack(Collections.emptyList(), retainedReader);
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, retainedReader,
reports.get(retainedReader));
+
+ assertExactCheckpointOffsets(context, priorAssignments,
reports.get(retainedReader));
+ context.getSplitsAssignmentSequence().stream()
+ .skip(priorAssignments)
+ .forEach(
+ assignment ->
+ assertThat(assignment.assignment())
+ .containsOnlyKeys(retainedReader));
+ if (bounded) {
+ assertBoundedAssignmentsBeforeCompletion(
+ events, retainedReader,
reports.get(retainedReader).size());
+ assertThat(events.stream().filter(event ->
event.equals("done:" + retainedReader)))
+ .hasSize(1);
+ assertThat(events).doesNotContain("done:" + (1 -
retainedReader));
+ } else {
+ assertThat(events).noneMatch(event ->
event.startsWith("done:"));
+ }
+ }
+ }
+
+ private static Stream<Arguments> collidingSplitOwners() {
+ return Stream.of(
+ Arguments.of(true, 0),
+ Arguments.of(true, 1),
+ Arguments.of(false, 0),
+ Arguments.of(false, 1));
+ }
+
+ @Test
+ public void testLocalFailurePreservesStillInactiveRetainedSplit() throws
Throwable {
+ String cluster = "cluster-0";
+ KafkaStream stream = createKafkaStream("stream", cluster,
"active-topic");
+ DynamicKafkaSourceSplit retained =
+ new DynamicKafkaSourceSplit(
+ cluster,
+ new KafkaPartitionSplit(
+ new TopicPartition("inactive-topic",
0), 123L))
+ .retainUntil(Long.MAX_VALUE);
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(2);
+ DynamicKafkaSourceEnumerator enumerator =
+ createEnumerator(
+ "stream",
+ new
MockKafkaMetadataService(Collections.singleton(stream)),
+ context,
+ createGlobalModeProperties(),
+ createRestoredState(stream, cluster,
Collections.emptyList()))) {
+ enumerator.start();
+ registerReaderAndReportRestoredSplits(context, enumerator, 0,
List.of(retained));
+ registerReaderAndReportRestoredSplits(context, enumerator, 1,
Collections.emptyList());
+ context.runNextOneTimeCallable();
+ assertExactCheckpointOffsets(context, 0, List.of(retained));
+
+ int priorAssignments =
context.getSplitsAssignmentSequence().size();
+ context.unregisterReader(0);
+ enumerator.addSplitsBack(Collections.emptyList(), 0);
+ registerReaderAndReportRestoredSplits(context, enumerator, 0,
List.of(retained));
+
+ assertExactCheckpointOffsets(context, priorAssignments,
List.of(retained));
+ context.getSplitsAssignmentSequence().stream()
+ .skip(priorAssignments)
+ .forEach(assignment ->
assertThat(assignment.assignment()).containsOnlyKeys(0));
+ }
+ }
+
+ @Test
+ public void testLocalFailureAfterNewCheckpointUsesReportedOffsets() throws
Throwable {
+ verifyNumericCheckpointRecovery(false, true, 1, false, false, true,
false);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ public void testLocalFailurePreservesCurrentOwnersAcrossClusterRetention(
+ boolean failWhileInactive) throws Throwable {
+ String streamId = "stream";
+ String clusterId = "cluster-0";
+ String topic = "topic";
+ List<DynamicKafkaSourceSplit> checkpointSplits =
+ createSplits(clusterId, topic, 11).stream()
+ .map(
+ split ->
+ withOffset(
+ split,
+ 1000L
+ +
split.getKafkaPartitionSplit()
+
.getPartition()))
+ .collect(Collectors.toList());
+ List<DynamicKafkaSourceSplit> historicalReaderZeroSplits =
+ checkpointSplits.stream()
+ .filter(split ->
split.getKafkaPartitionSplit().getPartition() % 2 == 0)
+ .collect(Collectors.toList());
+ List<DynamicKafkaSourceSplit> historicalReaderOneSplits =
+ checkpointSplits.stream()
+ .filter(split ->
split.getKafkaPartitionSplit().getPartition() % 2 == 1)
+ .collect(Collectors.toList());
+ KafkaStream stream = createKafkaStream(streamId, clusterId, topic);
+ Properties properties = createGlobalModeProperties();
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "1");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+ "60000");
+
+ try (MockKafkaMetadataService metadataService =
+ new
MockKafkaMetadataService(Collections.singleton(stream));
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(2);
+ DynamicKafkaSourceEnumerator enumerator =
+ createEnumerator(
+ streamId,
+ metadataService,
+ context,
+ properties,
+ createRestoredState(stream, clusterId,
checkpointSplits))) {
+ enumerator.start();
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, 0, historicalReaderZeroSplits);
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, 1, historicalReaderOneSplits);
+ context.runPeriodicCallable(0);
+ assertExactCheckpointOffsets(context, 0, checkpointSplits);
+
+ Map<Integer, List<DynamicKafkaSourceSplit>> currentAssignments =
new HashMap<>();
+ context.getSplitsAssignmentSequence()
+ .forEach(
+ assignment ->
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+ currentAssignments
+
.computeIfAbsent(
+
reader,
+
ignored ->
+
new ArrayList<>())
+
.addAll(splits)));
+ assertThat(historicalReaderZeroSplits.stream())
+ .anyMatch(currentAssignments.get(1)::contains);
+
+ metadataService.setKafkaStreams(
+ Collections.singleton(new KafkaStream(streamId,
Collections.emptyMap())));
+ context.runPeriodicCallable(0);
+
assertThat(enumerator.snapshotState(1L).getRetainedClusterEnumeratorStates())
+ .containsKey(clusterId);
+
+ if (!failWhileInactive) {
+ metadataService.setKafkaStreams(Collections.singleton(stream));
+ context.runPeriodicCallable(0);
+ }
+ int assignmentsBeforeFailure =
context.getSplitsAssignmentSequence().size();
+ context.unregisterReader(0);
+ enumerator.addSplitsBack(currentAssignments.get(0), 0);
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, 0, historicalReaderZeroSplits);
+
+ List<DynamicKafkaSourceSplit> recoveredSplits = new ArrayList<>();
+ for (SplitsAssignment<DynamicKafkaSourceSplit> assignment :
+ context.getSplitsAssignmentSequence()
+ .subList(
+ assignmentsBeforeFailure,
+
context.getSplitsAssignmentSequence().size())) {
+ assertThat(assignment.assignment()).containsOnlyKeys(0);
+ recoveredSplits.addAll(assignment.assignment().get(0));
+ }
+ assertThat(recoveredSplits)
+ .allSatisfy(
+ split ->
assertThat(split.isRetained()).isEqualTo(failWhileInactive));
+ assertThat(
+ recoveredSplits.stream()
+
.map(DynamicKafkaSourceSplit::clearRetention)
+ .collect(Collectors.toList()))
+
.containsExactlyInAnyOrderElementsOf(currentAssignments.get(0));
+
+ if (failWhileInactive) {
+ int assignmentsBeforeReactivation =
context.getSplitsAssignmentSequence().size();
+ metadataService.setKafkaStreams(Collections.singleton(stream));
+ context.runPeriodicCallable(0);
+ assertThat(context.getSplitsAssignmentSequence())
+ .hasSize(assignmentsBeforeReactivation);
+ }
+ }
+ }
+
+ @Test
+ public void
testCheckpointReportsReplacePreinitializedPendingOwnersAndOffsets()
+ throws Throwable {
+ verifyNumericCheckpointRecovery(true, true, 3, true, true, false,
true);
+ }
+
+ @Test
+ public void
testNewPartitionDiscoveryWaitsForRecoveryAndSurvivesRepeatedLocalFailure()
+ throws Throwable {
+ String cluster = "cluster-0";
+ KafkaStream stream = createKafkaStream("stream", cluster, "topic");
+ List<DynamicKafkaSourceSplit> checkpointSplits = createSplits(cluster,
"topic", 2);
+ DynamicKafkaSourceSplit discovered =
+ withOffset(createSplits(cluster, "topic", 3).get(2), 2500L);
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(2);
+ DynamicKafkaSourceEnumerator enumerator =
+ createEnumerator(
+ "stream",
+ new
MockKafkaMetadataService(Collections.singleton(stream)),
+ context,
+ createGlobalModeProperties(),
+ createRestoredState(stream, cluster,
checkpointSplits))) {
+ enumerator.start();
+ context.runNextOneTimeCallable();
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, 0,
Collections.singletonList(checkpointSplits.get(0)));
+ // Invoke the real coordinator callback with a completed
broker-discovery result.
+ // No broker or mock owner-selection implementation participates
in the regression.
+ Object subEnumerator =
+ ((Map<?, ?>) Whitebox.getInternalState(enumerator,
"clusterEnumeratorMap"))
+ .get(cluster);
+ Class<?> changeClass =
+ Class.forName(KafkaSourceEnumerator.class.getName() +
"$PartitionSplitChange");
+ Constructor<?> constructor =
changeClass.getDeclaredConstructor(Set.class, Set.class);
+ constructor.setAccessible(true);
+ Method handler =
+ KafkaSourceEnumerator.class.getDeclaredMethod(
+ "handlePartitionSplitChanges", changeClass,
Throwable.class);
+ handler.setAccessible(true);
+ handler.invoke(
+ subEnumerator,
+ constructor.newInstance(
+
Collections.singleton(discovered.getKafkaPartitionSplit()),
+ Collections.emptySet()),
+ null);
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, 1,
Collections.singletonList(checkpointSplits.get(1)));
+ List<DynamicKafkaSourceSplit> expected = new
ArrayList<>(checkpointSplits);
+ expected.add(discovered);
+ assertExactCheckpointOffsets(context, 0, expected);
+ Map<Integer, List<DynamicKafkaSourceSplit>> actualOwners = new
HashMap<>();
+ context.getSplitsAssignmentSequence()
+ .forEach(
+ assignment ->
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+ actualOwners
+
.computeIfAbsent(
+
reader,
+
ignored ->
+
new ArrayList<>())
+
.addAll(splits)));
+ int failedReader =
+ actualOwners.entrySet().stream()
+ .filter(entry ->
entry.getValue().contains(discovered))
+ .findFirst()
+ .get()
+ .getKey();
+ for (int attempt = 0; attempt < 2; attempt++) {
+ int priorAssignments =
context.getSplitsAssignmentSequence().size();
+ context.unregisterReader(failedReader);
+ enumerator.addSplitsBack(actualOwners.get(failedReader),
failedReader);
+ registerReaderAndReportRestoredSplits(
+ context,
+ enumerator,
+ failedReader,
+
Collections.singletonList(checkpointSplits.get(failedReader)));
+ assertExactCheckpointOffsets(
+ context, priorAssignments,
actualOwners.get(failedReader));
+ context.getSplitsAssignmentSequence().stream()
+ .skip(priorAssignments)
+ .forEach(
+ assignment ->
+ assertThat(assignment.assignment())
+
.containsOnlyKeys(failedReader));
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("boundedRecoveryModes")
+ public void testBoundedRecoveryAssignsAllClustersBeforeNoMoreSplits(
+ String mode, boolean discoverFirst) throws Throwable {
+ verifyBoundedRecoveryAssignmentOrder(mode, discoverFirst, true);
+ }
+
+ @ParameterizedTest
+ @MethodSource("boundedRecoveryModes")
+ public void testBoundedLocalRecoveryAssignsAllClustersBeforeNoMoreSplits(
+ String mode, boolean discoverFirst) throws Throwable {
+ verifyBoundedRecoveryAssignmentOrder(mode, discoverFirst, false);
+ }
+
+ private void verifyBoundedRecoveryAssignmentOrder(
+ String mode, boolean discoverFirst, boolean checkInitialOrder)
throws Throwable {
+ Map<String, ClusterMetadata> metadata = new HashMap<>();
+ Map<String, KafkaSourceEnumState> states = new HashMap<>();
+ Map<Integer, List<DynamicKafkaSourceSplit>> reports = new HashMap<>();
+ for (String cluster : List.of("cluster-0", "cluster-1")) {
+ metadata.putAll(createKafkaStream("stream", cluster,
"topic").getClusterMetadataMap());
+ List<DynamicKafkaSourceSplit> splits = new ArrayList<>();
+ for (int reader = 0; reader < 2; reader++) {
+ DynamicKafkaSourceSplit split =
+ new DynamicKafkaSourceSplit(
+ cluster,
+ new KafkaPartitionSplit(
+ new TopicPartition("topic", reader),
100L + reader, 1000L));
+ splits.add(split);
+ reports.computeIfAbsent(reader, ignored -> new
ArrayList<>()).add(split);
+ }
+ states.put(
+ cluster,
+ new KafkaSourceEnumState(unwrapSplits(splits),
Collections.emptyList(), true));
+ }
+ KafkaStream stream = new KafkaStream("stream", metadata);
+ Properties properties = createBaseProperties();
+
properties.setProperty(DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE.key(),
mode);
+ List<String> events = new ArrayList<>();
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new
MockSplitEnumeratorContext<DynamicKafkaSourceSplit>(2) {
+ @Override
+ public void assignSplits(
+ SplitsAssignment<DynamicKafkaSourceSplit>
assignment) {
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+ splits.forEach(
+ split ->
+
events.add(
+
"assign:"
+
+ reader
+
+ ":"
+
+ split
+
.splitId())));
+ super.assignSplits(assignment);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int reader) {
+ events.add("done:" + reader);
+ super.signalNoMoreSplits(reader);
+ }
+ };
+ DynamicKafkaSourceEnumerator enumerator =
+ new DynamicKafkaSourceEnumerator(
+ new
KafkaStreamSetSubscriber(Collections.singleton("stream")),
+ new
MockKafkaMetadataService(Collections.singleton(stream)),
+ context,
+ OffsetsInitializer.earliest(),
+ OffsetsInitializer.latest(),
+ properties,
+ Boundedness.BOUNDED,
+ new DynamicKafkaSourceEnumState(
+ Collections.singleton(stream), states),
+ new NoOpKafkaEnumContextProxyFactory())) {
+ enumerator.start();
+ // Complete actual sub-enumerator discovery callbacks without
broker I/O. Each
+ // sub-enumerator must subsequently signal completion during its
addReader flush.
+ Map<?, ?> subEnumerators =
+ (Map<?, ?>) Whitebox.getInternalState(enumerator,
"clusterEnumeratorMap");
+ Class<?> changeClass =
+ Class.forName(KafkaSourceEnumerator.class.getName() +
"$PartitionSplitChange");
+ Constructor<?> constructor =
changeClass.getDeclaredConstructor(Set.class, Set.class);
+ constructor.setAccessible(true);
+ Method handler =
+ KafkaSourceEnumerator.class.getDeclaredMethod(
+ "handlePartitionSplitChanges", changeClass,
Throwable.class);
+ handler.setAccessible(true);
+ for (Object subEnumerator : subEnumerators.values()) {
+ handler.invoke(
+ subEnumerator,
+ constructor.newInstance(Collections.emptySet(),
Collections.emptySet()),
+ null);
+ }
+ if (discoverFirst) {
+ context.runNextOneTimeCallable();
+ }
+ for (int reader = 0; reader < 2; reader++) {
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, reader, reports.get(reader));
+ }
+ if (!discoverFirst) {
+ context.runNextOneTimeCallable();
+ }
+ if (checkInitialOrder) {
+ assertBoundedAssignmentsBeforeCompletion(events, 0, 2);
+ assertBoundedAssignmentsBeforeCompletion(events, 1, 2);
+ }
+ Map<Integer, List<DynamicKafkaSourceSplit>> assigned = new
HashMap<>();
+ context.getSplitsAssignmentSequence()
+ .forEach(
+ assignment ->
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+
assigned.computeIfAbsent(
+
reader,
+
ignored ->
+
new ArrayList<>())
+
.addAll(splits)));
+ events.clear();
+ int priorAssignments =
context.getSplitsAssignmentSequence().size();
+ context.unregisterReader(0);
+ context.unregisterReader(1);
+ List<DynamicKafkaSourceSplit> readerZeroReports = new
ArrayList<>(assigned.get(0));
+ readerZeroReports.add(
+ new DynamicKafkaSourceSplit(
+ "removed-cluster",
+ new KafkaPartitionSplit(
+ new
TopicPartition("removed-topic", 0), 5L, 1000L))
+ .retainUntil(Long.MAX_VALUE));
+ enumerator.addSplitsBack(Collections.emptyList(), 0);
+ registerReaderAndReportRestoredSplits(context, enumerator, 0,
readerZeroReports);
+
+ assertBoundedAssignmentsBeforeCompletion(events, 0, 3);
+ assertThat(events).noneMatch(event ->
event.startsWith("assign:1:"));
+ assertThat(events).doesNotContain("done:1");
+ assertExactCheckpointOffsets(context, priorAssignments,
readerZeroReports);
+ context.getSplitsAssignmentSequence().stream()
+ .skip(priorAssignments)
+ .flatMap(assignment ->
assignment.assignment().values().stream())
+ .flatMap(List::stream)
+ .forEach(
+ split ->
+
assertThat(split.getKafkaPartitionSplit().getStoppingOffset())
+ .contains(1000L));
+ registerReaderAndReportRestoredSplits(context, enumerator, 1,
assigned.get(1));
+ assertBoundedAssignmentsBeforeCompletion(events, 1, 2);
+ assertThat(events.stream().filter(event ->
event.equals("done:0"))).hasSize(1);
+ assertThat(events.stream().filter(event ->
event.equals("done:1"))).hasSize(1);
+ }
+ }
+
+ private static Stream<Arguments> boundedRecoveryModes() {
+ return Stream.of(
+ Arguments.of("per_cluster", true),
+ Arguments.of("per_cluster", false),
+ Arguments.of("global", true),
+ Arguments.of("global", false));
+ }
+
+ private static void assertBoundedAssignmentsBeforeCompletion(
+ List<String> events, int reader, int expectedSplits) {
+ String assignmentPrefix = "assign:" + reader + ":";
+ int completion = events.indexOf("done:" + reader);
+ assertThat(completion).as("completion must be signaled: %s",
events).isNotNegative();
+ assertThat(events.stream().filter(event ->
event.startsWith(assignmentPrefix)))
+ .hasSize(expectedSplits);
+ assertThat(
+ events.subList(0, completion).stream()
+ .filter(event ->
event.startsWith(assignmentPrefix)))
+ .as("all restored assignments must precede completion: %s",
events)
+ .hasSize(expectedSplits);
+ }
+
+ private void verifyNumericCheckpointRecovery(boolean discoverFirst,
boolean localFailure)
+ throws Throwable {
+ verifyNumericCheckpointRecovery(discoverFirst, localFailure, 3, false,
false, false, false);
+ }
+
+ private void verifyNumericCheckpointRecovery(
+ boolean discoverFirst,
+ boolean localFailure,
+ int failedReaderMask,
+ boolean reverseRegistration,
+ boolean interleavedReturns,
+ boolean checkpointAfterRedistribution,
+ boolean restoredPendingCopies)
+ throws Throwable {
+ verifyNumericCheckpointRecovery(
+ discoverFirst,
+ localFailure,
+ failedReaderMask,
+ reverseRegistration,
+ interleavedReturns,
+ checkpointAfterRedistribution,
+ restoredPendingCopies,
+ "global");
+ }
+
+ private void verifyNumericCheckpointRecovery(
+ boolean discoverFirst,
+ boolean localFailure,
+ int failedReaderMask,
+ boolean reverseRegistration,
+ boolean interleavedReturns,
+ boolean checkpointAfterRedistribution,
+ boolean restoredPendingCopies,
+ String mode)
+ throws Throwable {
+ verifyNumericCheckpointRecovery(
+ discoverFirst,
+ localFailure,
+ failedReaderMask,
+ reverseRegistration,
+ interleavedReturns,
+ checkpointAfterRedistribution,
+ restoredPendingCopies,
+ mode,
+ false);
+ }
+
+ private void verifyNumericCheckpointRecovery(
+ boolean discoverFirst,
+ boolean localFailure,
+ int failedReaderMask,
+ boolean reverseRegistration,
+ boolean interleavedReturns,
+ boolean checkpointAfterRedistribution,
+ boolean restoredPendingCopies,
+ String mode,
+ boolean retainedCheckpointReports)
+ throws Throwable {
+ String streamId = "stream";
+ String clusterId = "cluster-0";
+ String topic = "topic";
+ int parallelism = 2;
+ List<DynamicKafkaSourceSplit> checkpointSplits =
+ createSplits(clusterId, topic, 11).stream()
+ .map(
+ split ->
+ new DynamicKafkaSourceSplit(
+ clusterId,
+ new KafkaPartitionSplit(
+
split.getKafkaPartitionSplit()
+
.getTopicPartition(),
+ 1000L
+ +
split.getKafkaPartitionSplit()
+
.getTopicPartition()
+
.partition())))
+ .collect(Collectors.toList());
+ KafkaStream stream = createKafkaStream(streamId, clusterId, topic);
+ Properties properties = createBaseProperties();
+
properties.setProperty(DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE.key(),
mode);
+ Map<Integer, List<DynamicKafkaSourceSplit>> checkpointOwners = new
HashMap<>();
+ for (DynamicKafkaSourceSplit split : checkpointSplits) {
+ int owner =
+
split.getKafkaPartitionSplit().getTopicPartition().partition() % parallelism;
+ checkpointOwners
+ .computeIfAbsent(owner, ignored -> new ArrayList<>())
+ .add(retainedCheckpointReports ?
split.retainUntil(Long.MAX_VALUE) : split);
+ }
+ DynamicKafkaSourceEnumState restoredState =
+ createRestoredState(stream, clusterId, checkpointSplits);
+ if (restoredPendingCopies) {
+ restoredState =
+ new DynamicKafkaSourceEnumState(
+ Collections.singleton(stream),
+ Collections.singletonMap(
+ clusterId,
+ new KafkaSourceEnumState(
+ Collections.emptyList(),
+ checkpointSplits.stream()
+ .map(
+ split ->
+
withOffset(split, 9000L)
+
.getKafkaPartitionSplit())
+
.collect(Collectors.toList()),
+ true)));
+ }
+ try (MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(parallelism);
+ DynamicKafkaSourceEnumerator enumerator =
+ createEnumerator(
+ streamId,
+ new
MockKafkaMetadataService(Collections.singleton(stream)),
+ context,
+ properties,
+ restoredState)) {
+ enumerator.start();
+ if (discoverFirst) {
+ context.runNextOneTimeCallable();
+ }
+ for (int reader = 0; reader < parallelism; reader++) {
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, reader,
checkpointOwners.get(reader));
+ }
+ if (!discoverFirst) {
+ context.runNextOneTimeCallable();
+ }
+ assertSinglePhysicalOwners(context, 0, 11);
+ assertExactCheckpointOffsets(context, 0, checkpointSplits);
+ if (!localFailure) {
+ return;
+ }
+ // Model SourceCoordinator local recovery: return all
+ // assignments since the restored checkpoint, then register
original checkpoint reports.
+ Map<Integer, List<DynamicKafkaSourceSplit>>
uncheckpointedAssignments = new HashMap<>();
+ for (SplitsAssignment<DynamicKafkaSourceSplit> assignment :
+ context.getSplitsAssignmentSequence()) {
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+ uncheckpointedAssignments
+ .computeIfAbsent(
+ reader, ignored -> new
ArrayList<>())
+ .addAll(splits));
+ }
+ int assignmentsBeforeFailure =
context.getSplitsAssignmentSequence().size();
+ Map<Integer, List<DynamicKafkaSourceSplit>> currentAssignments =
new HashMap<>();
+ uncheckpointedAssignments.forEach(
+ (reader, splits) -> currentAssignments.put(reader, new
ArrayList<>(splits)));
+ if (checkpointAfterRedistribution) {
+ // Runtime no longer returns assignments covered by a
completed checkpoint.
+ // Reader reports now use the current owner and the
checkpointed next offset.
+ checkpointOwners.clear();
+ currentAssignments.forEach(
+ (reader, splits) ->
+ checkpointOwners.put(
+ reader,
+ splits.stream()
+ .map(
+ split ->
+ withOffset(
+ split,
+
split.getKafkaPartitionSplit()
+
.getStartingOffset()
+
+ 10000L))
+
.collect(Collectors.toList())));
+ uncheckpointedAssignments.replaceAll((reader, splits) ->
Collections.emptyList());
+ }
+ for (int reader = 0; reader < parallelism; reader++) {
+ if ((failedReaderMask & (1 << reader)) != 0) {
+ context.unregisterReader(reader);
+ }
+ }
+ if (!interleavedReturns) {
+ for (int reader = 0; reader < parallelism; reader++) {
+ if ((failedReaderMask & (1 << reader)) != 0) {
+
enumerator.addSplitsBack(uncheckpointedAssignments.get(reader), reader);
+ }
+ }
+ }
+ for (int index = 0; index < parallelism; index++) {
+ int reader = reverseRegistration ? parallelism - 1 - index :
index;
+ if ((failedReaderMask & (1 << reader)) != 0) {
+ if (interleavedReturns) {
+
enumerator.addSplitsBack(uncheckpointedAssignments.get(reader), reader);
+ }
+ registerReaderAndReportRestoredSplits(
+ context, enumerator, reader,
checkpointOwners.get(reader));
+ }
+ }
+ Map<Integer, List<DynamicKafkaSourceSplit>>
assignmentsAfterFailure = new HashMap<>();
+ context.getSplitsAssignmentSequence().stream()
+ .skip(assignmentsBeforeFailure)
+ .forEach(
+ assignment ->
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+
assignmentsAfterFailure
+
.computeIfAbsent(
+
reader,
+
ignored ->
+
new ArrayList<>())
+
.addAll(splits)));
+ for (int reader = 0; reader < parallelism; reader++) {
+ if ((failedReaderMask & (1 << reader)) == 0) {
+
assertThat(assignmentsAfterFailure).doesNotContainKey(reader);
+ } else {
+ List<DynamicKafkaSourceSplit> expected =
+ checkpointAfterRedistribution
+ ? checkpointOwners.get(reader)
+ : currentAssignments.get(reader);
+ assertThat(assignmentsAfterFailure.get(reader))
+ .as(
+ "failed reader %s keeps its actual owner
and checkpoint offsets",
+ reader)
+ .containsExactlyInAnyOrderElementsOf(expected);
+ }
+ }
+ }
+ }
+
+ private static DynamicKafkaSourceSplit withOffset(DynamicKafkaSourceSplit
split, long offset) {
+ return new DynamicKafkaSourceSplit(
+ split.getKafkaClusterId(),
+ new KafkaPartitionSplit(
+ split.getKafkaPartitionSplit().getTopicPartition(),
offset));
+ }
+
+ private static void completePartitionDiscovery(KafkaSourceEnumerator
enumerator)
+ throws Exception {
+ Class<?> changeClass =
+ Class.forName(KafkaSourceEnumerator.class.getName() +
"$PartitionSplitChange");
+ Constructor<?> constructor =
changeClass.getDeclaredConstructor(Set.class, Set.class);
+ constructor.setAccessible(true);
+ Method handler =
+ KafkaSourceEnumerator.class.getDeclaredMethod(
+ "handlePartitionSplitChanges", changeClass,
Throwable.class);
+ handler.setAccessible(true);
+ handler.invoke(
+ enumerator,
+ constructor.newInstance(Collections.emptySet(),
Collections.emptySet()),
+ null);
+ }
+
+ private static void assertExactCheckpointOffsets(
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+ int skipAssignments,
+ List<DynamicKafkaSourceSplit> expected) {
+ assertThat(
+ context.getSplitsAssignmentSequence().stream()
+ .skip(skipAssignments)
+ .flatMap(assignment ->
assignment.assignment().values().stream())
+ .flatMap(List::stream)
+ .collect(Collectors.toList()))
+ .containsExactlyInAnyOrderElementsOf(expected);
+ }
+
+ private static void assertSinglePhysicalOwners(
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+ int skipAssignments,
+ int expectedPartitions) {
+ Map<String, Set<Integer>> owners = new HashMap<>();
+ context.getSplitsAssignmentSequence().stream()
+ .skip(skipAssignments)
+ .forEach(
+ assignment ->
+ assignment
+ .assignment()
+ .forEach(
+ (reader, splits) ->
+ splits.forEach(
+ split ->
+
owners.computeIfAbsent(
+
split
+
.splitId(),
+
ignored ->
+
new HashSet<>())
+
.add(reader))));
+ assertThat(owners).hasSize(expectedPartitions);
+ assertThat(owners.entrySet())
+ .as("every physical split must have one active reader: %s",
owners)
+ .allSatisfy(entry -> assertThat(entry.getValue()).hasSize(1));
+ }
+
+ private static void registerReaderAndReportRestoredSplits(
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
+ DynamicKafkaSourceEnumerator enumerator,
+ int readerId,
+ List<DynamicKafkaSourceSplit> restoredSplits) {
+ context.registerReader(
+ ReaderInfo.createReaderInfo(readerId, "location-" + readerId,
restoredSplits));
+ enumerator.addReader(readerId);
+ }
+
private static void registerReadersWithoutReportedSplits(
MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
DynamicKafkaSourceEnumerator enumerator,
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 389beb68..e100aedf 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
@@ -257,8 +257,15 @@ public class DynamicKafkaSourceEnumeratorTest {
context.registerReader(
ReaderInfo.createReaderInfo(0, "restarted",
reportedSplitsByReader.get(0)));
enumerator.addReader(0);
- assertThat(context.getSplitsAssignmentSequence()).isEmpty();
- assertThat(context.splitsAtCompletion).isEmpty();
+ assertThat(context.getAssignedSplits(0))
+
.containsExactlyInAnyOrderElementsOf(reportedSplitsByReader.get(0));
+ assertThat(context.splitsAtCompletion).containsOnlyKeys(0);
+ assertThat(context.splitsAtCompletion.get(0))
+ .singleElement()
+ .isEqualTo(
+ reportedSplitsByReader.get(0).stream()
+ .map(DynamicKafkaSourceSplit::splitId)
+ .collect(Collectors.toSet()));
context.registerReader(
ReaderInfo.createReaderInfo(1, "restarted",
reportedSplitsByReader.get(1)));
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 cf3d5376..4b2cfa51 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
@@ -40,8 +40,7 @@ class ReaderRecoveryGateTest {
ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
assertThat(gate.hasPendingRecovery()).isFalse();
- assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isFalse();
- assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents()).isFalse();
assertThat(gate.hasReportedSplits()).isFalse();
}
@@ -50,56 +49,66 @@ class ReaderRecoveryGateTest {
ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
assertThat(gate.hasPendingRecovery()).isTrue();
- assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isTrue();
- assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isTrue();
+ assertThat(gate.shouldDeferMetadataUpdateEvents()).isTrue();
gate.markInitialRegistrationComplete();
assertThat(gate.hasPendingRecovery()).isFalse();
- assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents()).isFalse();
}
@Test
- void
testCompletingInitialRegistrationDoesNotReleaseGateWithPendingReportedSplits() {
+ void testDrainingReportsDoesNotReleaseInitialAssignmentGate() {
ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
gate.recordReportedSplits(1, Collections.singletonList(split("topic",
0)));
+ gate.startReaderRegistration(1);
- gate.markInitialRegistrationComplete();
+ gate.drainReportedSplits();
assertThat(gate.hasPendingRecovery()).isTrue();
- assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isTrue();
- assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ assertThat(gate.isReaderReadyForAssignment(1)).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents()).isTrue();
- gate.drainReportedSplits();
+ gate.markInitialRegistrationComplete();
assertThat(gate.hasPendingRecovery()).isFalse();
- assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isFalse();
- assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ assertThat(gate.isReaderReadyForAssignment(1)).isTrue();
+ assertThat(gate.isReconciliationPending()).isFalse();
}
@Test
- void testReportedSplitsGateUntilAllReadersRegistered() {
+ void testLocalRegistrationOnlyBlocksTheRecoveringReader() {
ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ gate.startReaderRegistration(1);
gate.recordReportedSplits(1, Collections.singletonList(split("topic",
0)));
- assertThat(gate.hasPendingRecovery()).isTrue();
- assertThat(gate.hasReportedSplits()).isTrue();
- assertThat(gate.shouldDeferMetadataUpdateEvents(false)).isTrue();
- assertThat(gate.shouldDeferMetadataUpdateEvents(true)).isFalse();
+ assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.hasReportedSplits()).isFalse();
+ assertThat(gate.isReaderReadyForAssignment(0)).isTrue();
+ assertThat(gate.isReaderReadyForAssignment(1)).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents()).isTrue();
+
+ gate.completeReaderRegistration(1);
+
+ assertThat(gate.isReaderReadyForAssignment(1)).isTrue();
+ assertThat(gate.isReconciliationPending()).isFalse();
+ assertThat(gate.shouldDeferMetadataUpdateEvents()).isFalse();
}
@Test
- void testEmptyReportedSplitsAreIgnored() {
- ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ void testEmptyReportDuringInitialRecovery() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
gate.recordReportedSplits(1, Collections.emptyList());
- assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.hasPendingRecovery()).isTrue();
assertThat(gate.hasReportedSplits()).isFalse();
+ assertThat(gate.drainReportedSplits()).isEmpty();
+ assertThat(gate.hasPendingRecovery()).isTrue();
}
@Test
void testRepeatedReportMergesSplitsAndPrefersLatestOffsets() {
- ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
DynamicKafkaSourceSplit firstReport = split("topic", 0);
DynamicKafkaSourceSplit secondReport = split("topic", 1);
DynamicKafkaSourceSplit updatedSplit =
@@ -118,7 +127,7 @@ class ReaderRecoveryGateTest {
@Test
void testSnapshotReportedSplitsIsIndependentAndDoesNotDrain() {
- ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
DynamicKafkaSourceSplit firstReport = split("topic", 0);
DynamicKafkaSourceSplit secondReport = split("topic", 1);
gate.recordReportedSplits(1, Collections.singletonList(firstReport));
@@ -139,8 +148,8 @@ class ReaderRecoveryGateTest {
}
@Test
- void testEmptyRepeatedReportRetainsPreviousReport() {
- ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ void testEmptyRepeatedReportPreservesPendingSplits() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
DynamicKafkaSourceSplit firstReport = split("topic", 0);
gate.recordReportedSplits(1, Collections.singletonList(firstReport));
gate.recordReportedSplits(1, Collections.emptyList());
@@ -149,8 +158,51 @@ class ReaderRecoveryGateTest {
}
@Test
- void testDrainReportedSplitsReturnsReaderOrderAndClears() {
+ void testReturnedSplitsRemainPendingWithoutInitialRecoveryGate() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ DynamicKafkaSourceSplit firstReturnedSplit = split("topic", 0);
+ DynamicKafkaSourceSplit secondReturnedSplit = split("topic", 1);
+ DynamicKafkaSourceSplit updatedSplit =
+ new DynamicKafkaSourceSplit(
+ "cluster0", new KafkaPartitionSplit(new
TopicPartition("topic", 0), 42L));
+ gate.recordReturnedSplits(1,
Collections.singletonList(firstReturnedSplit));
+ gate.recordReturnedSplits(1, Arrays.asList(updatedSplit,
secondReturnedSplit));
+
+ assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.hasReportedSplits()).isTrue();
+ assertThat(gate.snapshotReportedSplits().get(1))
+ .containsExactlyInAnyOrder(updatedSplit, secondReturnedSplit);
+
assertThat(gate.snapshotReportedSplits().get(1).get(0).getStartingOffset()).isEqualTo(42L);
+ }
+
+ @Test
+ void testDrainReportedSplitsForOneReaderPreservesOtherReaders() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ DynamicKafkaSourceSplit splitReader1 = split("topic", 1);
+ DynamicKafkaSourceSplit splitReader2 = split("topic", 2);
+ gate.recordReturnedSplits(1, Collections.singletonList(splitReader1));
+ gate.recordReturnedSplits(2, Collections.singletonList(splitReader2));
+
+ assertThat(gate.drainReportedSplits(1)).containsExactly(splitReader1);
+ assertThat(gate.drainReportedSplits(1)).isEmpty();
+ assertThat(gate.snapshotReportedSplits()).containsOnlyKeys(2);
+
assertThat(gate.snapshotReportedSplits().get(2)).containsExactly(splitReader2);
+ }
+
+ @Test
+ void testEmptyReturnedSplitsAreIgnored() {
ReaderRecoveryGate gate = new ReaderRecoveryGate(false);
+ gate.recordReturnedSplits(1, Collections.emptyList());
+
+ assertThat(gate.hasPendingRecovery()).isFalse();
+ assertThat(gate.hasReportedSplits()).isFalse();
+ assertThat(gate.snapshotReportedSplits()).isEmpty();
+ assertThat(gate.drainReportedSplits(1)).isEmpty();
+ }
+
+ @Test
+ void testDrainReportedSplitsReturnsReaderOrderAndClears() {
+ ReaderRecoveryGate gate = new ReaderRecoveryGate(true);
DynamicKafkaSourceSplit splitReader2 = split("topic", 2);
DynamicKafkaSourceSplit splitReader0 = split("topic", 0);
gate.recordReportedSplits(2, Collections.singletonList(splitReader2));
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumeratorPendingOwnershipRegressionTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumeratorPendingOwnershipRegressionTest.java
new file mode 100644
index 00000000..7468db74
--- /dev/null
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumeratorPendingOwnershipRegressionTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.connector.kafka.source.enumerator;
+
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.ReaderInfo;
+import org.apache.flink.api.connector.source.mocks.MockSplitEnumeratorContext;
+import
org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
+import
org.apache.flink.connector.kafka.source.enumerator.subscriber.KafkaSubscriber;
+import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
+
+import org.apache.kafka.common.TopicPartition;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Offline regressions for physical-partition ownership during returned-split
reconciliation. */
+class KafkaSourceEnumeratorPendingOwnershipRegressionTest {
+
+ @Test
+ void sameOwnerSameOffsetIsIdempotentControl() throws Exception {
+ assertSinglePendingPhysicalSplit(0, 0, 100L, 100L);
+ }
+
+ @Test
+ void changedOwnerMustNotRetainPreviousPendingOwner() throws Exception {
+ assertSinglePendingPhysicalSplit(0, 1, 100L, 100L);
+ }
+
+ @Test
+ void changedOwnerAndOffsetMustNotRetainPreviousPendingCopy() throws
Exception {
+ assertSinglePendingPhysicalSplit(0, 1, 100L, 200L);
+ }
+
+ @Test
+ void changedOffsetMustNotRetainTwoCopiesOnSameOwner() throws Exception {
+ assertSinglePendingPhysicalSplit(0, 0, 100L, 200L);
+ }
+
+ @Test
+ void lowerCheckpointOffsetReplacesHigherPendingOffset() throws Exception {
+ assertSinglePendingPhysicalSplit(0, 1, 200L, 100L);
+ }
+
+ @ParameterizedTest
+ @CsvSource({"100, 200", "200, 100"})
+ void sameBatchKeepsLastPhysicalSplitAndPreservesOtherPartitions(
+ long firstOffset, long lastOffset) throws Exception {
+ TopicPartition partition = new TopicPartition("topic", 0);
+ KafkaPartitionSplit unrelated =
+ new KafkaPartitionSplit(new TopicPartition("topic", 1), 500L);
+ KafkaPartitionSplit expected = new KafkaPartitionSplit(partition,
lastOffset);
+ try (MockSplitEnumeratorContext<KafkaPartitionSplit> context =
+ new MockSplitEnumeratorContext<>(1);
+ KafkaSourceEnumerator enumerator =
+ new KafkaSourceEnumerator(
+ client -> Collections.emptySet(),
+ OffsetsInitializer.earliest(),
+ OffsetsInitializer.earliest(),
+ new Properties(),
+ context,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ new KafkaSourceEnumState(
+ Collections.emptyList(),
Collections.emptyList(), true))) {
+ enumerator.addSplitsBack(List.of(unrelated), 0);
+ enumerator.addSplitsBack(
+ List.of(new KafkaPartitionSplit(partition, firstOffset),
expected), 0);
+ assertThat(enumerator.snapshotState(1L).unassignedSplits())
+ .containsExactlyInAnyOrder(unrelated, expected);
+
+ context.registerReader(new ReaderInfo(0, "reader-0"));
+ enumerator.addReader(0);
+
+ assertThat(context.getSplitsAssignmentSequence()).hasSize(1);
+
assertThat(context.getSplitsAssignmentSequence().get(0).assignment().get(0))
+ .containsExactlyInAnyOrder(unrelated, expected);
+ assertThat(enumerator.snapshotState(2L).assignedSplits())
+ .containsExactlyInAnyOrder(unrelated, expected);
+ }
+ }
+
+ private void assertSinglePendingPhysicalSplit(
+ int firstOwner, int secondOwner, long firstOffset, long
secondOffset) throws Exception {
+ AtomicInteger owner = new AtomicInteger(firstOwner);
+ try (MockSplitEnumeratorContext<KafkaPartitionSplit> context =
+ new MockSplitEnumeratorContext<>(2)) {
+ KafkaSubscriber subscriber = client -> Collections.emptySet();
+ KafkaSourceEnumerator enumerator =
+ new KafkaSourceEnumerator(
+ subscriber,
+ OffsetsInitializer.earliest(),
+ OffsetsInitializer.earliest(),
+ new Properties(),
+ context,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ new KafkaSourceEnumState(
+ Collections.emptySet(),
Collections.emptySet(), true),
+ (split, readers) -> owner.get());
+ TopicPartition partition = new TopicPartition("topic", 2);
+ enumerator.addSplitsBack(
+ Collections.singletonList(new
KafkaPartitionSplit(partition, firstOffset)),
+ firstOwner);
+ owner.set(secondOwner);
+ enumerator.addSplitsBack(
+ Collections.singletonList(new
KafkaPartitionSplit(partition, secondOffset)),
+ secondOwner);
+ for (int reader = 0; reader < 2; reader++) {
+ context.registerReader(new ReaderInfo(reader, "reader-" +
reader));
+ enumerator.addReader(reader);
+ }
+ List<String> emitted =
+ context.getSplitsAssignmentSequence().stream()
+ .flatMap(a -> a.assignment().entrySet().stream())
+ .flatMap(e -> e.getValue().stream().map(s ->
e.getKey() + ":" + s))
+ .collect(Collectors.toList());
+ assertThat(emitted)
+ .as("one pending assignment for physical topic partition
%s", partition)
+ .hasSize(1);
+
assertThat(context.getSplitsAssignmentSequence().get(0).assignment())
+ .containsOnlyKeys(secondOwner);
+
assertThat(context.getSplitsAssignmentSequence().get(0).assignment().get(secondOwner))
+ .containsExactly(new KafkaPartitionSplit(partition,
secondOffset));
+ }
+ }
+}
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumeratorRecoveryDiscoveryRaceTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumeratorRecoveryDiscoveryRaceTest.java
new file mode 100644
index 00000000..06e6ba41
--- /dev/null
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/enumerator/KafkaSourceEnumeratorRecoveryDiscoveryRaceTest.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.connector.kafka.source.enumerator;
+
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.ReaderInfo;
+import org.apache.flink.api.connector.source.mocks.MockSplitEnumeratorContext;
+import org.apache.flink.connector.kafka.source.KafkaSourceOptions;
+import
org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
+import
org.apache.flink.connector.kafka.source.enumerator.subscriber.KafkaSubscriber;
+import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
+import org.apache.flink.mock.Whitebox;
+
+import org.apache.kafka.clients.admin.MockAdminClient;
+import org.apache.kafka.common.TopicPartition;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.lang.reflect.Method;
+import java.util.ArrayDeque;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.SoftAssertions.assertSoftly;
+
+/** Replays delayed initialization callbacks against newer authoritative
checkpoint splits. */
+class KafkaSourceEnumeratorRecoveryDiscoveryRaceTest {
+ private static final TopicPartition PARTITION = new
TopicPartition("topic", 0);
+ private static final long CHECKPOINT_STOP = 1500L;
+ private static final long DISCOVERY_STOP = 2000L;
+
+ @ParameterizedTest
+ @CsvSource({
+ "120, 900, false",
+ "900, 120, false",
+ "120, 900, true",
+ "900, 120, true",
+ "-2, 900, false",
+ "-3, 900, false"
+ })
+ void checkpointSplitWinsOverDelayedDiscoveryRegardlessOfOffsetOrder(
+ long checkpointOffset, long discoveryOffset, boolean
alreadyRegistered)
+ throws Throwable {
+ KafkaPartitionSplit checkpoint =
+ new KafkaPartitionSplit(PARTITION, checkpointOffset,
CHECKPOINT_STOP);
+ try (HoldingCompletionContext context = new HoldingCompletionContext();
+ KafkaSourceEnumerator enumerator =
+ newEnumerator(context, Collections.emptyList(),
discoveryOffset)) {
+ discover(enumerator);
+ // Compute the real worker result first, but hold its real
completion callback.
+ context.runNextOneTimeCallable();
+ assertThat(context.completions).hasSize(1);
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+ if (alreadyRegistered) {
+ registerReader(context, enumerator);
+ }
+ enumerator.addSplitsBack(Collections.singletonList(checkpoint), 0);
+ KafkaSourceEnumState before = enumerator.snapshotState(1L);
+ assertThat(alreadyRegistered ? before.assignedSplits() :
before.unassignedSplits())
+ .containsExactly(checkpoint);
+ assertThat(before.initialDiscoveryFinished()).isFalse();
+
+ context.deliverCompletion();
+ KafkaSourceEnumState afterCallback = enumerator.snapshotState(2L);
+ List<KafkaPartitionSplit> pendingAfterCallback =
+
enumerator.getPendingPartitionSplitAssignment().values().stream()
+ .flatMap(Set::stream)
+ .collect(Collectors.toList());
+ if (!alreadyRegistered) {
+ registerReader(context, enumerator);
+ }
+ KafkaSourceEnumState afterRegistration =
enumerator.snapshotState(3L);
+ // Collect both persisted-state and emitted-assignment failures in
the red baseline.
+ assertSoftly(
+ softly -> {
+
softly.assertThat(afterCallback.initialDiscoveryFinished()).isTrue();
+ softly.assertThat(
+ alreadyRegistered
+ ?
afterCallback.assignedSplits()
+ :
afterCallback.unassignedSplits())
+ .as(
+ "checkpoint split survives the delayed
callback in snapshot state")
+ .containsExactly(checkpoint);
+ softly.assertThat(
+ alreadyRegistered
+ ?
afterCallback.unassignedSplits()
+ :
afterCallback.assignedSplits())
+ .isEmpty();
+ if (alreadyRegistered) {
+ softly.assertThat(pendingAfterCallback).isEmpty();
+ } else {
+
softly.assertThat(pendingAfterCallback).containsExactly(checkpoint);
+ }
+ softly.assertThat(emittedSplits(context))
+ .as(
+ "exactly one checkpoint assignment;
never the stale discovery copy")
+ .containsExactly(checkpoint);
+ softly.assertThat(afterRegistration.assignedSplits())
+ .containsExactly(checkpoint);
+
softly.assertThat(afterRegistration.unassignedSplits()).isEmpty();
+ });
+ assertCompletionBookkeeping(context, enumerator);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void absentOrUnresolvedMigratedPartitionStillAcceptsInitialization(boolean
migrated)
+ throws Throwable {
+ List<KafkaPartitionSplit> restored =
+ migrated
+ ? Collections.singletonList(
+ new KafkaPartitionSplit(PARTITION,
KafkaPartitionSplit.MIGRATED))
+ : Collections.emptyList();
+ KafkaPartitionSplit initialized = new KafkaPartitionSplit(PARTITION,
900L, DISCOVERY_STOP);
+ try (HoldingCompletionContext context = new HoldingCompletionContext();
+ KafkaSourceEnumerator enumerator = newEnumerator(context,
restored, 900L)) {
+ discover(enumerator);
+ context.runNextOneTimeCallable();
+ assertThat(context.completions).hasSize(1);
+ context.deliverCompletion();
+ KafkaSourceEnumState pending = enumerator.snapshotState(1L);
+
assertThat(pending.unassignedSplits()).containsExactly(initialized);
+ assertThat(pending.assignedSplits()).isEmpty();
+ registerReader(context, enumerator);
+ assertThat(emittedSplits(context)).containsExactly(initialized);
+ KafkaSourceEnumState assigned = enumerator.snapshotState(2L);
+ assertThat(assigned.assignedSplits()).containsExactly(initialized);
+ assertThat(assigned.unassignedSplits()).isEmpty();
+ assertCompletionBookkeeping(context, enumerator);
+ }
+ }
+
+ private static KafkaSourceEnumerator newEnumerator(
+ HoldingCompletionContext context,
+ List<KafkaPartitionSplit> restored,
+ long discoveryOffset) {
+ Properties properties = new Properties();
+
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
"0");
+ KafkaSubscriber subscriber = client ->
Collections.singleton(PARTITION);
+ KafkaSourceEnumerator enumerator =
+ new KafkaSourceEnumerator(
+ subscriber,
+ OffsetsInitializer.offsets(
+ Collections.singletonMap(PARTITION,
discoveryOffset)),
+ OffsetsInitializer.offsets(
+ Collections.singletonMap(PARTITION,
DISCOVERY_STOP)),
+ properties,
+ context,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ new KafkaSourceEnumState(Collections.emptyList(),
restored, false));
+ // No broker is involved; the production initializer resolves the
specified offsets.
+ Whitebox.setInternalState(enumerator, "adminClient", new
MockAdminClient());
+ return enumerator;
+ }
+
+ private static void discover(KafkaSourceEnumerator enumerator) throws
Exception {
+ Method discover =
+ KafkaSourceEnumerator.class.getDeclaredMethod(
+ "checkPartitionChanges", Set.class, Throwable.class);
+ discover.setAccessible(true);
+ discover.invoke(enumerator, Collections.singleton(PARTITION), null);
+ }
+
+ private static void registerReader(
+ HoldingCompletionContext context, KafkaSourceEnumerator
enumerator) {
+ context.registerReader(new ReaderInfo(0, "reader-0"));
+ enumerator.addReader(0);
+ }
+
+ private static List<KafkaPartitionSplit>
emittedSplits(HoldingCompletionContext context) {
+ return context.getSplitsAssignmentSequence().stream()
+ .flatMap(assignment ->
assignment.assignment().values().stream())
+ .flatMap(List::stream)
+ .collect(Collectors.toList());
+ }
+
+ private static void assertCompletionBookkeeping(
+ HoldingCompletionContext context, KafkaSourceEnumerator
enumerator) throws Exception {
+
assertThat(enumerator.snapshotState(4L).initialDiscoveryFinished()).isTrue();
+ discover(enumerator);
+ assertThat(context.getOneTimeCallables()).isEmpty();
+ assertThat(context.completions).isEmpty();
+ }
+
+ /** Holds only delivery of the actual initializer result, not its
computation or split state. */
+ private static class HoldingCompletionContext
+ extends MockSplitEnumeratorContext<KafkaPartitionSplit> {
+ private final Deque<Runnable> completions = new ArrayDeque<>();
+
+ private HoldingCompletionContext() {
+ super(1);
+ }
+
+ @Override
+ public <T> void callAsync(Callable<T> callable, BiConsumer<T,
Throwable> handler) {
+ super.callAsync(
+ callable,
+ (result, error) -> completions.addLast(() ->
handler.accept(result, error)));
+ }
+
+ private void deliverCompletion() {
+ completions.removeFirst().run();
+ }
+ }
+}