Skip to content

Commit bcb7ef4

Browse files
committed
feat: Add Adaptive Kafka watermark generator (#346)
1 parent d32b805 commit bcb7ef4

16 files changed

Lines changed: 1512 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright © 2026 DataSQRL (contact@datasqrl.com)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.datasqrl.flinkrunner.connector.kafka;
17+
18+
import static org.apache.flink.util.Preconditions.checkArgument;
19+
20+
import com.datasqrl.flinkrunner.connector.kafka.SourceWatermarkOptions.SourceWatermarkConfig;
21+
import java.io.Serial;
22+
import java.io.Serializable;
23+
import java.lang.ref.Cleaner;
24+
import java.util.List;
25+
import java.util.Properties;
26+
import java.util.concurrent.TimeUnit;
27+
import lombok.extern.slf4j.Slf4j;
28+
import org.apache.kafka.clients.admin.AdminClient;
29+
import org.apache.kafka.common.config.ConfigResource;
30+
import org.apache.kafka.common.config.TopicConfig;
31+
import org.apache.kafka.common.errors.AuthorizationException;
32+
import org.apache.kafka.common.record.TimestampType;
33+
34+
/** Readiness checker for idle source-watermark advancement backed by Kafka AdminClient metadata. */
35+
@Slf4j
36+
public class KafkaAdminIdleAdvanceReadinessChecker implements IdleAdvanceReadinessChecker {
37+
38+
@Serial private static final long serialVersionUID = 1L;
39+
40+
private static final Cleaner ADMIN_CLIENT_CLEANER = Cleaner.create();
41+
42+
private final Properties kafkaProperties;
43+
private final List<String> topics;
44+
private final long brokerCheckTimeoutMillis;
45+
private final long brokerCheckTtlMillis;
46+
47+
@SuppressWarnings({"FieldCanBeLocal", "unused"})
48+
private transient Cleaner.Cleanable adminClientCleanable;
49+
50+
private transient AdminClient adminClient;
51+
private transient long lastCheckMillis = Long.MIN_VALUE;
52+
private transient boolean lastCheckReady;
53+
54+
public KafkaAdminIdleAdvanceReadinessChecker(
55+
Properties kafkaProperties, List<String> topics, SourceWatermarkConfig config) {
56+
checkArgument(
57+
topics != null && !topics.isEmpty(),
58+
"Watermark idle advance only supports the 'topic' configuration");
59+
this.kafkaProperties = kafkaProperties;
60+
this.topics = List.copyOf(topics);
61+
this.brokerCheckTimeoutMillis = config.idleAdvanceBrokerCheckTimeoutMillis();
62+
this.brokerCheckTtlMillis = config.idleAdvanceBrokerCheckTtlMillis();
63+
}
64+
65+
/**
66+
* Returns whether it is safe to emit wall-clock-derived idle source watermarks.
67+
*
68+
* <p>Idle watermark advancement uses wall-clock time to move event time forward while no records
69+
* are arriving. That is only safe when the broker is reachable and the source topics use Kafka
70+
* {@code LogAppendTime}.
71+
*/
72+
@Override
73+
public boolean isReady(long currentTimeMillis) {
74+
if (lastCheckMillis != Long.MIN_VALUE
75+
&& currentTimeMillis - lastCheckMillis < brokerCheckTtlMillis) {
76+
return lastCheckReady;
77+
}
78+
79+
lastCheckMillis = currentTimeMillis;
80+
lastCheckReady = checkBrokerAndTopicTimestampType();
81+
82+
return lastCheckReady;
83+
}
84+
85+
boolean checkBrokerAndTopicTimestampType() {
86+
try {
87+
var configs =
88+
adminClient()
89+
.describeConfigs(mapTopicsToConfigResources())
90+
.all()
91+
.get(brokerCheckTimeoutMillis, TimeUnit.MILLISECONDS);
92+
93+
return configs.values().stream()
94+
.allMatch(
95+
config -> {
96+
var timestampType = config.get(TopicConfig.MESSAGE_TIMESTAMP_TYPE_CONFIG);
97+
return TimestampType.LOG_APPEND_TIME.toString().equals(timestampType.value());
98+
});
99+
} catch (Exception e) {
100+
if (e.getCause() instanceof AuthorizationException ae) {
101+
throw new RuntimeException(
102+
"Cannot check idle watermark advance readiness: insufficient Kafka permissions.", ae);
103+
}
104+
105+
log.debug("Failed to check idle watermark advance readiness", e);
106+
return false;
107+
}
108+
}
109+
110+
private AdminClient adminClient() {
111+
if (adminClient == null) {
112+
adminClient = AdminClient.create(kafkaProperties);
113+
adminClientCleanable = ADMIN_CLIENT_CLEANER.register(this, adminClient::close);
114+
}
115+
116+
return adminClient;
117+
}
118+
119+
private List<ConfigResource> mapTopicsToConfigResources() {
120+
return topics.stream()
121+
.map(topic -> new ConfigResource(ConfigResource.Type.TOPIC, topic))
122+
.toList();
123+
}
124+
}
125+
126+
/** Determines whether it is currently safe to emit wall-clock-derived idle source watermarks. */
127+
@FunctionalInterface
128+
interface IdleAdvanceReadinessChecker extends Serializable {
129+
130+
boolean isReady(long currentTimeMillis);
131+
}

0 commit comments

Comments
 (0)