Skip to content

Commit 9ea6a14

Browse files
authored
feat: Add rate limiter for Kafka sources (#357)
1 parent c318157 commit 9ea6a14

7 files changed

Lines changed: 811 additions & 4 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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.util.Optional;
19+
import org.apache.flink.configuration.ConfigOption;
20+
import org.apache.flink.configuration.ConfigOptions;
21+
import org.apache.flink.configuration.ReadableConfig;
22+
import org.apache.flink.table.api.ValidationException;
23+
24+
public class RateLimitOptions {
25+
26+
public static final ConfigOption<Integer> SCAN_RATE_LIMIT_RECORDS_PER_SECOND =
27+
ConfigOptions.key("scan.rate-limit.records-per-second")
28+
.intType()
29+
.noDefaultValue()
30+
.withDescription(
31+
"Optional maximum number of records per second emitted by the Kafka source.");
32+
33+
public static Optional<Integer> scanRateLimitRecordsPerSecond(ReadableConfig tableOptions) {
34+
Optional<Integer> recordsPerSecond =
35+
tableOptions.getOptional(SCAN_RATE_LIMIT_RECORDS_PER_SECOND);
36+
37+
recordsPerSecond.ifPresent(
38+
value -> {
39+
if (value <= 0) {
40+
throw new ValidationException(
41+
"'%s' must be greater than 0.".formatted(SCAN_RATE_LIMIT_RECORDS_PER_SECOND.key()));
42+
}
43+
});
44+
45+
return recordsPerSecond;
46+
}
47+
}
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
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.util.Collection;
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.Set;
23+
import java.util.concurrent.CompletableFuture;
24+
import org.apache.flink.api.common.eventtime.Watermark;
25+
import org.apache.flink.api.common.watermark.WatermarkDeclaration;
26+
import org.apache.flink.api.connector.source.Boundedness;
27+
import org.apache.flink.api.connector.source.ReaderOutput;
28+
import org.apache.flink.api.connector.source.Source;
29+
import org.apache.flink.api.connector.source.SourceEvent;
30+
import org.apache.flink.api.connector.source.SourceOutput;
31+
import org.apache.flink.api.connector.source.SourceReader;
32+
import org.apache.flink.api.connector.source.SourceReaderContext;
33+
import org.apache.flink.api.connector.source.SourceSplit;
34+
import org.apache.flink.api.connector.source.SplitEnumerator;
35+
import org.apache.flink.api.connector.source.SplitEnumeratorContext;
36+
import org.apache.flink.api.connector.source.util.ratelimit.RateLimiter;
37+
import org.apache.flink.api.connector.source.util.ratelimit.RateLimiterStrategy;
38+
import org.apache.flink.core.io.InputStatus;
39+
import org.apache.flink.core.io.SimpleVersionedSerializer;
40+
import org.apache.flink.streaming.api.lineage.LineageDataset;
41+
import org.apache.flink.streaming.api.lineage.LineageVertex;
42+
import org.apache.flink.streaming.api.lineage.LineageVertexProvider;
43+
import org.apache.flink.streaming.api.lineage.SourceLineageVertex;
44+
import org.apache.flink.util.Preconditions;
45+
46+
/**
47+
* Rate-limits emitted records while preserving all callbacks of the wrapped source.
48+
*
49+
* <p>Flink's built-in rate-limited reader does not forward Kafka's checkpoint-complete callback,
50+
* which Kafka uses for offset commits.
51+
*/
52+
public final class RateLimitedSource<T, SplitT extends SourceSplit, EnumChkT>
53+
implements Source<T, SplitT, EnumChkT>, LineageVertexProvider {
54+
55+
private final Source<T, SplitT, EnumChkT> delegate;
56+
private final RateLimiterStrategy<SplitT> rateLimiterStrategy;
57+
58+
public RateLimitedSource(
59+
Source<T, SplitT, EnumChkT> delegate, RateLimiterStrategy<SplitT> rateLimiterStrategy) {
60+
this.delegate = Preconditions.checkNotNull(delegate, "Delegate source must not be null.");
61+
this.rateLimiterStrategy =
62+
Preconditions.checkNotNull(rateLimiterStrategy, "Rate limiter strategy must not be null.");
63+
}
64+
65+
@Override
66+
public Boundedness getBoundedness() {
67+
return delegate.getBoundedness();
68+
}
69+
70+
@Override
71+
public SourceReader<T, SplitT> createReader(SourceReaderContext readerContext) throws Exception {
72+
return new RateLimitedSourceReader<>(
73+
delegate.createReader(readerContext),
74+
rateLimiterStrategy.createRateLimiter(readerContext.currentParallelism()));
75+
}
76+
77+
@Override
78+
public SplitEnumerator<SplitT, EnumChkT> createEnumerator(
79+
SplitEnumeratorContext<SplitT> enumContext) throws Exception {
80+
return delegate.createEnumerator(enumContext);
81+
}
82+
83+
@Override
84+
public SplitEnumerator<SplitT, EnumChkT> restoreEnumerator(
85+
SplitEnumeratorContext<SplitT> enumContext, EnumChkT checkpoint) throws Exception {
86+
return delegate.restoreEnumerator(enumContext, checkpoint);
87+
}
88+
89+
@Override
90+
public SimpleVersionedSerializer<SplitT> getSplitSerializer() {
91+
return delegate.getSplitSerializer();
92+
}
93+
94+
@Override
95+
public SimpleVersionedSerializer<EnumChkT> getEnumeratorCheckpointSerializer() {
96+
return delegate.getEnumeratorCheckpointSerializer();
97+
}
98+
99+
@Override
100+
public Set<? extends WatermarkDeclaration> declareWatermarks() {
101+
return delegate.declareWatermarks();
102+
}
103+
104+
@Override
105+
public LineageVertex getLineageVertex() {
106+
if (delegate instanceof LineageVertexProvider lineageVertexProvider) {
107+
return lineageVertexProvider.getLineageVertex();
108+
}
109+
return new EmptySourceLineageVertex(getBoundedness());
110+
}
111+
112+
private record EmptySourceLineageVertex(Boundedness boundedness) implements SourceLineageVertex {
113+
114+
@Override
115+
public List<LineageDataset> datasets() {
116+
return List.of();
117+
}
118+
}
119+
120+
private static final class RateLimitedSourceReader<T, SplitT extends SourceSplit>
121+
implements SourceReader<T, SplitT> {
122+
123+
private final SourceReader<T, SplitT> delegate;
124+
private final RateLimiter<SplitT> rateLimiter;
125+
private final CountingReaderOutput<T> countingOutput = new CountingReaderOutput<>();
126+
private CompletableFuture<Void> rateLimitPermissionFuture =
127+
CompletableFuture.completedFuture(null);
128+
129+
private RateLimitedSourceReader(
130+
SourceReader<T, SplitT> delegate, RateLimiter<SplitT> rateLimiter) {
131+
this.delegate = Preconditions.checkNotNull(delegate, "Delegate reader must not be null.");
132+
this.rateLimiter = Preconditions.checkNotNull(rateLimiter, "Rate limiter must not be null.");
133+
}
134+
135+
@Override
136+
public void start() {
137+
delegate.start();
138+
}
139+
140+
@Override
141+
public InputStatus pollNext(ReaderOutput<T> output) throws Exception {
142+
if (!rateLimitPermissionFuture.isDone()) {
143+
return InputStatus.NOTHING_AVAILABLE;
144+
}
145+
146+
// pollNext() returns only InputStatus, not the emitted-record count. Count actual collect()
147+
// calls and acquire permits after delegation.
148+
countingOutput.reset(output);
149+
InputStatus status = delegate.pollNext(countingOutput);
150+
int emittedRecords = countingOutput.getEmittedRecords();
151+
if (emittedRecords > 0) {
152+
rateLimitPermissionFuture = rateLimiter.acquire(emittedRecords).toCompletableFuture();
153+
if (status == InputStatus.MORE_AVAILABLE && !rateLimitPermissionFuture.isDone()) {
154+
// Force the runtime through isAvailable() so it waits on the limiter before polling
155+
// again.
156+
return InputStatus.NOTHING_AVAILABLE;
157+
}
158+
}
159+
return status;
160+
}
161+
162+
@Override
163+
public List<SplitT> snapshotState(long checkpointId) {
164+
return delegate.snapshotState(checkpointId);
165+
}
166+
167+
@Override
168+
public CompletableFuture<Void> isAvailable() {
169+
if (!rateLimitPermissionFuture.isDone()) {
170+
return rateLimitPermissionFuture;
171+
}
172+
return delegate.isAvailable();
173+
}
174+
175+
@Override
176+
public void addSplits(List<SplitT> splits) {
177+
splits.forEach(rateLimiter::notifyAddingSplit);
178+
delegate.addSplits(splits);
179+
}
180+
181+
@Override
182+
public void notifyNoMoreSplits() {
183+
delegate.notifyNoMoreSplits();
184+
}
185+
186+
@Override
187+
public void handleSourceEvents(SourceEvent sourceEvent) {
188+
delegate.handleSourceEvents(sourceEvent);
189+
}
190+
191+
@Override
192+
public void notifyCheckpointComplete(long checkpointId) throws Exception {
193+
// KafkaSourceReader uses checkpoint completion for offset commits; keep it delegated.
194+
delegate.notifyCheckpointComplete(checkpointId);
195+
rateLimiter.notifyCheckpointComplete(checkpointId);
196+
}
197+
198+
@Override
199+
public void pauseOrResumeSplits(
200+
Collection<String> splitsToPause, Collection<String> splitsToResume) {
201+
delegate.pauseOrResumeSplits(splitsToPause, splitsToResume);
202+
}
203+
204+
@Override
205+
public void close() throws Exception {
206+
delegate.close();
207+
}
208+
}
209+
210+
// Records can be emitted through either ReaderOutput or split-specific SourceOutput, so both
211+
// paths need to be counted.
212+
private static final class CountingReaderOutput<T> implements ReaderOutput<T> {
213+
214+
private ReaderOutput<T> delegate;
215+
private final Map<String, CountingSourceOutput<T>> splitOutputs = new HashMap<>();
216+
private int emittedRecords;
217+
218+
private void reset(ReaderOutput<T> delegate) {
219+
ReaderOutput<T> checkedDelegate =
220+
Preconditions.checkNotNull(delegate, "Delegate output must not be null.");
221+
if (this.delegate != checkedDelegate) {
222+
// Split outputs are scoped to the current ReaderOutput. Invalidate cached delegates if
223+
// Flink supplies a different output instance.
224+
splitOutputs.values().forEach(CountingSourceOutput::resetDelegateOutput);
225+
}
226+
this.delegate = checkedDelegate;
227+
emittedRecords = 0;
228+
}
229+
230+
@Override
231+
public void collect(T record) {
232+
delegate.collect(record);
233+
emittedRecords++;
234+
}
235+
236+
@Override
237+
public void collect(T record, long timestamp) {
238+
delegate.collect(record, timestamp);
239+
emittedRecords++;
240+
}
241+
242+
@Override
243+
public void emitWatermark(Watermark watermark) {
244+
delegate.emitWatermark(watermark);
245+
}
246+
247+
@Override
248+
public void markIdle() {
249+
delegate.markIdle();
250+
}
251+
252+
@Override
253+
public void markActive() {
254+
delegate.markActive();
255+
}
256+
257+
@Override
258+
public SourceOutput<T> createOutputForSplit(String splitId) {
259+
return splitOutputs.computeIfAbsent(splitId, id -> new CountingSourceOutput<>(id, this));
260+
}
261+
262+
@Override
263+
public void releaseOutputForSplit(String splitId) {
264+
splitOutputs.remove(splitId);
265+
delegate.releaseOutputForSplit(splitId);
266+
}
267+
268+
private SourceOutput<T> createDelegateOutputForSplit(String splitId) {
269+
return delegate.createOutputForSplit(splitId);
270+
}
271+
272+
private int getEmittedRecords() {
273+
return emittedRecords;
274+
}
275+
}
276+
277+
private static final class CountingSourceOutput<T> implements SourceOutput<T> {
278+
279+
private final String splitId;
280+
private final CountingReaderOutput<T> readerOutput;
281+
private SourceOutput<T> delegateOutput;
282+
283+
private CountingSourceOutput(String splitId, CountingReaderOutput<T> readerOutput) {
284+
this.splitId = Preconditions.checkNotNull(splitId, "Split ID must not be null.");
285+
this.readerOutput = readerOutput;
286+
}
287+
288+
@Override
289+
public void collect(T record) {
290+
delegateOutput().collect(record);
291+
readerOutput.emittedRecords++;
292+
}
293+
294+
@Override
295+
public void collect(T record, long timestamp) {
296+
delegateOutput().collect(record, timestamp);
297+
readerOutput.emittedRecords++;
298+
}
299+
300+
@Override
301+
public void emitWatermark(Watermark watermark) {
302+
delegateOutput().emitWatermark(watermark);
303+
}
304+
305+
@Override
306+
public void markIdle() {
307+
delegateOutput().markIdle();
308+
}
309+
310+
@Override
311+
public void markActive() {
312+
delegateOutput().markActive();
313+
}
314+
315+
private SourceOutput<T> delegateOutput() {
316+
if (delegateOutput == null) {
317+
delegateOutput = readerOutput.createDelegateOutputForSplit(splitId);
318+
}
319+
return delegateOutput;
320+
}
321+
322+
private void resetDelegateOutput() {
323+
delegateOutput = null;
324+
}
325+
}
326+
}

0 commit comments

Comments
 (0)