Skip to content

Commit 7125130

Browse files
committed
address review comments
1 parent 9622dc6 commit 7125130

7 files changed

Lines changed: 302 additions & 22 deletions

File tree

connectors/kafka-safe-connector/src/main/java/com/datasqrl/flinkrunner/connector/kafka/KafkaRecordTimestampWatermarkStrategy.java

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package com.datasqrl.flinkrunner.connector.kafka;
1717

18+
import com.datasqrl.flinkrunner.connector.kafka.SourceWatermarkOptions.SourceWatermarkConfig;
1819
import java.io.Serial;
1920
import java.time.Duration;
2021
import java.util.Arrays;
@@ -25,6 +26,7 @@
2526
import org.apache.flink.api.common.eventtime.WatermarkOutput;
2627
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
2728
import org.apache.flink.table.data.RowData;
29+
import org.apache.flink.table.watermark.WatermarkEmitStrategy;
2830

2931
/** Adaptive watermark strategy based on Kafka record timestamps. */
3032
@Internal
@@ -37,18 +39,36 @@ public final class KafkaRecordTimestampWatermarkStrategy implements WatermarkStr
3739
static final long MIN_OUT_OF_ORDERNESS_MILLIS = 50L;
3840
static final long MAX_OUT_OF_ORDERNESS_MILLIS = Duration.ofDays(1).toMillis();
3941

40-
private static final double OUT_OF_ORDERNESS_QUANTILE = 0.95D;
42+
static final double OUT_OF_ORDERNESS_QUANTILE = 0.95D;
4143

42-
public static final KafkaRecordTimestampWatermarkStrategy INSTANCE =
43-
new KafkaRecordTimestampWatermarkStrategy();
44+
private final WatermarkEmitStrategy emitStrategy;
45+
private final SourceWatermarkConfig configuration;
4446

45-
private KafkaRecordTimestampWatermarkStrategy() {}
47+
public KafkaRecordTimestampWatermarkStrategy() {
48+
this(WatermarkEmitStrategy.ON_PERIODIC);
49+
}
50+
51+
public KafkaRecordTimestampWatermarkStrategy(WatermarkEmitStrategy emitStrategy) {
52+
this(
53+
emitStrategy,
54+
new SourceWatermarkConfig(
55+
MIN_RECORDS,
56+
MIN_OUT_OF_ORDERNESS_MILLIS,
57+
MAX_OUT_OF_ORDERNESS_MILLIS,
58+
OUT_OF_ORDERNESS_QUANTILE));
59+
}
60+
61+
public KafkaRecordTimestampWatermarkStrategy(
62+
WatermarkEmitStrategy emitStrategy, SourceWatermarkConfig configuration) {
63+
this.emitStrategy = emitStrategy;
64+
this.configuration = configuration;
65+
}
4666

4767
@Override
4868
public WatermarkGenerator<RowData> createWatermarkGenerator(
4969
WatermarkGeneratorSupplier.Context context) {
5070

51-
return new AdaptiveKafkaRecordTimestampWatermarkGenerator();
71+
return new AdaptiveKafkaRecordTimestampWatermarkGenerator(emitStrategy, configuration);
5272
}
5373

5474
/**
@@ -75,6 +95,9 @@ private static final class AdaptiveKafkaRecordTimestampWatermarkGenerator
7595
*/
7696
private final long[] latenessSamples = new long[SAMPLE_SIZE];
7797

98+
private final WatermarkEmitStrategy emitStrategy;
99+
private final SourceWatermarkConfig configuration;
100+
78101
/** Highest Kafka record timestamp observed by this generator. */
79102
private long maxTimestamp = Long.MIN_VALUE;
80103

@@ -89,6 +112,12 @@ private static final class AdaptiveKafkaRecordTimestampWatermarkGenerator
89112
/** Next slot in the circular lateness sample buffer. */
90113
private int nextSampleIndex;
91114

115+
private AdaptiveKafkaRecordTimestampWatermarkGenerator(
116+
WatermarkEmitStrategy emitStrategy, SourceWatermarkConfig configuration) {
117+
this.emitStrategy = emitStrategy;
118+
this.configuration = configuration;
119+
}
120+
92121
@Override
93122
public void onEvent(RowData event, long eventTimestamp, WatermarkOutput output) {
94123
if (maxTimestamp != Long.MIN_VALUE) {
@@ -100,11 +129,23 @@ public void onEvent(RowData event, long eventTimestamp, WatermarkOutput output)
100129

101130
maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
102131
recordCount++;
132+
133+
if (emitStrategy.isOnEvent()) {
134+
emitIfReady(output);
135+
}
103136
}
104137

105138
@Override
106139
public void onPeriodicEmit(WatermarkOutput output) {
107-
if (recordCount < MIN_RECORDS || maxTimestamp == Long.MIN_VALUE) {
140+
if (!emitStrategy.isOnPeriodic()) {
141+
return;
142+
}
143+
144+
emitIfReady(output);
145+
}
146+
147+
private void emitIfReady(WatermarkOutput output) {
148+
if (recordCount < configuration.getMinRecords() || maxTimestamp == Long.MIN_VALUE) {
108149
// Avoid emitting during warm-up, so limited early samples won't distort the estimate.
109150
return;
110151
}
@@ -121,7 +162,7 @@ public void onPeriodicEmit(WatermarkOutput output) {
121162
private long calculateOutOfOrdernessMillis() {
122163
int sampleCount = (int) Math.min(recordCount - 1, SAMPLE_SIZE);
123164
if (sampleCount <= 0) {
124-
return MIN_OUT_OF_ORDERNESS_MILLIS;
165+
return configuration.getMinOutOfOrdernessMillis();
125166
}
126167

127168
long[] samples = Arrays.copyOf(latenessSamples, sampleCount);
@@ -130,11 +171,13 @@ private long calculateOutOfOrdernessMillis() {
130171
// Use a high quantile rather than the maximum, so a single pathological record does not stall
131172
// all event-time progress until it rotates out of the sample buffer.
132173
int quantileIndex =
133-
Math.min(sampleCount - 1, (int) Math.ceil(sampleCount * OUT_OF_ORDERNESS_QUANTILE) - 1);
174+
Math.min(
175+
sampleCount - 1,
176+
(int) Math.ceil(sampleCount * configuration.getOutOfOrdernessQuantile()) - 1);
134177

135178
return Math.min(
136-
MAX_OUT_OF_ORDERNESS_MILLIS,
137-
Math.max(MIN_OUT_OF_ORDERNESS_MILLIS, samples[quantileIndex]));
179+
configuration.getMaxOutOfOrdernessMillis(),
180+
Math.max(configuration.getMinOutOfOrdernessMillis(), samples[quantileIndex]));
138181
}
139182
}
140183
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 java.io.Serial;
19+
import java.io.Serializable;
20+
import java.time.Duration;
21+
import lombok.Value;
22+
import org.apache.flink.configuration.ConfigOption;
23+
import org.apache.flink.configuration.ConfigOptions;
24+
import org.apache.flink.configuration.ReadableConfig;
25+
import org.apache.flink.table.api.ValidationException;
26+
27+
public class SourceWatermarkOptions {
28+
29+
public static final ConfigOption<Integer> SCAN_SOURCE_WATERMARK_MIN_RECORDS =
30+
ConfigOptions.key("scan.source-watermark.min-records")
31+
.intType()
32+
.defaultValue(KafkaRecordTimestampWatermarkStrategy.MIN_RECORDS)
33+
.withDescription(
34+
"Minimum number of records to observe before emitting source watermarks.");
35+
36+
public static final ConfigOption<Duration> SCAN_SOURCE_WATERMARK_MIN_OUT_OF_ORDERNESS =
37+
ConfigOptions.key("scan.source-watermark.min-out-of-orderness")
38+
.durationType()
39+
.defaultValue(
40+
Duration.ofMillis(KafkaRecordTimestampWatermarkStrategy.MIN_OUT_OF_ORDERNESS_MILLIS))
41+
.withDescription("Minimum adaptive out-of-orderness delay for source watermarks.");
42+
43+
public static final ConfigOption<Duration> SCAN_SOURCE_WATERMARK_MAX_OUT_OF_ORDERNESS =
44+
ConfigOptions.key("scan.source-watermark.max-out-of-orderness")
45+
.durationType()
46+
.defaultValue(
47+
Duration.ofMillis(KafkaRecordTimestampWatermarkStrategy.MAX_OUT_OF_ORDERNESS_MILLIS))
48+
.withDescription("Maximum adaptive out-of-orderness delay for source watermarks.");
49+
50+
public static final ConfigOption<Double> SCAN_SOURCE_WATERMARK_OUT_OF_ORDERNESS_QUANTILE =
51+
ConfigOptions.key("scan.source-watermark.out-of-orderness-quantile")
52+
.doubleType()
53+
.defaultValue(KafkaRecordTimestampWatermarkStrategy.OUT_OF_ORDERNESS_QUANTILE)
54+
.withDescription(
55+
"Quantile of observed lateness samples to use as the source watermark delay.");
56+
57+
public static SourceWatermarkConfig sourceWatermarkConfiguration(ReadableConfig tableOptions) {
58+
var minRecords = tableOptions.get(SCAN_SOURCE_WATERMARK_MIN_RECORDS);
59+
var minOutOfOrderness = tableOptions.get(SCAN_SOURCE_WATERMARK_MIN_OUT_OF_ORDERNESS);
60+
var maxOutOfOrderness = tableOptions.get(SCAN_SOURCE_WATERMARK_MAX_OUT_OF_ORDERNESS);
61+
var quantile = tableOptions.get(SCAN_SOURCE_WATERMARK_OUT_OF_ORDERNESS_QUANTILE);
62+
63+
if (minRecords <= 0) {
64+
throw new ValidationException(
65+
String.format("'%s' must be greater than 0.", SCAN_SOURCE_WATERMARK_MIN_RECORDS.key()));
66+
}
67+
68+
if (minOutOfOrderness.isNegative()) {
69+
throw new ValidationException(
70+
String.format(
71+
"'%s' must not be negative.", SCAN_SOURCE_WATERMARK_MIN_OUT_OF_ORDERNESS.key()));
72+
}
73+
74+
if (maxOutOfOrderness.compareTo(minOutOfOrderness) < 0) {
75+
throw new ValidationException(
76+
String.format(
77+
"'%s' must be greater than or equal to '%s'.",
78+
SCAN_SOURCE_WATERMARK_MAX_OUT_OF_ORDERNESS.key(),
79+
SCAN_SOURCE_WATERMARK_MIN_OUT_OF_ORDERNESS.key()));
80+
}
81+
82+
if (quantile <= 0D || quantile > 1D) {
83+
throw new ValidationException(
84+
String.format(
85+
"'%s' must be greater than 0 and less than or equal to 1.",
86+
SCAN_SOURCE_WATERMARK_OUT_OF_ORDERNESS_QUANTILE.key()));
87+
}
88+
89+
return new SourceWatermarkConfig(
90+
minRecords, minOutOfOrderness.toMillis(), maxOutOfOrderness.toMillis(), quantile);
91+
}
92+
93+
@Value
94+
public static class SourceWatermarkConfig implements Serializable {
95+
96+
@Serial private static final long serialVersionUID = 1L;
97+
98+
int minRecords;
99+
long minOutOfOrdernessMillis;
100+
long maxOutOfOrdernessMillis;
101+
double outOfOrdernessQuantile;
102+
}
103+
}

connectors/kafka-safe-connector/src/main/java/org/apache/flink/streaming/connectors/kafka/table/SafeKafkaDynamicSource.java

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.apache.flink.streaming.connectors.kafka.table;
1717

1818
import com.datasqrl.flinkrunner.connector.kafka.KafkaRecordTimestampWatermarkStrategy;
19+
import com.datasqrl.flinkrunner.connector.kafka.SourceWatermarkOptions.SourceWatermarkConfig;
1920
import org.apache.flink.annotation.Internal;
2021
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
2122
import org.apache.flink.api.common.serialization.DeserializationSchema;
@@ -52,6 +53,7 @@
5253
import org.apache.flink.table.types.logical.LogicalType;
5354
import org.apache.flink.table.types.logical.RowType;
5455
import org.apache.flink.table.types.logical.utils.LogicalTypeUtils;
56+
import org.apache.flink.table.watermark.WatermarkEmitStrategy;
5557
import org.apache.flink.util.Preconditions;
5658

5759
import com.datasqrl.flinkrunner.connector.kafka.DeserFailureHandler;
@@ -63,6 +65,7 @@
6365

6466
import javax.annotation.Nullable;
6567

68+
import java.time.Duration;
6669
import java.util.ArrayList;
6770
import java.util.Arrays;
6871
import java.util.Collections;
@@ -107,6 +110,15 @@ public class SafeKafkaDynamicSource
107110
/** Whether to use Kafka record timestamps to generate source watermarks. */
108111
protected boolean sourceWatermarkEnabled;
109112

113+
/** Emit strategy for source watermarks. */
114+
protected final WatermarkEmitStrategy sourceWatermarkEmitStrategy;
115+
116+
/** Idle timeout for source watermarks. */
117+
protected final Optional<Duration> sourceWatermarkIdleTimeout;
118+
119+
/** Adaptive source watermark configuration. */
120+
protected final SourceWatermarkConfig sourceWatermarkConfig;
121+
110122
// --------------------------------------------------------------------------------------------
111123
// Format attributes
112124
// --------------------------------------------------------------------------------------------
@@ -205,7 +217,10 @@ public SafeKafkaDynamicSource(
205217
boolean upsertMode,
206218
String tableIdentifier,
207219
@Nullable Integer parallelism,
208-
DeserFailureHandler deserFailureHandler) {
220+
DeserFailureHandler deserFailureHandler,
221+
WatermarkEmitStrategy sourceWatermarkEmitStrategy,
222+
Optional<Duration> sourceWatermarkIdleTimeout,
223+
SourceWatermarkConfig sourceWatermarkConfig) {
209224
// Format attributes
210225
this.physicalDataType =
211226
Preconditions.checkNotNull(
@@ -248,6 +263,18 @@ public SafeKafkaDynamicSource(
248263
this.tableIdentifier = tableIdentifier;
249264
this.parallelism = parallelism;
250265
this.deserFailureHandler = deserFailureHandler;
266+
this.sourceWatermarkEmitStrategy =
267+
Preconditions.checkNotNull(
268+
sourceWatermarkEmitStrategy,
269+
"Source watermark emit strategy must not be null.");
270+
this.sourceWatermarkIdleTimeout =
271+
Preconditions.checkNotNull(
272+
sourceWatermarkIdleTimeout,
273+
"Source watermark idle timeout must not be null.");
274+
this.sourceWatermarkConfig =
275+
Preconditions.checkNotNull(
276+
sourceWatermarkConfig,
277+
"Source watermark configuration must not be null.");
251278
}
252279

253280
@Override
@@ -374,7 +401,10 @@ public DynamicTableSource copy() {
374401
upsertMode,
375402
tableIdentifier,
376403
parallelism,
377-
deserFailureHandler);
404+
deserFailureHandler,
405+
sourceWatermarkEmitStrategy,
406+
sourceWatermarkIdleTimeout,
407+
sourceWatermarkConfig);
378408
copy.producedDataType = producedDataType;
379409
copy.metadataKeys = metadataKeys;
380410
copy.watermarkStrategy = watermarkStrategy;
@@ -417,6 +447,9 @@ public boolean equals(Object o) {
417447
&& Objects.equals(tableIdentifier, that.tableIdentifier)
418448
&& Objects.equals(watermarkStrategy, that.watermarkStrategy)
419449
&& sourceWatermarkEnabled == that.sourceWatermarkEnabled
450+
&& sourceWatermarkEmitStrategy == that.sourceWatermarkEmitStrategy
451+
&& Objects.equals(sourceWatermarkIdleTimeout, that.sourceWatermarkIdleTimeout)
452+
&& Objects.equals(sourceWatermarkConfig, that.sourceWatermarkConfig)
420453
&& Objects.equals(parallelism, that.parallelism);
421454
}
422455

@@ -444,6 +477,9 @@ public int hashCode() {
444477
tableIdentifier,
445478
watermarkStrategy,
446479
sourceWatermarkEnabled,
480+
sourceWatermarkEmitStrategy,
481+
sourceWatermarkIdleTimeout,
482+
sourceWatermarkConfig,
447483
parallelism);
448484
}
449485

@@ -530,7 +566,15 @@ private WatermarkStrategy<RowData> getWatermarkStrategy() {
530566
}
531567

532568
if (sourceWatermarkEnabled) {
533-
return KafkaRecordTimestampWatermarkStrategy.INSTANCE;
569+
WatermarkStrategy<RowData> sourceWatermarkStrategy =
570+
new KafkaRecordTimestampWatermarkStrategy(
571+
sourceWatermarkEmitStrategy, sourceWatermarkConfig);
572+
if (sourceWatermarkIdleTimeout.isPresent()) {
573+
sourceWatermarkStrategy =
574+
sourceWatermarkStrategy.withIdleness(sourceWatermarkIdleTimeout.get());
575+
}
576+
577+
return sourceWatermarkStrategy;
534578
}
535579

536580
return WatermarkStrategy.noWatermarks();

0 commit comments

Comments
 (0)