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 cb40de0e [FLINK-39837] Retain removed dynamic Kafka cluster offsets
(#264)
cb40de0e is described below
commit cb40de0ef41aadbc735dfae31b0d23b48e58f9f3
Author: lnbest0707 <[email protected]>
AuthorDate: Thu Jun 18 08:40:52 2026 -0700
[FLINK-39837] Retain removed dynamic Kafka cluster offsets (#264)
Retain removed DynamicKafkaSource cluster split offsets and enumerator
state in checkpoints for a configurable retention window so a temporarily
removed cluster can resume from checkpointed offsets when it is added back.
---
.../docs/connectors/datastream/dynamic-kafka.md | 12 +
.../docs/connectors/datastream/dynamic-kafka.md | 12 +
.../dynamic/source/DynamicKafkaSourceOptions.java | 27 ++
.../enumerator/DynamicKafkaSourceEnumState.java | 34 +++
.../DynamicKafkaSourceEnumStateSerializer.java | 98 ++++++--
.../enumerator/DynamicKafkaSourceEnumerator.java | 96 +++++--
.../source/reader/DynamicKafkaSourceReader.java | 81 +++++-
.../source/split/DynamicKafkaSourceSplit.java | 39 ++-
.../split/DynamicKafkaSourceSplitSerializer.java | 23 +-
.../kafka/table/DynamicKafkaTableFactory.java | 10 +
.../dynamic/source/DynamicKafkaSourceITTest.java | 278 +++++++++++++++++++++
.../DynamicKafkaSourceEnumStateSerializerTest.java | 11 +-
.../DynamicKafkaSourceEnumeratorTest.java | 157 ++++++++++++
.../reader/DynamicKafkaSourceReaderTest.java | 66 +++++
.../DynamicKafkaSourceSplitSerializerTest.java | 49 +++-
.../kafka/table/DynamicKafkaTableFactoryTest.java | 20 ++
16 files changed, 950 insertions(+), 63 deletions(-)
diff --git a/docs/content.zh/docs/connectors/datastream/dynamic-kafka.md
b/docs/content.zh/docs/connectors/datastream/dynamic-kafka.md
index cdff12f4..8f4adc6d 100644
--- a/docs/content.zh/docs/connectors/datastream/dynamic-kafka.md
+++ b/docs/content.zh/docs/connectors/datastream/dynamic-kafka.md
@@ -229,6 +229,11 @@ swap from one cluster to the new cluster when the service
makes that change in t
Cluster metadata 可以包含每个集群的起始/停止 offsets initializer,用于覆盖全局 builder 配置。
+默认情况下,从 metadata 中移除集群后,后续 checkpoint 也会移除该集群的 split offset。
+如果希望在之后重新加入集群或恢复作业时继续使用这些 offset,请将
+`stream-metadata-removed-cluster-retention-ms` 设置为正数。例如,`604800000`
+会将已移除集群的状态保留七天,之后 source 将不再把它写入 checkpoint。
+
### Additional Properties
There are configuration options in DynamicKafkaSourceOptions that can be
configured in the properties through the builder:
<table class="table table-bordered">
@@ -256,6 +261,13 @@ There are configuration options in
DynamicKafkaSourceOptions that can be configu
<td>Integer</td>
<td>The number of consecutive failures before letting the exception from
Kafka metadata service discovery trigger jobmanager failure and global
failover. The default is one to at least catch startup failures.</td>
</tr>
+ <tr>
+ <td><h5>stream-metadata-removed-cluster-retention-ms</h5></td>
+ <td>required</td>
+ <td style="word-wrap: break-word;">0</td>
+ <td>Long</td>
+ <td>已移除 Kafka 集群的 split offset 和 enumerator 状态继续写入 checkpoint
的时长,单位为毫秒。零值会禁用保留。</td>
+ </tr>
<tr>
<td><h5>stream-enumerator-mode</h5></td>
<td>required</td>
diff --git a/docs/content/docs/connectors/datastream/dynamic-kafka.md
b/docs/content/docs/connectors/datastream/dynamic-kafka.md
index 60b2e504..8df05afb 100644
--- a/docs/content/docs/connectors/datastream/dynamic-kafka.md
+++ b/docs/content/docs/connectors/datastream/dynamic-kafka.md
@@ -238,6 +238,11 @@ swap from one cluster to the new cluster when the service
makes that change in t
Cluster metadata can optionally carry per-cluster starting and stopping
offsets initializers. These
override the global builder configuration for the affected cluster.
+By default, metadata removal also removes that cluster's split offsets from
subsequent checkpoints.
+To keep removed cluster offsets available for a later re-add or restore, set
+`stream-metadata-removed-cluster-retention-ms` to a positive duration. For
example,
+`604800000` retains removed cluster state for seven days before the source
stops checkpointing it.
+
### Additional Properties
There are configuration options in DynamicKafkaSourceOptions that can be
configured in the properties through the builder:
<table class="table table-bordered">
@@ -265,6 +270,13 @@ There are configuration options in
DynamicKafkaSourceOptions that can be configu
<td>Integer</td>
<td>The number of consecutive failures before letting the exception from
Kafka metadata service discovery trigger jobmanager failure and global
failover. The default is one to at least catch startup failures.</td>
</tr>
+ <tr>
+ <td><h5>stream-metadata-removed-cluster-retention-ms</h5></td>
+ <td>required</td>
+ <td style="word-wrap: break-word;">0</td>
+ <td>Long</td>
+ <td>The duration in milliseconds that removed Kafka cluster split
offsets and enumerator state stay in checkpoints. Zero disables retention.</td>
+ </tr>
<tr>
<td><h5>stream-enumerator-mode</h5></td>
<td>required</td>
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceOptions.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceOptions.java
index 56c6d201..845cb7f5 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceOptions.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceOptions.java
@@ -62,6 +62,14 @@ public class DynamicKafkaSourceOptions {
+ "trigger jobmanager failure and global
failover. The default is one to at least catch startup "
+ "failures.");
+ public static final ConfigOption<Long>
STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS =
+ ConfigOptions.key("stream-metadata-removed-cluster-retention-ms")
+ .longType()
+ .defaultValue(0L)
+ .withDescription(
+ "The duration in milliseconds that removed Kafka
cluster split offsets "
+ + "and enumerator state stay in
checkpoints. Zero disables retention.");
+
public static final ConfigOption<String> STREAM_ENUMERATOR_MODE =
ConfigOptions.key("stream-enumerator-mode")
.stringType()
@@ -108,4 +116,23 @@ public class DynamicKafkaSourceOptions {
}
});
}
+
+ @Internal
+ public static long getRemovedClusterStateRetentionMs(Properties props) {
+ long retentionMs =
+ getOption(props, STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS,
Long::parseLong);
+ if (retentionMs < 0) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Invalid %s='%d'. The value must be non-negative.",
+
STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(), retentionMs));
+ }
+
+ return retentionMs;
+ }
+
+ @Internal
+ public static void removeRemovedClusterRetentionOption(Properties props) {
+ props.remove(STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key());
+ }
}
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
index 0dd0a774..bb474d8c 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumState.java
@@ -35,17 +35,27 @@ import java.util.Set;
public class DynamicKafkaSourceEnumState {
private final Set<KafkaStream> kafkaStreams;
private final Map<String, KafkaSourceEnumState> clusterEnumeratorStates;
+ private final Map<String, RetainedClusterState>
retainedClusterEnumeratorStates;
public DynamicKafkaSourceEnumState() {
this.kafkaStreams = new HashSet<>();
this.clusterEnumeratorStates = new HashMap<>();
+ this.retainedClusterEnumeratorStates = new HashMap<>();
}
public DynamicKafkaSourceEnumState(
Set<KafkaStream> kafkaStreams,
Map<String, KafkaSourceEnumState> clusterEnumeratorStates) {
+ this(kafkaStreams, clusterEnumeratorStates, new HashMap<>());
+ }
+
+ public DynamicKafkaSourceEnumState(
+ Set<KafkaStream> kafkaStreams,
+ Map<String, KafkaSourceEnumState> clusterEnumeratorStates,
+ Map<String, RetainedClusterState> retainedClusterEnumeratorStates)
{
this.kafkaStreams = kafkaStreams;
this.clusterEnumeratorStates = clusterEnumeratorStates;
+ this.retainedClusterEnumeratorStates = retainedClusterEnumeratorStates;
}
public Set<KafkaStream> getKafkaStreams() {
@@ -55,4 +65,28 @@ public class DynamicKafkaSourceEnumState {
public Map<String, KafkaSourceEnumState> getClusterEnumeratorStates() {
return clusterEnumeratorStates;
}
+
+ public Map<String, RetainedClusterState>
getRetainedClusterEnumeratorStates() {
+ return retainedClusterEnumeratorStates;
+ }
+
+ /** Kafka enumerator state that stays checkpointed after its cluster
becomes inactive. */
+ public static class RetainedClusterState {
+ private final KafkaSourceEnumState kafkaSourceEnumState;
+ private final long retainedUntilMs;
+
+ public RetainedClusterState(
+ KafkaSourceEnumState kafkaSourceEnumState, long
retainedUntilMs) {
+ this.kafkaSourceEnumState = kafkaSourceEnumState;
+ this.retainedUntilMs = retainedUntilMs;
+ }
+
+ public KafkaSourceEnumState getKafkaSourceEnumState() {
+ return kafkaSourceEnumState;
+ }
+
+ public long getRetainedUntilMs() {
+ return retainedUntilMs;
+ }
+ }
}
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
index b7f0b49a..1cec2a18 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializer.java
@@ -50,6 +50,7 @@ public class DynamicKafkaSourceEnumStateSerializer
private static final int VERSION_1 = 1;
private static final int VERSION_2 = 2;
+ private static final int VERSION_3 = 3;
private final KafkaSourceEnumStateSerializer
kafkaSourceEnumStateSerializer;
@@ -59,7 +60,7 @@ public class DynamicKafkaSourceEnumStateSerializer
@Override
public int getVersion() {
- return VERSION_2;
+ return VERSION_3;
}
@Override
@@ -74,19 +75,17 @@ public class DynamicKafkaSourceEnumStateSerializer
state.getClusterEnumeratorStates();
out.writeInt(kafkaSourceEnumStateSerializer.getVersion());
- // write sub enumerator states
- out.writeInt(clusterEnumeratorStates.size());
- for (Map.Entry<String, KafkaSourceEnumState>
clusterEnumeratorState :
- clusterEnumeratorStates.entrySet()) {
- String kafkaClusterId = clusterEnumeratorState.getKey();
- out.writeUTF(kafkaClusterId);
- byte[] bytes =
-
kafkaSourceEnumStateSerializer.serialize(clusterEnumeratorState.getValue());
- // we need to know the exact size of the byte array since
- // KafkaSourceEnumStateSerializer
- // will throw exception if there are leftover unread bytes in
deserialization.
- out.writeInt(bytes.length);
- out.write(bytes);
+ writeClusterEnumeratorStates(clusterEnumeratorStates, out);
+
+ Map<String, DynamicKafkaSourceEnumState.RetainedClusterState>
+ retainedClusterEnumeratorStates =
state.getRetainedClusterEnumeratorStates();
+ out.writeInt(retainedClusterEnumeratorStates.size());
+ for (Map.Entry<String,
DynamicKafkaSourceEnumState.RetainedClusterState>
+ retainedClusterEnumeratorState :
retainedClusterEnumeratorStates.entrySet()) {
+ out.writeUTF(retainedClusterEnumeratorState.getKey());
+
out.writeLong(retainedClusterEnumeratorState.getValue().getRetainedUntilMs());
+ writeKafkaSourceEnumState(
+
retainedClusterEnumeratorState.getValue().getKafkaSourceEnumState(), out);
}
return baos.toByteArray();
@@ -96,7 +95,7 @@ public class DynamicKafkaSourceEnumStateSerializer
@Override
public DynamicKafkaSourceEnumState deserialize(int version, byte[]
serialized)
throws IOException {
- if (version != VERSION_1 && version != VERSION_2) {
+ if (version != VERSION_1 && version != VERSION_2 && version !=
VERSION_3) {
throw new IOException(
String.format(
"The bytes are serialized with version %d, "
@@ -112,21 +111,70 @@ public class DynamicKafkaSourceEnumStateSerializer
Map<String, KafkaSourceEnumState> clusterEnumeratorStates = new
HashMap<>();
int kafkaSourceEnumStateSerializerVersion = in.readInt();
- int clusterEnumeratorStateMapSize = in.readInt();
- for (int i = 0; i < clusterEnumeratorStateMapSize; i++) {
- String kafkaClusterId = in.readUTF();
- int byteArraySize = in.readInt();
- KafkaSourceEnumState kafkaSourceEnumState =
- kafkaSourceEnumStateSerializer.deserialize(
- kafkaSourceEnumStateSerializerVersion,
- readNBytes(in, byteArraySize));
- clusterEnumeratorStates.put(kafkaClusterId,
kafkaSourceEnumState);
+ readClusterEnumeratorStates(
+ in, kafkaSourceEnumStateSerializerVersion,
clusterEnumeratorStates);
+
+ Map<String, DynamicKafkaSourceEnumState.RetainedClusterState>
+ retainedClusterEnumeratorStates = new HashMap<>();
+ if (version == VERSION_3) {
+ int retainedClusterEnumeratorStateMapSize = in.readInt();
+ for (int i = 0; i < retainedClusterEnumeratorStateMapSize;
i++) {
+ String kafkaClusterId = in.readUTF();
+ long retainedUntilMs = in.readLong();
+ KafkaSourceEnumState kafkaSourceEnumState =
+ readKafkaSourceEnumState(in,
kafkaSourceEnumStateSerializerVersion);
+ retainedClusterEnumeratorStates.put(
+ kafkaClusterId,
+ new
DynamicKafkaSourceEnumState.RetainedClusterState(
+ kafkaSourceEnumState, retainedUntilMs));
+ }
}
- return new DynamicKafkaSourceEnumState(kafkaStreams,
clusterEnumeratorStates);
+ return new DynamicKafkaSourceEnumState(
+ kafkaStreams, clusterEnumeratorStates,
retainedClusterEnumeratorStates);
+ }
+ }
+
+ private void writeClusterEnumeratorStates(
+ Map<String, KafkaSourceEnumState> clusterEnumeratorStates,
DataOutputStream out)
+ throws IOException {
+ out.writeInt(clusterEnumeratorStates.size());
+ for (Map.Entry<String, KafkaSourceEnumState> clusterEnumeratorState :
+ clusterEnumeratorStates.entrySet()) {
+ out.writeUTF(clusterEnumeratorState.getKey());
+ writeKafkaSourceEnumState(clusterEnumeratorState.getValue(), out);
}
}
+ private void readClusterEnumeratorStates(
+ DataInputStream in,
+ int kafkaSourceEnumStateSerializerVersion,
+ Map<String, KafkaSourceEnumState> clusterEnumeratorStates)
+ throws IOException {
+ int clusterEnumeratorStateMapSize = in.readInt();
+ for (int i = 0; i < clusterEnumeratorStateMapSize; i++) {
+ String kafkaClusterId = in.readUTF();
+ clusterEnumeratorStates.put(
+ kafkaClusterId,
+ readKafkaSourceEnumState(in,
kafkaSourceEnumStateSerializerVersion));
+ }
+ }
+
+ private void writeKafkaSourceEnumState(
+ KafkaSourceEnumState kafkaSourceEnumState, DataOutputStream out)
throws IOException {
+ byte[] bytes =
kafkaSourceEnumStateSerializer.serialize(kafkaSourceEnumState);
+ // KafkaSourceEnumStateSerializer rejects leftover unread bytes on
deserialization.
+ out.writeInt(bytes.length);
+ out.write(bytes);
+ }
+
+ private KafkaSourceEnumState readKafkaSourceEnumState(
+ DataInputStream in, int kafkaSourceEnumStateSerializerVersion)
throws IOException {
+ int byteArraySize = in.readInt();
+ return kafkaSourceEnumStateSerializer.deserialize(
+ kafkaSourceEnumStateSerializerVersion, readNBytes(in,
byteArraySize));
+ }
+
private void serializeV2(Set<KafkaStream> kafkaStreams, DataOutputStream
out)
throws IOException {
out.writeInt(kafkaStreams.size());
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 ba20fa98..cc3a34f7 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
@@ -94,11 +94,14 @@ public class DynamicKafkaSourceEnumerator
// options
private final long kafkaMetadataServiceDiscoveryIntervalMs;
private final int kafkaMetadataServiceDiscoveryFailureThreshold;
+ private final long removedClusterStateRetentionMs;
// state
private int kafkaMetadataServiceDiscoveryFailureCount;
private Map<String, Set<String>> latestClusterTopicsMap;
private Set<KafkaStream> latestKafkaStreams;
+ private Map<String, DynamicKafkaSourceEnumState.RetainedClusterState>
+ retainedClusterEnumeratorStates;
private boolean firstDiscoveryComplete;
public DynamicKafkaSourceEnumerator(
@@ -154,6 +157,8 @@ public class DynamicKafkaSourceEnumerator
properties,
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_FAILURE_THRESHOLD,
Integer::parseInt);
+ this.removedClusterStateRetentionMs =
+
DynamicKafkaSourceOptions.getRemovedClusterStateRetentionMs(properties);
this.kafkaMetadataServiceDiscoveryFailureCount = 0;
this.firstDiscoveryComplete = false;
@@ -173,6 +178,9 @@ public class DynamicKafkaSourceEnumerator
this.latestKafkaStreams =
refreshRestoredClusterPropertiesFromMetadataService(this.latestKafkaStreams);
}
+ this.retainedClusterEnumeratorStates =
+ new
HashMap<>(dynamicKafkaSourceEnumState.getRetainedClusterEnumeratorStates());
+ pruneExpiredRetainedClusterEnumeratorStates();
Map<String, Properties> clusterProperties = new HashMap<>();
Map<String, OffsetsInitializer> clusterStartingOffsets = new
HashMap<>();
@@ -342,6 +350,7 @@ public class DynamicKafkaSourceEnumerator
firstDiscoveryComplete = true;
Set<KafkaStream> handledFetchKafkaStreams =
handleFetchSubscribedStreamsError(fetchedKafkaStreams, t);
+ pruneExpiredRetainedClusterEnumeratorStates();
Map<String, Set<String>> newClustersTopicsMap = new HashMap<>();
Map<String, Properties> clusterProperties = new HashMap<>();
@@ -395,29 +404,34 @@ public class DynamicKafkaSourceEnumerator
latestKafkaStreams = handledFetchKafkaStreams;
sendMetadataUpdateEventToAvailableReaders();
+ retainRemovedClusterEnumeratorStates(
+ dynamicKafkaSourceEnumState.getClusterEnumeratorStates(),
+ latestClusterTopicsMap.keySet());
+
// create enumerators
Set<String> activeSplitIds = new HashSet<>();
for (Entry<String, Set<String>> activeClusterTopics :
latestClusterTopicsMap.entrySet()) {
+ String kafkaClusterId = activeClusterTopics.getKey();
KafkaSourceEnumState kafkaSourceEnumState =
- dynamicKafkaSourceEnumState
- .getClusterEnumeratorStates()
- .get(activeClusterTopics.getKey());
+
dynamicKafkaSourceEnumState.getClusterEnumeratorStates().get(kafkaClusterId);
+ if (kafkaSourceEnumState == null) {
+ DynamicKafkaSourceEnumState.RetainedClusterState
retainedClusterState =
+ retainedClusterEnumeratorStates.remove(kafkaClusterId);
+ if (retainedClusterState != null) {
+ kafkaSourceEnumState =
retainedClusterState.getKafkaSourceEnumState();
+ }
+ } else {
+ retainedClusterEnumeratorStates.remove(kafkaClusterId);
+ }
final KafkaSourceEnumState newKafkaSourceEnumState;
if (kafkaSourceEnumState != null) {
- final Set<String> activeTopics =
activeClusterTopics.getValue();
-
- // filter out removed topics
Set<SplitAndAssignmentStatus> partitions =
- kafkaSourceEnumState.splits().stream()
- .filter(tp ->
activeTopics.contains(tp.split().getTopic()))
- .collect(Collectors.toSet());
+ filterStateByTopics(kafkaSourceEnumState,
activeClusterTopics.getValue());
partitions.forEach(
splitStatus ->
activeSplitIds.add(
- toDynamicSplitId(
- activeClusterTopics.getKey(),
- splitStatus.split())));
+ toDynamicSplitId(kafkaClusterId,
splitStatus.split())));
newKafkaSourceEnumState =
new KafkaSourceEnumState(
@@ -426,15 +440,17 @@ public class DynamicKafkaSourceEnumerator
newKafkaSourceEnumState = new
KafkaSourceEnumState(Collections.emptySet(), false);
}
- // restarts enumerator from state using only the active topic
partitions, to avoid
- // sending duplicate splits from enumerator
+ // Restart the enumerator from the active topic partitions already
known in state. The
+ // reader restores those splits from its own checkpointed offsets
during metadata
+ // reconciliation, so the enumerator must not send them again as
newly discovered
+ // splits.
createEnumeratorWithAssignedTopicPartitions(
- activeClusterTopics.getKey(),
+ kafkaClusterId,
activeClusterTopics.getValue(),
newKafkaSourceEnumState,
- clusterProperties.get(activeClusterTopics.getKey()),
- clusterStartingOffsets.get(activeClusterTopics.getKey()),
- clusterStoppingOffsets.get(activeClusterTopics.getKey()));
+ clusterProperties.get(kafkaClusterId),
+ clusterStartingOffsets.get(kafkaClusterId),
+ clusterStoppingOffsets.get(kafkaClusterId));
}
splitAssignmentStrategy.onMetadataRefresh(activeSplitIds);
@@ -516,6 +532,7 @@ public class DynamicKafkaSourceEnumerator
Properties consumerProps = new Properties();
KafkaPropertiesUtil.copyProperties(fetchedProperties, consumerProps);
KafkaPropertiesUtil.copyProperties(properties, consumerProps);
+
DynamicKafkaSourceOptions.removeRemovedClusterRetentionOption(consumerProps);
KafkaPropertiesUtil.setClientIdPrefix(consumerProps, kafkaClusterId);
consumerProps.setProperty(
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,
@@ -642,6 +659,7 @@ public class DynamicKafkaSourceEnumerator
*/
@Override
public DynamicKafkaSourceEnumState snapshotState(long checkpointId) throws
Exception {
+ pruneExpiredRetainedClusterEnumeratorStates();
Map<String, KafkaSourceEnumState> subEnumeratorStateByCluster = new
HashMap<>();
boolean isCheckpointSnapshot = checkpointId >= 0;
@@ -664,7 +682,47 @@ public class DynamicKafkaSourceEnumerator
}
}
- return new DynamicKafkaSourceEnumState(latestKafkaStreams,
subEnumeratorStateByCluster);
+ return new DynamicKafkaSourceEnumState(
+ latestKafkaStreams,
+ subEnumeratorStateByCluster,
+ new HashMap<>(retainedClusterEnumeratorStates));
+ }
+
+ private void retainRemovedClusterEnumeratorStates(
+ Map<String, KafkaSourceEnumState> activeClusterEnumeratorStates,
+ Set<String> activeKafkaClusterIds) {
+ if (removedClusterStateRetentionMs <= 0) {
+ return;
+ }
+
+ long retainedUntilMs = System.currentTimeMillis() +
removedClusterStateRetentionMs;
+ activeClusterEnumeratorStates.entrySet().stream()
+ .filter(entry ->
!activeKafkaClusterIds.contains(entry.getKey()))
+ .forEach(
+ entry ->
+ retainedClusterEnumeratorStates.put(
+ entry.getKey(),
+ new
DynamicKafkaSourceEnumState.RetainedClusterState(
+ entry.getValue(),
retainedUntilMs)));
+ }
+
+ private void pruneExpiredRetainedClusterEnumeratorStates() {
+ if (removedClusterStateRetentionMs <= 0) {
+ retainedClusterEnumeratorStates.clear();
+ return;
+ }
+
+ long currentTimeMillis = System.currentTimeMillis();
+ retainedClusterEnumeratorStates
+ .entrySet()
+ .removeIf(entry -> entry.getValue().getRetainedUntilMs() <=
currentTimeMillis);
+ }
+
+ private Set<SplitAndAssignmentStatus> filterStateByTopics(
+ KafkaSourceEnumState kafkaSourceEnumState, Set<String>
activeTopics) {
+ return kafkaSourceEnumState.splits().stream()
+ .filter(splitStatus ->
activeTopics.contains(splitStatus.split().getTopic()))
+ .collect(Collectors.toSet());
}
private static String
summarizeSplitOffsets(Collection<KafkaPartitionSplit> splits) {
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
index 3e4d3cf6..f5103392 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReader.java
@@ -30,6 +30,7 @@ import
org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
import
org.apache.flink.connector.base.source.reader.synchronization.FutureCompletingBlockingQueue;
import org.apache.flink.connector.kafka.dynamic.metadata.ClusterMetadata;
import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+import
org.apache.flink.connector.kafka.dynamic.source.DynamicKafkaSourceOptions;
import org.apache.flink.connector.kafka.dynamic.source.GetMetadataUpdateEvent;
import org.apache.flink.connector.kafka.dynamic.source.MetadataUpdateEvent;
import
org.apache.flink.connector.kafka.dynamic.source.metrics.KafkaClusterMetricGroup;
@@ -94,6 +95,8 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
private final Map<String, Properties> clustersProperties;
private final List<DynamicKafkaSourceSplit> pendingSplits;
private final Set<String> pendingSplitOutputReleases;
+ private final List<DynamicKafkaSourceSplit> retainedSplits;
+ private final long removedClusterStateRetentionMs;
private MultipleFuturesAvailabilityHelper availabilityHelper;
private int availabilityHelperSize;
@@ -117,6 +120,9 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
.addGroup(KafkaClusterMetricGroup.DYNAMIC_KAFKA_SOURCE_METRIC_GROUP);
this.kafkaClusterMetricGroupManager = new
KafkaClusterMetricGroupManager();
this.pendingSplits = new ArrayList<>();
+ this.retainedSplits = new ArrayList<>();
+ this.removedClusterStateRetentionMs =
+
DynamicKafkaSourceOptions.getRemovedClusterStateRetentionMs(properties);
this.availabilityHelper =
new
MultipleFuturesAvailabilityHelper(this.availabilityHelperSize = 0);
this.isNoMoreSplits = false;
@@ -249,6 +255,7 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
"Received source event {}: subtask={}",
sourceEvent,
readerContext.getIndexOfSubtask());
+ pruneExpiredRetainedSplits();
Set<KafkaStream> newKafkaStreams = ((MetadataUpdateEvent)
sourceEvent).getKafkaStreams();
Map<String, Set<String>> newClustersAndTopics = new HashMap<>();
Map<String, Properties> newClustersProperties = new HashMap<>();
@@ -285,6 +292,7 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
currentSplitState);
Map<String, Set<String>> currentMetadataFromState = new HashMap<>();
Map<String, List<KafkaPartitionSplit>> filteredNewClusterSplitStateMap
= new HashMap<>();
+ long retainedUntilMs = System.currentTimeMillis() +
removedClusterStateRetentionMs;
// the data structures above
for (DynamicKafkaSourceSplit split : currentSplitState) {
@@ -301,9 +309,13 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
.add(split);
} else {
releaseOrDeferSplitOutput(split.splitId());
+ if (shouldRetainSplit(split, newClustersAndTopics)) {
+ retainedSplits.add(split.retainUntil(retainedUntilMs));
+ }
logger.info("Skipping outdated split due to metadata changes:
{}", split);
}
}
+ reactivateRetainedSplits(newClustersAndTopics,
filteredNewClusterSplitStateMap);
// only restart if there was metadata change to handle duplicate
MetadataUpdateEvent from
// enumerator. We can possibly only restart the readers whose metadata
has changed but that
@@ -349,19 +361,11 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
// update event arrives. Splits in state could be
old and it's possible
// to not have another metadata update event, so
need to filter the
// splits at this point.
- .filter(
- pendingSplit -> {
- boolean splitValid =
- isSplitForActiveClusters(
- pendingSplit,
newClustersAndTopics);
- if (!splitValid) {
-
releaseOrDeferSplitOutput(pendingSplit.splitId());
- logger.info(
- "Removing invalid split
for reader: {}",
- pendingSplit);
- }
- return splitValid;
- })
+ .map(
+ pendingSplit ->
+ validatePendingSplit(
+ pendingSplit,
newClustersAndTopics))
+ .filter(split -> split != null)
.collect(Collectors.toList());
addSplits(validPendingSplits);
@@ -400,10 +404,12 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
@Override
public List<DynamicKafkaSourceSplit> snapshotState(long checkpointId) {
+ pruneExpiredRetainedSplits();
List<DynamicKafkaSourceSplit> splits =
snapshotStateFromAllReaders(checkpointId);
// pending splits should be typically empty, since we do not add
splits to pending splits if
// reader has started
+ splits.addAll(retainedSplits);
splits.addAll(pendingSplits);
return splits;
}
@@ -488,6 +494,7 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
kafkaClusterId,
clustersProperties.keySet()),
readerSpecificProperties);
+
DynamicKafkaSourceOptions.removeRemovedClusterRetentionOption(readerSpecificProperties);
KafkaPropertiesUtil.setClientIdPrefix(readerSpecificProperties,
kafkaClusterId);
// layer a kafka cluster group to distinguish metrics by cluster
@@ -625,6 +632,54 @@ public class DynamicKafkaSourceReader<T> implements
SourceReader<T, DynamicKafka
clustersProperties.clear();
}
+ private void reactivateRetainedSplits(
+ Map<String, Set<String>> newClustersAndTopics,
+ Map<String, List<KafkaPartitionSplit>>
filteredNewClusterSplitStateMap) {
+ List<DynamicKafkaSourceSplit> stillRetainedSplits = new ArrayList<>();
+ for (DynamicKafkaSourceSplit retainedSplit : retainedSplits) {
+ if (isSplitForActiveClusters(retainedSplit, newClustersAndTopics))
{
+ filteredNewClusterSplitStateMap
+ .computeIfAbsent(
+ retainedSplit.getKafkaClusterId(), (ignore) ->
new ArrayList<>())
+ .add(retainedSplit.clearRetention());
+ } else {
+ stillRetainedSplits.add(retainedSplit);
+ }
+ }
+
+ retainedSplits.clear();
+ retainedSplits.addAll(stillRetainedSplits);
+ }
+
+ private DynamicKafkaSourceSplit validatePendingSplit(
+ DynamicKafkaSourceSplit pendingSplit, Map<String, Set<String>>
newClustersAndTopics) {
+ if (isSplitForActiveClusters(pendingSplit, newClustersAndTopics)) {
+ return pendingSplit.clearRetention();
+ }
+
+ releaseOrDeferSplitOutput(pendingSplit.splitId());
+ if (pendingSplit.isRetained(System.currentTimeMillis())) {
+ retainedSplits.add(pendingSplit);
+ } else {
+ logger.info("Removing invalid split for reader: {}", pendingSplit);
+ }
+
+ return null;
+ }
+
+ private boolean shouldRetainSplit(
+ DynamicKafkaSourceSplit split, Map<String, Set<String>>
newClustersAndTopics) {
+ return removedClusterStateRetentionMs > 0
+ &&
!newClustersAndTopics.containsKey(split.getKafkaClusterId());
+ }
+
+ private void pruneExpiredRetainedSplits() {
+ long currentTimeMillis = System.currentTimeMillis();
+ retainedSplits.removeIf(
+ split -> split.isRetained() &&
!split.isRetained(currentTimeMillis));
+ pendingSplits.removeIf(split -> split.isRetained() &&
!split.isRetained(currentTimeMillis));
+ }
+
static Configuration toConfiguration(Properties props) {
Configuration config = new Configuration();
props.stringPropertyNames().forEach(key -> config.setString(key,
props.getProperty(key)));
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplit.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplit.java
index b98cc805..85da9736 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplit.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplit.java
@@ -21,6 +21,8 @@ package org.apache.flink.connector.kafka.dynamic.source.split;
import org.apache.flink.annotation.Internal;
import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
+import javax.annotation.Nullable;
+
import java.util.Objects;
/** Split that wraps {@link KafkaPartitionSplit} with Kafka cluster
information. */
@@ -29,14 +31,23 @@ public class DynamicKafkaSourceSplit extends
KafkaPartitionSplit {
private final String kafkaClusterId;
private final KafkaPartitionSplit kafkaPartitionSplit;
+ @Nullable private final Long retainedUntilMs;
public DynamicKafkaSourceSplit(String kafkaClusterId, KafkaPartitionSplit
kafkaPartitionSplit) {
+ this(kafkaClusterId, kafkaPartitionSplit, null);
+ }
+
+ public DynamicKafkaSourceSplit(
+ String kafkaClusterId,
+ KafkaPartitionSplit kafkaPartitionSplit,
+ @Nullable Long retainedUntilMs) {
super(
kafkaPartitionSplit.getTopicPartition(),
kafkaPartitionSplit.getStartingOffset(),
kafkaPartitionSplit.getStoppingOffset().orElse(NO_STOPPING_OFFSET));
this.kafkaClusterId = kafkaClusterId;
this.kafkaPartitionSplit = kafkaPartitionSplit;
+ this.retainedUntilMs = retainedUntilMs;
}
@Override
@@ -52,6 +63,27 @@ public class DynamicKafkaSourceSplit extends
KafkaPartitionSplit {
return kafkaPartitionSplit;
}
+ @Nullable
+ public Long getRetainedUntilMs() {
+ return retainedUntilMs;
+ }
+
+ public boolean isRetained() {
+ return retainedUntilMs != null;
+ }
+
+ public boolean isRetained(long currentTimeMillis) {
+ return retainedUntilMs != null && retainedUntilMs > currentTimeMillis;
+ }
+
+ public DynamicKafkaSourceSplit retainUntil(long newRetainedUntilMs) {
+ return new DynamicKafkaSourceSplit(kafkaClusterId,
kafkaPartitionSplit, newRetainedUntilMs);
+ }
+
+ public DynamicKafkaSourceSplit clearRetention() {
+ return new DynamicKafkaSourceSplit(kafkaClusterId,
kafkaPartitionSplit);
+ }
+
@Override
public String toString() {
return "DynamicKafkaSourceSplit{"
@@ -60,6 +92,8 @@ public class DynamicKafkaSourceSplit extends
KafkaPartitionSplit {
+ '\''
+ ", kafkaPartitionSplit="
+ kafkaPartitionSplit
+ + ", retainedUntilMs="
+ + retainedUntilMs
+ '}';
}
@@ -76,11 +110,12 @@ public class DynamicKafkaSourceSplit extends
KafkaPartitionSplit {
}
DynamicKafkaSourceSplit that = (DynamicKafkaSourceSplit) o;
return Objects.equals(kafkaClusterId, that.kafkaClusterId)
- && Objects.equals(kafkaPartitionSplit,
that.kafkaPartitionSplit);
+ && Objects.equals(kafkaPartitionSplit,
that.kafkaPartitionSplit)
+ && Objects.equals(retainedUntilMs, that.retainedUntilMs);
}
@Override
public int hashCode() {
- return Objects.hash(super.hashCode(), kafkaClusterId,
kafkaPartitionSplit);
+ return Objects.hash(super.hashCode(), kafkaClusterId,
kafkaPartitionSplit, retainedUntilMs);
}
}
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializer.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializer.java
index 69648374..9c7ed7d4 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializer.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializer.java
@@ -35,6 +35,7 @@ public class DynamicKafkaSourceSplitSerializer
implements SimpleVersionedSerializer<DynamicKafkaSourceSplit> {
private static final int VERSION_1 = 1;
+ private static final int VERSION_2 = 2;
private final KafkaPartitionSplitSerializer kafkaPartitionSplitSerializer;
@@ -44,7 +45,7 @@ public class DynamicKafkaSourceSplitSerializer
@Override
public int getVersion() {
- return VERSION_1;
+ return VERSION_2;
}
@Override
@@ -52,6 +53,11 @@ public class DynamicKafkaSourceSplitSerializer
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(baos)) {
out.writeUTF(split.getKafkaClusterId());
+ Long retainedUntilMs = split.getRetainedUntilMs();
+ out.writeBoolean(retainedUntilMs != null);
+ if (retainedUntilMs != null) {
+ out.writeLong(retainedUntilMs);
+ }
out.writeInt(kafkaPartitionSplitSerializer.getVersion());
out.write(kafkaPartitionSplitSerializer.serialize(split.getKafkaPartitionSplit()));
out.flush();
@@ -61,14 +67,27 @@ public class DynamicKafkaSourceSplitSerializer
@Override
public DynamicKafkaSourceSplit deserialize(int version, byte[] serialized)
throws IOException {
+ if (version != VERSION_1 && version != VERSION_2) {
+ throw new IOException(
+ String.format(
+ "The bytes are serialized with version %d, "
+ + "while this deserializer only supports
version up to %d",
+ version, getVersion()));
+ }
+
try (ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
DataInputStream in = new DataInputStream(bais)) {
String kafkaClusterId = in.readUTF();
+ Long retainedUntilMs = null;
+ if (version == VERSION_2 && in.readBoolean()) {
+ retainedUntilMs = in.readLong();
+ }
int kafkaPartitionSplitSerializerVersion = in.readInt();
KafkaPartitionSplit kafkaPartitionSplit =
kafkaPartitionSplitSerializer.deserialize(
kafkaPartitionSplitSerializerVersion,
in.readAllBytes());
- return new DynamicKafkaSourceSplit(kafkaClusterId,
kafkaPartitionSplit);
+ return new DynamicKafkaSourceSplit(
+ kafkaClusterId, kafkaPartitionSplit, retainedUntilMs);
}
}
}
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactory.java
b/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactory.java
index 99304838..5514533f 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactory.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactory.java
@@ -122,6 +122,7 @@ public class DynamicKafkaTableFactory implements
DynamicTableSourceFactory {
options.add(SCAN_PARALLELISM);
options.add(DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS);
options.add(DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_FAILURE_THRESHOLD);
+
options.add(DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS);
options.add(DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE);
return options;
}
@@ -217,6 +218,15 @@ public class DynamicKafkaTableFactory implements
DynamicTableSourceFactory {
.STREAM_METADATA_DISCOVERY_FAILURE_THRESHOLD
.key(),
Integer.toString(value)));
+ tableOptions
+
.getOptional(DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS)
+ .ifPresent(
+ value ->
+ properties.setProperty(
+ DynamicKafkaSourceOptions
+
.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS
+ .key(),
+ Long.toString(value)));
tableOptions
.getOptional(DynamicKafkaSourceOptions.STREAM_ENUMERATOR_MODE)
.ifPresent(
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
index c95f2f9a..a28e4412 100644
---
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/DynamicKafkaSourceITTest.java
@@ -18,14 +18,18 @@
package org.apache.flink.connector.kafka.dynamic.source;
+import org.apache.flink.api.common.JobStatus;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.connector.source.Boundedness;
import org.apache.flink.api.connector.source.ReaderInfo;
import org.apache.flink.api.connector.source.SplitEnumerator;
import org.apache.flink.api.connector.source.SplitsAssignment;
import org.apache.flink.api.connector.source.mocks.MockSplitEnumeratorContext;
+import org.apache.flink.configuration.CheckpointingOptions;
import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.ExternalizedCheckpointRetention;
import org.apache.flink.configuration.RestartStrategyOptions;
+import org.apache.flink.configuration.StateBackendOptions;
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;
@@ -52,15 +56,21 @@ import
org.apache.flink.connector.testframe.junit.annotations.TestExternalSystem
import org.apache.flink.connector.testframe.junit.annotations.TestSemantics;
import org.apache.flink.connector.testframe.testsuites.SourceTestSuiteBase;
import org.apache.flink.core.execution.CheckpointingMode;
+import org.apache.flink.core.execution.JobClient;
import org.apache.flink.core.testutils.CommonTestUtils;
import org.apache.flink.metrics.MetricGroup;
+import
org.apache.flink.runtime.messages.FlinkJobTerminatedWithoutCancellationException;
import org.apache.flink.runtime.testutils.InMemoryReporter;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.functions.sink.legacy.RichSinkFunction;
import
org.apache.flink.streaming.connectors.kafka.DynamicKafkaSourceTestHelper;
import org.apache.flink.streaming.connectors.kafka.KafkaTestBase;
import org.apache.flink.test.util.MiniClusterWithClientResource;
+import org.apache.flink.test.util.TestUtils;
+import org.apache.flink.testutils.junit.SharedObjectsExtension;
+import org.apache.flink.testutils.junit.SharedReference;
import org.apache.flink.util.CloseableIterator;
import com.google.common.collect.ImmutableList;
@@ -76,6 +86,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
@@ -92,12 +103,16 @@ import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
+import static
org.apache.flink.configuration.StateRecoveryOptions.SAVEPOINT_PATH;
import static
org.apache.flink.connector.kafka.dynamic.source.metrics.KafkaClusterMetricGroup.DYNAMIC_KAFKA_SOURCE_METRIC_GROUP;
import static org.assertj.core.api.Assertions.assertThat;
@@ -120,6 +135,9 @@ class DynamicKafkaSourceITTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DynamicKafkaSourceSpecificTests {
+ @RegisterExtension
+ final SharedObjectsExtension sharedObjects =
SharedObjectsExtension.create();
+
@BeforeAll
void beforeAll() throws Throwable {
DynamicKafkaSourceTestHelper.setup();
@@ -714,6 +732,97 @@ class DynamicKafkaSourceITTest {
.collect(Collectors.toList()));
}
+ @Test
+ void
testRemovedClusterOffsetsRetainedAcrossCheckpointRestoreAndRescale() throws
Throwable {
+ int kafkaClusterIdx = 0;
+ String topic = "test-retained-removed-cluster-" +
System.currentTimeMillis();
+ DynamicKafkaSourceTestHelper.createTopic(kafkaClusterIdx, topic,
NUM_PARTITIONS);
+
+ String testStreamId = "test-retained-removed-cluster-stream";
+ File metadataFile = File.createTempFile(testDir.getPath() +
"/metadata", ".yaml");
+ writeClusterMetadataToFile(
+ metadataFile,
+ testStreamId,
+ topic,
+ ImmutableList.of(
+
DynamicKafkaSourceTestHelper.getKafkaClusterTestEnvMetadata(
+ kafkaClusterIdx)));
+
+ SharedReference<List<Integer>> collectedRecords =
sharedObjects.add(new ArrayList<>());
+ Configuration checkpointConfiguration =
createCheckpointConfiguration();
+ JobClient phase1JobClient = null;
+ JobClient phase2JobClient = null;
+ try {
+ int stage1End =
+ DynamicKafkaSourceTestHelper.produceToKafka(
+ kafkaClusterIdx, topic, NUM_PARTITIONS,
NUM_RECORDS_PER_SPLIT, 0);
+ phase1JobClient =
+ startRetainedRemovedClusterJob(
+ checkpointConfiguration,
+ metadataFile,
+ testStreamId,
+ collectedRecords,
+ 1);
+ waitForCollectedRecords(
+ collectedRecords,
+ phase1JobClient,
+ stage1End,
+ "Could not read initial retained-cluster records
before removal");
+ assertThat(copyCollectedRecords(collectedRecords))
+ .containsExactlyInAnyOrderElementsOf(
+ IntStream.range(0,
stage1End).boxed().collect(Collectors.toList()));
+
+ File checkpointBeforeRemoval =
waitForCompletedCheckpoint(null);
+ writeClusterMetadataToFile(
+ metadataFile, testStreamId, topic,
Collections.emptyList());
+ waitForKafkaClusterMetricsToDisappear(
+ kafkaClusterTestEnvMetadata0.getKafkaClusterId());
+ waitForCompletedCheckpoint(checkpointBeforeRemoval);
+
+ phase1JobClient.cancel().get(30, TimeUnit.SECONDS);
+ phase1JobClient = null;
+ // The selected checkpoint can be subsumed before cancellation
completes.
+ File retainedCheckpoint = waitForCompletedCheckpoint(null);
+
+ writeClusterMetadataToFile(
+ metadataFile,
+ testStreamId,
+ topic,
+ ImmutableList.of(
+
DynamicKafkaSourceTestHelper.getKafkaClusterTestEnvMetadata(
+ kafkaClusterIdx)));
+ int stage2End =
+ DynamicKafkaSourceTestHelper.produceToKafka(
+ kafkaClusterIdx,
+ topic,
+ NUM_PARTITIONS,
+ NUM_RECORDS_PER_SPLIT,
+ stage1End);
+
+ Configuration restoreConfiguration = new
Configuration(checkpointConfiguration);
+ restoreConfiguration.set(SAVEPOINT_PATH,
retainedCheckpoint.toURI().toString());
+ phase2JobClient =
+ startRetainedRemovedClusterJob(
+ restoreConfiguration,
+ metadataFile,
+ testStreamId,
+ collectedRecords,
+ 2);
+ waitForCollectedRecords(
+ collectedRecords,
+ phase2JobClient,
+ stage2End,
+ "Could not read records after retained cluster re-add
and restore");
+
+ assertThat(copyCollectedRecords(collectedRecords))
+ .containsExactlyInAnyOrderElementsOf(
+ IntStream.range(0,
stage2End).boxed().collect(Collectors.toList()));
+ } finally {
+ cancelJob(phase2JobClient);
+ cancelJob(phase1JobClient);
+ }
+ }
+
@Test
void testTopicReAddMigrationUsingFileMetadataService() throws
Throwable {
// setup topics
@@ -1134,6 +1243,162 @@ class DynamicKafkaSourceITTest {
.collect(Collectors.toSet());
}
+ private Configuration createCheckpointConfiguration() {
+ Configuration configuration = new Configuration();
+ configuration.set(RestartStrategyOptions.RESTART_STRATEGY,
"disable");
+ configuration.set(StateBackendOptions.STATE_BACKEND, "rocksdb");
+ File checkpointDir = new File(testDir,
"retained-removed-cluster-checkpoints");
+ configuration.set(
+ CheckpointingOptions.CHECKPOINTS_DIRECTORY,
checkpointDir.toURI().toString());
+ configuration.set(
+ CheckpointingOptions.EXTERNALIZED_CHECKPOINT_RETENTION,
+ ExternalizedCheckpointRetention.RETAIN_ON_CANCELLATION);
+ configuration.set(CheckpointingOptions.MAX_RETAINED_CHECKPOINTS,
2);
+ return configuration;
+ }
+
+ private JobClient startRetainedRemovedClusterJob(
+ Configuration configuration,
+ File metadataFile,
+ String streamId,
+ SharedReference<List<Integer>> collectedRecords,
+ int parallelism)
+ throws Exception {
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(configuration);
+ env.setParallelism(parallelism);
+ env.enableCheckpointing(100L);
+
+ Properties properties = new Properties();
+
properties.setProperty(KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
"100");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "100");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_FAILURE_THRESHOLD.key(),
+ "2");
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+ "60000");
+ properties.setProperty(
+ CommonClientConfigs.GROUP_ID_CONFIG,
"test-retained-removed-cluster-offsets");
+
+ YamlFileMetadataService yamlFileMetadataService =
+ new YamlFileMetadataService(metadataFile.getPath(),
Duration.ofMillis(100));
+ DynamicKafkaSource<Integer> dynamicKafkaSource =
+ DynamicKafkaSource.<Integer>builder()
+ .setStreamIds(Collections.singleton(streamId))
+ .setKafkaMetadataService(yamlFileMetadataService)
+ .setDeserializer(
+ KafkaRecordDeserializationSchema.valueOnly(
+ IntegerDeserializer.class))
+ .setStartingOffsets(OffsetsInitializer.earliest())
+ .setProperties(properties)
+ .build();
+
+ DataStreamSource<Integer> stream =
+ env.fromSource(
+ dynamicKafkaSource,
+ WatermarkStrategy.noWatermarks(),
+ "dynamic-kafka-src");
+ stream.uid("dynamic-kafka-src");
+ stream.addSink(new
CollectingSink(collectedRecords)).uid("collecting-sink");
+ return env.executeAsync("test-retained-removed-cluster-offsets");
+ }
+
+ private void waitForCollectedRecords(
+ SharedReference<List<Integer>> collectedRecords,
+ JobClient jobClient,
+ int expectedCount,
+ String message)
+ throws Exception {
+ CommonTestUtils.waitUtil(
+ () -> {
+ try {
+ throwIfJobFailed(jobClient);
+ } catch (Exception exception) {
+ throw new RuntimeException(exception);
+ }
+ return collectedRecords.applySync(
+ records -> records.size() >= expectedCount);
+ },
+ Duration.ofSeconds(30),
+ message);
+ }
+
+ private List<Integer> copyCollectedRecords(
+ SharedReference<List<Integer>> collectedRecords) {
+ return collectedRecords.applySync(ArrayList::new);
+ }
+
+ private void waitForKafkaClusterMetricsToDisappear(String
kafkaClusterId) throws Exception {
+ CommonTestUtils.waitUtil(
+ () -> !hasKafkaClusterMetrics(kafkaClusterId),
+ Duration.ofSeconds(30),
+ "Could not observe removed Kafka cluster metrics
disappear");
+ }
+
+ private boolean hasKafkaClusterMetrics(String kafkaClusterId) {
+ Optional<MetricGroup> groups =
reporter.findGroup(DYNAMIC_KAFKA_SOURCE_METRIC_GROUP);
+ if (groups.isEmpty()) {
+ return false;
+ }
+
+ return reporter.getMetricsByGroup(groups.get()).keySet().stream()
+ .map(metricName ->
groups.get().getMetricIdentifier(metricName))
+ .anyMatch(metricName ->
metricName.contains(".kafkaCluster." + kafkaClusterId));
+ }
+
+ private File waitForCompletedCheckpoint(File previousCheckpoint)
throws Exception {
+ AtomicReference<File> completedCheckpoint = new
AtomicReference<>();
+ CommonTestUtils.waitUtil(
+ () -> {
+ try {
+ File checkpoint =
+ TestUtils.getMostRecentCompletedCheckpoint(
+ new File(
+ testDir,
+
"retained-removed-cluster-checkpoints"));
+ if (checkpoint != null &&
!checkpoint.equals(previousCheckpoint)) {
+ completedCheckpoint.set(checkpoint);
+ return true;
+ }
+ } catch (Exception ignored) {
+ // Checkpoint directory is not populated yet.
+ }
+ return false;
+ },
+ Duration.ofSeconds(30),
+ "Could not obtain a completed retained-cluster
checkpoint");
+ return completedCheckpoint.get();
+ }
+
+ private void cancelJob(JobClient jobClient) throws Exception {
+ if (jobClient != null) {
+ try {
+ jobClient.cancel().get(30, TimeUnit.SECONDS);
+ } catch (ExecutionException executionException) {
+ if (!(executionException.getCause()
+ instanceof
FlinkJobTerminatedWithoutCancellationException)) {
+ throw executionException;
+ }
+ }
+ }
+ }
+
+ private void throwIfJobFailed(JobClient jobClient) throws Exception {
+ if (jobClient.getJobStatus().get(30, TimeUnit.SECONDS) !=
JobStatus.FAILED) {
+ return;
+ }
+
+ try {
+ jobClient.getJobExecutionResult().get(30, TimeUnit.SECONDS);
+ } catch (ExecutionException executionException) {
+ throw new RuntimeException(
+ "Dynamic source job failed before expected records
were collected",
+ executionException.getCause());
+ }
+ }
+
private void registerReader(
MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context,
DynamicKafkaSourceEnumerator enumerator,
@@ -1207,6 +1472,19 @@ class DynamicKafkaSourceITTest {
}
}
+ private static final class CollectingSink extends
RichSinkFunction<Integer> {
+ private final SharedReference<List<Integer>> collectedRecords;
+
+ private CollectingSink(SharedReference<List<Integer>>
collectedRecords) {
+ this.collectedRecords = collectedRecords;
+ }
+
+ @Override
+ public void invoke(Integer value, Context context) {
+ collectedRecords.consumeSync(records -> records.add(value));
+ }
+ }
+
/** Integration test based on connector testing framework. */
@Nested
class IntegrationTests extends SourceTestSuiteBase<String> {
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
index 1473bbdc..d79e9323 100644
---
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/enumerator/DynamicKafkaSourceEnumStateSerializerTest.java
@@ -104,7 +104,15 @@ public class DynamicKafkaSourceEnumStateSerializerTest {
getSplitAssignment("topic3",
1, UNASSIGNED),
getSplitAssignment("topic4",
2, UNASSIGNED),
getSplitAssignment("topic5",
3, UNASSIGNED)),
- false)));
+ false)),
+ ImmutableMap.of(
+ "retained-cluster",
+ new
DynamicKafkaSourceEnumState.RetainedClusterState(
+ new KafkaSourceEnumState(
+ ImmutableSet.of(
+
getSplitAssignment("topic6", 0, ASSIGNED)),
+ true),
+ 123L)));
DynamicKafkaSourceEnumState dynamicKafkaSourceEnumStateAfterSerde =
dynamicKafkaSourceEnumStateSerializer.deserialize(
@@ -190,6 +198,7 @@ public class DynamicKafkaSourceEnumStateSerializerTest {
dynamicKafkaSourceEnumStateSerializer.deserialize(1,
serializedState);
assertThat(dynamicKafkaSourceEnumState.getClusterEnumeratorStates()).isEmpty();
+
assertThat(dynamicKafkaSourceEnumState.getRetainedClusterEnumeratorStates()).isEmpty();
KafkaStream kafkaStream =
dynamicKafkaSourceEnumState.getKafkaStreams().iterator().next();
assertThat(kafkaStream.getStreamId()).isEqualTo("stream0");
ClusterMetadata clusterMetadata =
kafkaStream.getClusterMetadataMap().get("cluster0");
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 e6867d32..4683037d 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
@@ -681,6 +681,163 @@ public class DynamicKafkaSourceEnumeratorTest {
}
}
+ @Test
+ public void testEnumeratorStateRetainsRemovedClusterUntilExpired() throws
Throwable {
+ int restoredParallelism = NUM_SUBTASKS + 1;
+ KafkaStream initialStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ KafkaStream shrunkStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ String removedClusterId =
DynamicKafkaSourceTestHelper.getKafkaClusterId(1);
+ shrunkStream.getClusterMetadataMap().remove(removedClusterId);
+
+ try (MockKafkaMetadataService metadataService =
+ new
MockKafkaMetadataService(Collections.singleton(initialStream));
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
+ DynamicKafkaSourceEnumerator enumerator =
+ createEnumerator(
+ context,
+ metadataService,
+ (properties) -> {
+ properties.setProperty(
+ DynamicKafkaSourceOptions
+
.STREAM_METADATA_DISCOVERY_INTERVAL_MS
+ .key(),
+ "1");
+ properties.setProperty(
+ DynamicKafkaSourceOptions
+
.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS
+ .key(),
+ "1000");
+ })) {
+ enumerator.start();
+ context.runPeriodicCallable(0);
+ runAllOneTimeCallables(context);
+
+ mockRegisterReaderAndSendReaderStartupEvent(context, enumerator,
0);
+ mockRegisterReaderAndSendReaderStartupEvent(context, enumerator,
1);
+ runAllOneTimeCallables(context);
+
+
metadataService.setKafkaStreams(Collections.singleton(shrunkStream));
+ context.runPeriodicCallable(0);
+ runAllOneTimeCallables(context);
+
+ DynamicKafkaSourceEnumState retainedCheckpoint =
enumerator.snapshotState(-1);
+ assertThat(retainedCheckpoint.getClusterEnumeratorStates())
+ .doesNotContainKey(removedClusterId);
+ assertThat(retainedCheckpoint.getRetainedClusterEnumeratorStates())
+ .containsKey(removedClusterId);
+
+ Properties restoredProperties = new Properties();
+ restoredProperties.setProperty(
+ KafkaSourceOptions.PARTITION_DISCOVERY_INTERVAL_MS.key(),
"0");
+ restoredProperties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_DISCOVERY_INTERVAL_MS.key(), "1");
+ restoredProperties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+ "1000");
+ try (MockKafkaMetadataService restoredMetadataService =
+ new
MockKafkaMetadataService(Collections.singleton(shrunkStream));
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit>
restoredContext =
+ new
MockSplitEnumeratorContext<>(restoredParallelism);
+ DynamicKafkaSourceEnumerator restoredEnumerator =
+ new DynamicKafkaSourceEnumerator(
+ new
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+ restoredMetadataService,
+ restoredContext,
+ OffsetsInitializer.earliest(),
+ new NoStoppingOffsetsInitializer(),
+ restoredProperties,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ retainedCheckpoint,
+ new TestKafkaEnumContextProxyFactory())) {
+ restoredEnumerator.start();
+ restoredContext.runPeriodicCallable(0);
+ runAllOneTimeCallables(restoredContext);
+ for (int i = 0; i < restoredParallelism; i++) {
+ mockRegisterReaderAndSendReaderStartupEvent(
+ restoredContext, restoredEnumerator, i);
+ }
+ runAllOneTimeCallables(restoredContext);
+
+ int assignmentsBeforeReAdd =
restoredContext.getSplitsAssignmentSequence().size();
+
restoredMetadataService.setKafkaStreams(Collections.singleton(initialStream));
+ restoredContext.runPeriodicCallable(0);
+ runAllOneTimeCallables(restoredContext);
+ assertThat(restoredContext.getSplitsAssignmentSequence())
+ .as(
+ "re-added cluster should not resend splits
restored from retained state after rescale")
+ .hasSize(assignmentsBeforeReAdd);
+ }
+
+ DynamicKafkaSourceEnumState expiredCheckpoint = retainedCheckpoint;
+ DynamicKafkaSourceEnumState.RetainedClusterState
retainedClusterState =
+
expiredCheckpoint.getRetainedClusterEnumeratorStates().get(removedClusterId);
+ expiredCheckpoint
+ .getRetainedClusterEnumeratorStates()
+ .put(
+ removedClusterId,
+ new
DynamicKafkaSourceEnumState.RetainedClusterState(
+
retainedClusterState.getKafkaSourceEnumState(),
+ System.currentTimeMillis() - 1));
+
+ Properties expiredStateProperties = new Properties();
+ expiredStateProperties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+ "1000");
+ try (MockKafkaMetadataService restoredMetadataService =
+ new
MockKafkaMetadataService(Collections.singleton(shrunkStream));
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit>
restoredContext =
+ new MockSplitEnumeratorContext<>(NUM_SUBTASKS);
+ DynamicKafkaSourceEnumerator restoredEnumerator =
+ new DynamicKafkaSourceEnumerator(
+ new
KafkaStreamSetSubscriber(Collections.singleton(TOPIC)),
+ restoredMetadataService,
+ restoredContext,
+ OffsetsInitializer.earliest(),
+ new NoStoppingOffsetsInitializer(),
+ expiredStateProperties,
+ Boundedness.CONTINUOUS_UNBOUNDED,
+ expiredCheckpoint,
+ new TestKafkaEnumContextProxyFactory())) {
+ assertThat(
+ restoredEnumerator
+ .snapshotState(-1)
+ .getRetainedClusterEnumeratorStates())
+ .doesNotContainKey(removedClusterId);
+ }
+ }
+ }
+
+ @Test
+ public void
testRemovedClusterRetentionOptionIsNotPassedToClusterEnumerator() throws
Exception {
+ MockSplitEnumeratorContext<DynamicKafkaSourceSplit> context =
+ new MockSplitEnumeratorContext<>(2);
+ try (DynamicKafkaSourceEnumerator enumerator =
+ createEnumerator(
+ context,
+ properties ->
+ properties.setProperty(
+ DynamicKafkaSourceOptions
+
.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS
+ .key(),
+ "60000"))) {
+ enumerator.start();
+ mockRegisterReaderAndSendReaderStartupEvent(context, enumerator,
0);
+
+ Map<String, ?> clusterEnumeratorMap =
+ (Map<String, ?>) Whitebox.getInternalState(enumerator,
"clusterEnumeratorMap");
+ for (Object clusterEnumerator : clusterEnumeratorMap.values()) {
+ Properties clusterEnumeratorProperties =
+ (Properties)
Whitebox.getInternalState(clusterEnumerator, "properties");
+ assertThat(clusterEnumeratorProperties)
+ .doesNotContainKey(
+ DynamicKafkaSourceOptions
+
.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS
+ .key());
+ }
+ }
+ }
+
@Test
public void testStartupWithCheckpointState() throws Throwable {
// init enumerator with checkpoint state
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
index f7c91fb5..c8e95ef3 100644
---
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/reader/DynamicKafkaSourceReaderTest.java
@@ -27,6 +27,7 @@ import
org.apache.flink.connector.base.source.reader.splitreader.SplitReader;
import org.apache.flink.connector.base.source.reader.splitreader.SplitsChange;
import
org.apache.flink.connector.base.source.reader.synchronization.FutureCompletingBlockingQueue;
import org.apache.flink.connector.kafka.dynamic.metadata.KafkaStream;
+import
org.apache.flink.connector.kafka.dynamic.source.DynamicKafkaSourceOptions;
import org.apache.flink.connector.kafka.dynamic.source.MetadataUpdateEvent;
import
org.apache.flink.connector.kafka.dynamic.source.split.DynamicKafkaSourceSplit;
import
org.apache.flink.connector.kafka.source.metrics.KafkaSourceReaderMetrics;
@@ -191,6 +192,59 @@ public class DynamicKafkaSourceReaderTest extends
SourceReaderTestBase<DynamicKa
}
}
+ @Test
+ void testHandleSourceEventRetainsRemovedClusterOffsetsUntilExpired()
throws Exception {
+ DynamicKafkaSourceSplit cluster0Split =
+ new DynamicKafkaSourceSplit(
+ kafkaClusterId0, new KafkaPartitionSplit(new
TopicPartition(TOPIC, 0), 10));
+ DynamicKafkaSourceSplit cluster1Split =
+ new DynamicKafkaSourceSplit(
+ kafkaClusterId1, new KafkaPartitionSplit(new
TopicPartition(TOPIC, 0), 20));
+ KafkaStream shrunkKafkaStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ shrunkKafkaStream.getClusterMetadataMap().remove(kafkaClusterId0);
+
+ TestingReaderContext context = new TestingReaderContext();
+ try (DynamicKafkaSourceReader<Integer> reader =
+ createReaderWithoutStartWithRemovedClusterRetention(context)) {
+ reader.start();
+ KafkaStream kafkaStream =
DynamicKafkaSourceTestHelper.getKafkaStream(TOPIC);
+ reader.handleSourceEvents(new
MetadataUpdateEvent(Collections.singleton(kafkaStream)));
+
+ reader.addSplits(ImmutableList.of(cluster0Split, cluster1Split));
+
+ reader.handleSourceEvents(
+ new
MetadataUpdateEvent(Collections.singleton(shrunkKafkaStream)));
+
+ DynamicKafkaSourceSplit retainedCluster0Split =
+ reader.snapshotState(-1).stream()
+ .filter(split ->
split.getKafkaClusterId().equals(kafkaClusterId0))
+ .findFirst()
+ .orElseThrow();
+ assertThat(retainedCluster0Split.isRetained()).isTrue();
+ assertThat(retainedCluster0Split.getRetainedUntilMs())
+ .isGreaterThan(System.currentTimeMillis());
+ assertThat(reader.snapshotState(-1))
+ .containsExactlyInAnyOrder(retainedCluster0Split,
cluster1Split);
+
+ reader.handleSourceEvents(new
MetadataUpdateEvent(Collections.singleton(kafkaStream)));
+ assertThat(reader.snapshotState(-1))
+ .containsExactlyInAnyOrder(cluster0Split, cluster1Split);
+ }
+
+ TestingReaderContext restoredContext = new TestingReaderContext();
+ try (DynamicKafkaSourceReader<Integer> restoredReader =
+
createReaderWithoutStartWithRemovedClusterRetention(restoredContext)) {
+ restoredReader.addSplits(
+ ImmutableList.of(
+
cluster0Split.retainUntil(System.currentTimeMillis() - 1),
+ cluster1Split));
+ restoredReader.start();
+ restoredReader.handleSourceEvents(
+ new
MetadataUpdateEvent(Collections.singleton(shrunkKafkaStream)));
+
assertThat(restoredReader.snapshotState(-1)).containsExactly(cluster1Split);
+ }
+ }
+
@Test
void testNoSubReadersInputStatus() throws Exception {
try (DynamicKafkaSourceReader<Integer> reader =
@@ -345,6 +399,18 @@ public class DynamicKafkaSourceReaderTest extends
SourceReaderTestBase<DynamicKa
properties);
}
+ private DynamicKafkaSourceReader<Integer>
createReaderWithoutStartWithRemovedClusterRetention(
+ TestingReaderContext context) {
+ Properties properties = getRequiredProperties();
+ properties.setProperty(
+
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+ "1000");
+ return new DynamicKafkaSourceReader<>(
+ context,
+
KafkaRecordDeserializationSchema.valueOnly(IntegerDeserializer.class),
+ properties);
+ }
+
private SourceReader<Integer, DynamicKafkaSourceSplit> startReader(
DynamicKafkaSourceReader<Integer> reader, TestingReaderContext
context) {
reader.start();
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializerTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializerTest.java
index 4125219a..850bbf20 100644
---
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializerTest.java
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/dynamic/source/split/DynamicKafkaSourceSplitSerializerTest.java
@@ -19,10 +19,13 @@
package org.apache.flink.connector.kafka.dynamic.source.split;
import org.apache.flink.connector.kafka.source.split.KafkaPartitionSplit;
+import
org.apache.flink.connector.kafka.source.split.KafkaPartitionSplitSerializer;
import org.apache.kafka.common.TopicPartition;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -41,7 +44,51 @@ public class DynamicKafkaSourceSplitSerializerTest {
"test-cluster",
new KafkaPartitionSplit(new
TopicPartition("test-topic", 3), 1));
DynamicKafkaSourceSplit dynamicKafkaSourceSplitAfterSerde =
- serializer.deserialize(1,
serializer.serialize(dynamicKafkaSourceSplit));
+ serializer.deserialize(
+ serializer.getVersion(),
serializer.serialize(dynamicKafkaSourceSplit));
assertEquals(dynamicKafkaSourceSplit,
dynamicKafkaSourceSplitAfterSerde);
}
+
+ @Test
+ public void testSerdeRetainedSplit() throws IOException {
+ DynamicKafkaSourceSplitSerializer serializer = new
DynamicKafkaSourceSplitSerializer();
+ DynamicKafkaSourceSplit retainedSplit =
+ new DynamicKafkaSourceSplit(
+ "test-cluster",
+ new KafkaPartitionSplit(new
TopicPartition("test-topic", 3), 1),
+ 123L);
+
+ DynamicKafkaSourceSplit retainedSplitAfterSerde =
+ serializer.deserialize(
+ serializer.getVersion(),
serializer.serialize(retainedSplit));
+
+ assertEquals(retainedSplit, retainedSplitAfterSerde);
+ }
+
+ @Test
+ public void testDeserializeV1State() throws IOException {
+ DynamicKafkaSourceSplitSerializer serializer = new
DynamicKafkaSourceSplitSerializer();
+ DynamicKafkaSourceSplit dynamicKafkaSourceSplit =
+ serializer.deserialize(1, serializeV1State());
+
+ assertEquals(
+ new DynamicKafkaSourceSplit(
+ "test-cluster",
+ new KafkaPartitionSplit(new
TopicPartition("test-topic", 3), 1)),
+ dynamicKafkaSourceSplit);
+ }
+
+ private static byte[] serializeV1State() throws IOException {
+ KafkaPartitionSplitSerializer kafkaPartitionSplitSerializer =
+ new KafkaPartitionSplitSerializer();
+ KafkaPartitionSplit kafkaPartitionSplit =
+ new KafkaPartitionSplit(new TopicPartition("test-topic", 3),
1);
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ DataOutputStream out = new DataOutputStream(baos)) {
+ out.writeUTF("test-cluster");
+ out.writeInt(kafkaPartitionSplitSerializer.getVersion());
+
out.write(kafkaPartitionSplitSerializer.serialize(kafkaPartitionSplit));
+ return baos.toByteArray();
+ }
+ }
}
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactoryTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactoryTest.java
index db7b4715..3bb5deae 100644
---
a/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactoryTest.java
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaTableFactoryTest.java
@@ -18,6 +18,7 @@
package org.apache.flink.streaming.connectors.kafka.table;
+import
org.apache.flink.connector.kafka.dynamic.source.DynamicKafkaSourceOptions;
import org.apache.flink.streaming.connectors.kafka.config.BoundedMode;
import org.apache.flink.streaming.connectors.kafka.config.StartupMode;
import org.apache.flink.table.api.DataTypes;
@@ -77,6 +78,25 @@ class DynamicKafkaTableFactoryTest {
.withMessageContaining("stream-pattern");
}
+ @Test
+ void testTableSourceWithRemovedClusterRetentionOption() {
+ final Map<String, String> options = getSingleClusterSourceOptions();
+ options.put(
+
DynamicKafkaSourceOptions.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS.key(),
+ "60000");
+
+ final DynamicTableSource actualSource = createTableSource(SCHEMA,
options);
+ assertThat(actualSource).isInstanceOf(DynamicKafkaTableSource.class);
+
+ final DynamicKafkaTableSource actualKafkaSource =
(DynamicKafkaTableSource) actualSource;
+ assertThat(
+ actualKafkaSource.properties.getProperty(
+ DynamicKafkaSourceOptions
+
.STREAM_METADATA_REMOVED_CLUSTER_RETENTION_MS
+ .key()))
+ .isEqualTo("60000");
+ }
+
private static Map<String, String> getSingleClusterSourceOptions() {
Map<String, String> tableOptions = new HashMap<>();
// Dynamic Kafka specific options.