Skip to content

Commit f0bda49

Browse files
authored
feat: Add metronome source implementation (#324)
1 parent b742b21 commit f0bda49

13 files changed

Lines changed: 918 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
4+
Copyright © 2026 DataSQRL (contact@datasqrl.com)
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
18+
-->
19+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
20+
<modelVersion>4.0.0</modelVersion>
21+
22+
<parent>
23+
<groupId>com.datasqrl.flinkrunner</groupId>
24+
<artifactId>connectors</artifactId>
25+
<version>1.0-SNAPSHOT</version>
26+
</parent>
27+
28+
<artifactId>datagen-connectors</artifactId>
29+
<name>Datagen Connectors</name>
30+
31+
<dependencies>
32+
<dependency>
33+
<groupId>org.apache.flink</groupId>
34+
<artifactId>flink-table-api-java-bridge</artifactId>
35+
<version>${flink.version}</version>
36+
<scope>provided</scope>
37+
</dependency>
38+
39+
<dependency>
40+
<groupId>org.apache.flink</groupId>
41+
<artifactId>flink-table-planner_2.12</artifactId>
42+
<version>${flink.version}</version>
43+
<scope>test</scope>
44+
</dependency>
45+
46+
<dependency>
47+
<groupId>org.apache.flink</groupId>
48+
<artifactId>flink-test-utils</artifactId>
49+
<version>${flink.version}</version>
50+
<scope>test</scope>
51+
</dependency>
52+
</dependencies>
53+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.datagen.metronome;
17+
18+
import com.google.auto.service.AutoService;
19+
import java.util.Set;
20+
import org.apache.flink.configuration.ConfigOption;
21+
import org.apache.flink.table.api.ValidationException;
22+
import org.apache.flink.table.connector.source.DynamicTableSource;
23+
import org.apache.flink.table.factories.DynamicTableSourceFactory;
24+
import org.apache.flink.table.factories.Factory;
25+
import org.apache.flink.table.factories.FactoryUtil;
26+
import org.apache.flink.table.types.DataType;
27+
import org.apache.flink.table.types.logical.LogicalTypeRoot;
28+
29+
/** Dynamic table factory for the {@code metronome} connector. */
30+
@AutoService(Factory.class)
31+
public class MetronomeDynamicTableSourceFactory implements DynamicTableSourceFactory {
32+
33+
public static final String IDENTIFIER = "metronome";
34+
35+
@Override
36+
public DynamicTableSource createDynamicTableSource(Context context) {
37+
FactoryUtil.createTableFactoryHelper(this, context).validate();
38+
39+
var rowDataType = context.getPhysicalRowDataType();
40+
validateSchema(rowDataType);
41+
42+
return new MetronomeTableSource(rowDataType);
43+
}
44+
45+
@Override
46+
public String factoryIdentifier() {
47+
return IDENTIFIER;
48+
}
49+
50+
@Override
51+
public Set<ConfigOption<?>> requiredOptions() {
52+
return Set.of();
53+
}
54+
55+
@Override
56+
public Set<ConfigOption<?>> optionalOptions() {
57+
return Set.of();
58+
}
59+
60+
private static void validateSchema(DataType rowDataType) {
61+
var fieldDataTypes = DataType.getFieldDataTypes(rowDataType);
62+
if (fieldDataTypes.size() != 2) {
63+
throw new ValidationException(
64+
"Metronome source expects exactly two physical columns: BIGINT sequence number and timestamp.");
65+
}
66+
67+
var firstField = fieldDataTypes.get(0).getLogicalType().getTypeRoot();
68+
var secondField = fieldDataTypes.get(1).getLogicalType().getTypeRoot();
69+
70+
if (firstField != LogicalTypeRoot.BIGINT) {
71+
throw new ValidationException("Metronome source first column must be BIGINT.");
72+
}
73+
74+
if (secondField != LogicalTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE
75+
&& secondField != LogicalTypeRoot.TIMESTAMP_WITHOUT_TIME_ZONE) {
76+
throw new ValidationException(
77+
"Metronome source second column must be TIMESTAMP or TIMESTAMP_LTZ.");
78+
}
79+
}
80+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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.datagen.metronome;
17+
18+
import com.datasqrl.flinkrunner.connector.datagen.metronome.split.MetronomeSplit;
19+
import java.time.Instant;
20+
import java.util.List;
21+
import java.util.concurrent.CompletableFuture;
22+
import java.util.concurrent.Executors;
23+
import java.util.concurrent.ScheduledExecutorService;
24+
import java.util.concurrent.TimeUnit;
25+
import javax.annotation.Nullable;
26+
import org.apache.flink.api.connector.source.ReaderOutput;
27+
import org.apache.flink.api.connector.source.SourceReader;
28+
import org.apache.flink.api.connector.source.SourceReaderContext;
29+
import org.apache.flink.core.io.InputStatus;
30+
import org.apache.flink.table.data.GenericRowData;
31+
import org.apache.flink.table.data.RowData;
32+
import org.apache.flink.table.data.TimestampData;
33+
import org.apache.flink.util.concurrent.ExecutorThreadFactory;
34+
35+
/** Source reader that emits due metronome rows and snapshots sequence progress in its split. */
36+
public class MetronomeReader implements SourceReader<RowData, MetronomeSplit> {
37+
38+
public static final long UNINITIALIZED = Long.MIN_VALUE;
39+
40+
private final SourceReaderContext readerContext;
41+
@Nullable private final Long numberOfRows;
42+
private final ScheduledExecutorService availabilityExecutor;
43+
44+
private CompletableFuture<Void> availability;
45+
private boolean noMoreSplits;
46+
private boolean closed;
47+
private boolean splitAssigned;
48+
private long lastEmittedNumber;
49+
private long startTimestampSec;
50+
51+
public MetronomeReader(SourceReaderContext readerContext, @Nullable Long numberOfRows) {
52+
this.readerContext = readerContext;
53+
this.numberOfRows = numberOfRows;
54+
this.availability = new CompletableFuture<>();
55+
this.availabilityExecutor =
56+
Executors.newSingleThreadScheduledExecutor(new ExecutorThreadFactory("metronome-source"));
57+
this.lastEmittedNumber = 0L;
58+
this.startTimestampSec = UNINITIALIZED;
59+
}
60+
61+
@Override
62+
public void start() {
63+
readerContext.sendSplitRequest();
64+
}
65+
66+
/**
67+
* Emits the next due metronome row, or schedules the reader to become available at the next
68+
* second boundary.
69+
*
70+
* <p>The emitted sequence number is derived from wall-clock epoch seconds relative to the first
71+
* observed start second. If the reader wakes up late, repeated calls emit all missing sequence
72+
* numbers with their intended event timestamps until the source catches up.
73+
*/
74+
@Override
75+
public InputStatus pollNext(ReaderOutput<RowData> output) {
76+
if (closed) {
77+
return InputStatus.END_OF_INPUT;
78+
}
79+
80+
if (!splitAssigned) {
81+
return noMoreSplits ? InputStatus.END_OF_INPUT : InputStatus.NOTHING_AVAILABLE;
82+
}
83+
84+
long currentTimestampSec = currentTimestampSec();
85+
if (startTimestampSec == UNINITIALIZED) {
86+
startTimestampSec = currentTimestampSec;
87+
}
88+
89+
long targetNumber = currentTimestampSec - startTimestampSec;
90+
if (numberOfRows != null) {
91+
targetNumber = Math.min(targetNumber, numberOfRows);
92+
}
93+
94+
if (lastEmittedNumber < targetNumber) {
95+
long nextNumber = lastEmittedNumber + 1;
96+
long eventTimestampSec = startTimestampSec + nextNumber;
97+
long eventTimestampMillis = eventTimestampSec * 1000L;
98+
var row =
99+
GenericRowData.of(
100+
nextNumber, TimestampData.fromInstant(Instant.ofEpochSecond(eventTimestampSec)));
101+
lastEmittedNumber = nextNumber;
102+
output.collect(row, eventTimestampMillis);
103+
104+
return lastEmittedNumber < targetNumber ? InputStatus.MORE_AVAILABLE : nextStatus();
105+
}
106+
107+
return nextStatus();
108+
}
109+
110+
/**
111+
* Snapshots the assigned split as the reader's progress state.
112+
*
113+
* <p>The split carries the only source progress that must survive failover: the last emitted
114+
* sequence number and the source's fixed start epoch second.
115+
*/
116+
@Override
117+
public List<MetronomeSplit> snapshotState(long checkpointId) {
118+
if (!splitAssigned || closed || (numberOfRows != null && lastEmittedNumber >= numberOfRows)) {
119+
return List.of();
120+
}
121+
122+
return List.of(new MetronomeSplit(lastEmittedNumber, startTimestampSec));
123+
}
124+
125+
/** Returns the current availability future used by the Flink runtime to avoid busy polling. */
126+
@Override
127+
public CompletableFuture<Void> isAvailable() {
128+
return availability;
129+
}
130+
131+
/** Restores progress from the assigned split and makes the reader immediately pollable. */
132+
@Override
133+
public void addSplits(List<MetronomeSplit> splits) {
134+
for (var split : splits) {
135+
lastEmittedNumber = split.lastEmittedNumber();
136+
startTimestampSec = split.startTimestampSec();
137+
splitAssigned = true;
138+
break;
139+
}
140+
completeAvailability();
141+
}
142+
143+
@Override
144+
public void notifyNoMoreSplits() {
145+
noMoreSplits = true;
146+
completeAvailability();
147+
}
148+
149+
/** Closes the reader and wakes any runtime caller blocked on the current availability future. */
150+
@Override
151+
public void close() {
152+
closed = true;
153+
availabilityExecutor.shutdownNow();
154+
completeAvailability();
155+
}
156+
157+
/**
158+
* Determines the next source status after a poll cycle.
159+
*
160+
* <p>The source ends when bounded output is exhausted or the sequence reaches {@link
161+
* Long#MAX_VALUE}. Otherwise, it replaces the availability future and schedules it for the next
162+
* epoch-second boundary.
163+
*/
164+
private InputStatus nextStatus() {
165+
if (lastEmittedNumber == Long.MAX_VALUE
166+
|| (numberOfRows != null && lastEmittedNumber >= numberOfRows)) {
167+
closed = true;
168+
return InputStatus.END_OF_INPUT;
169+
}
170+
171+
if (noMoreSplits && startTimestampSec == UNINITIALIZED) {
172+
return InputStatus.END_OF_INPUT;
173+
}
174+
175+
availability = new CompletableFuture<>();
176+
scheduleAvailability(nanosUntilNextSecond());
177+
178+
return InputStatus.NOTHING_AVAILABLE;
179+
}
180+
181+
private void completeAvailability() {
182+
availability.complete(null);
183+
}
184+
185+
/** Schedules completion of the current availability future using nanosecond delay precision. */
186+
private void scheduleAvailability(long delayNanos) {
187+
if (!closed) {
188+
availabilityExecutor.schedule(this::completeAvailability, delayNanos, TimeUnit.NANOSECONDS);
189+
}
190+
}
191+
192+
/** Returns the current wall-clock epoch second used for sequence catch-up calculations. */
193+
private static long currentTimestampSec() {
194+
return Instant.now().getEpochSecond();
195+
}
196+
197+
/** Returns the remaining nanoseconds until the next wall-clock epoch-second boundary. */
198+
private static long nanosUntilNextSecond() {
199+
int currentNano = Instant.now().getNano();
200+
201+
return TimeUnit.SECONDS.toNanos(1L) - currentNano;
202+
}
203+
}

0 commit comments

Comments
 (0)