Skip to content

Commit 3de2045

Browse files
authored
feat: Rework metronome source to emit AT MOST one event per sec (#338)
1 parent e9ffaec commit 3de2045

8 files changed

Lines changed: 68 additions & 149 deletions

File tree

connectors/datagen-connectors/src/main/java/com/datasqrl/flinkrunner/connector/datagen/metronome/MetronomeDynamicTableSourceFactory.java

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import com.google.auto.service.AutoService;
1919
import java.util.Set;
2020
import org.apache.flink.configuration.ConfigOption;
21-
import org.apache.flink.configuration.ConfigOptions;
2221
import org.apache.flink.table.api.ValidationException;
2322
import org.apache.flink.table.connector.source.DynamicTableSource;
2423
import org.apache.flink.table.factories.DynamicTableSourceFactory;
@@ -33,13 +32,6 @@ public class MetronomeDynamicTableSourceFactory implements DynamicTableSourceFac
3332

3433
public static final String IDENTIFIER = "metronome";
3534

36-
public static final ConfigOption<Boolean> REPLAY_ON_FAILURE =
37-
ConfigOptions.key("replay-on-failure")
38-
.booleanType()
39-
.defaultValue(true)
40-
.withDescription(
41-
"Whether to emit sequence numbers missed while recovering from a checkpoint.");
42-
4335
@Override
4436
public DynamicTableSource createDynamicTableSource(Context context) {
4537
var helper = FactoryUtil.createTableFactoryHelper(this, context);
@@ -48,7 +40,7 @@ public DynamicTableSource createDynamicTableSource(Context context) {
4840
var rowDataType = context.getPhysicalRowDataType();
4941
validateSchema(rowDataType);
5042

51-
return new MetronomeTableSource(rowDataType, helper.getOptions().get(REPLAY_ON_FAILURE));
43+
return new MetronomeTableSource(rowDataType);
5244
}
5345

5446
@Override
@@ -63,7 +55,7 @@ public Set<ConfigOption<?>> requiredOptions() {
6355

6456
@Override
6557
public Set<ConfigOption<?>> optionalOptions() {
66-
return Set.of(REPLAY_ON_FAILURE);
58+
return Set.of();
6759
}
6860

6961
private static void validateSchema(DataType rowDataType) {

connectors/datagen-connectors/src/main/java/com/datasqrl/flinkrunner/connector/datagen/metronome/MetronomeReader.java

Lines changed: 29 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -38,32 +38,24 @@ public class MetronomeReader implements SourceReader<RowData, MetronomeSplit> {
3838
public static final long UNINITIALIZED = Long.MIN_VALUE;
3939

4040
private final SourceReaderContext readerContext;
41-
private final boolean replayOnFailure;
4241
@Nullable private final Long numberOfRows;
4342
private final ScheduledExecutorService availabilityExecutor;
4443

4544
private CompletableFuture<Void> availability;
4645
private boolean noMoreSplits;
4746
private boolean closed;
4847
private boolean splitAssigned;
49-
private boolean skipReplayOnRecovery;
5048
private long lastEmittedNumber;
51-
private long startTimestampSec;
49+
private long lastEmissionTimestampMillis;
5250

53-
public MetronomeReader(
54-
SourceReaderContext readerContext, boolean replayOnFailure, @Nullable Long numberOfRows) {
51+
public MetronomeReader(SourceReaderContext readerContext, @Nullable Long numberOfRows) {
5552
this.readerContext = readerContext;
5653
this.numberOfRows = numberOfRows;
57-
this.replayOnFailure = replayOnFailure;
5854
this.availability = new CompletableFuture<>();
5955
this.availabilityExecutor =
6056
Executors.newSingleThreadScheduledExecutor(new ExecutorThreadFactory("metronome-source"));
6157
this.lastEmittedNumber = 0L;
62-
this.startTimestampSec = UNINITIALIZED;
63-
}
64-
65-
public MetronomeReader(SourceReaderContext readerContext, @Nullable Long numberOfRows) {
66-
this(readerContext, true, numberOfRows);
58+
this.lastEmissionTimestampMillis = UNINITIALIZED;
6759
}
6860

6961
@Override
@@ -75,11 +67,9 @@ public void start() {
7567
* Emits the next due metronome row, or schedules the reader to become available at the next
7668
* second boundary.
7769
*
78-
* <p>The emitted sequence number is derived from wall-clock epoch seconds relative to the first
79-
* observed start second. If the reader wakes up late, repeated calls emit all missing sequence
80-
* numbers with the current wall-clock timestamp until the source catches up. If checkpoint
81-
* recovery replay is disabled, only the first poll after restoring checkpointed progress skips
82-
* the recovery backlog.
70+
* <p>The emitted sequence number is always the next number after the checkpointed progress. The
71+
* reader never drains wall-clock backlog in a burst: after every emitted row, it records the
72+
* current time so the next row cannot become due before a full second has elapsed.
8373
*/
8474
@Override
8575
public InputStatus pollNext(ReaderOutput<RowData> output) {
@@ -91,37 +81,16 @@ public InputStatus pollNext(ReaderOutput<RowData> output) {
9181
return noMoreSplits ? InputStatus.END_OF_INPUT : InputStatus.NOTHING_AVAILABLE;
9282
}
9383

94-
long currentTimestampSec = currentTimestampSec();
95-
if (startTimestampSec == UNINITIALIZED) {
96-
startTimestampSec = currentTimestampSec;
97-
}
84+
var currentTimestamp = Instant.now();
85+
long currentTimestampMillis = currentTimestamp.toEpochMilli();
9886

99-
long targetNumber = currentTimestampSec - startTimestampSec;
100-
if (numberOfRows != null) {
101-
targetNumber = Math.min(targetNumber, numberOfRows);
102-
}
103-
104-
// One-shot checkpoint recovery skip; normal late wakeups still replay.
105-
boolean skipReplay = skipReplayOnRecovery;
106-
skipReplayOnRecovery = false;
107-
108-
if (lastEmittedNumber < targetNumber) {
87+
if (isDue(currentTimestampMillis)
88+
&& (numberOfRows == null || lastEmittedNumber < numberOfRows)) {
10989
long nextNumber = lastEmittedNumber + 1;
110-
long eventTimestampMillis = currentTimestampSec * 1000L;
111-
var row =
112-
GenericRowData.of(
113-
nextNumber, TimestampData.fromInstant(Instant.ofEpochSecond(currentTimestampSec)));
90+
var row = GenericRowData.of(nextNumber, TimestampData.fromInstant(currentTimestamp));
11491
lastEmittedNumber = nextNumber;
115-
if (skipReplay) {
116-
// Realign time so the restored backlog is dropped after this record.
117-
startTimestampSec = currentTimestampSec - lastEmittedNumber;
118-
}
119-
output.collect(row, eventTimestampMillis);
120-
121-
// Skip waits for the next tick; otherwise drain due records until caught up.
122-
return !skipReplay && lastEmittedNumber < targetNumber
123-
? InputStatus.MORE_AVAILABLE
124-
: nextStatus();
92+
lastEmissionTimestampMillis = currentTimestampMillis;
93+
output.collect(row, currentTimestampMillis);
12594
}
12695

12796
return nextStatus();
@@ -131,15 +100,15 @@ public InputStatus pollNext(ReaderOutput<RowData> output) {
131100
* Snapshots the assigned split as the reader's progress state.
132101
*
133102
* <p>The split carries the only source progress that must survive failover: the last emitted
134-
* sequence number and the source's effective start epoch second.
103+
* sequence number.
135104
*/
136105
@Override
137106
public List<MetronomeSplit> snapshotState(long checkpointId) {
138107
if (!splitAssigned || closed || (numberOfRows != null && lastEmittedNumber >= numberOfRows)) {
139108
return List.of();
140109
}
141110

142-
return List.of(new MetronomeSplit(lastEmittedNumber, startTimestampSec));
111+
return List.of(new MetronomeSplit(lastEmittedNumber));
143112
}
144113

145114
/** Returns the current availability future used by the Flink runtime to avoid busy polling. */
@@ -153,10 +122,6 @@ public CompletableFuture<Void> isAvailable() {
153122
public void addSplits(List<MetronomeSplit> splits) {
154123
for (var split : splits) {
155124
lastEmittedNumber = split.lastEmittedNumber();
156-
startTimestampSec = split.startTimestampSec();
157-
// Non-initial split means checkpoint/savepoint recovery.
158-
skipReplayOnRecovery =
159-
!replayOnFailure && (startTimestampSec != UNINITIALIZED || lastEmittedNumber > 0L);
160125
splitAssigned = true;
161126
break;
162127
}
@@ -181,8 +146,8 @@ public void close() {
181146
* Determines the next source status after a poll cycle.
182147
*
183148
* <p>The source ends when bounded output is exhausted or the sequence reaches {@link
184-
* Long#MAX_VALUE}. Otherwise, it replaces the availability future and schedules it for the next
185-
* epoch-second boundary.
149+
* Long#MAX_VALUE}. Otherwise, it replaces the availability future and schedules it one second
150+
* later.
186151
*/
187152
private InputStatus nextStatus() {
188153
if (lastEmittedNumber == Long.MAX_VALUE
@@ -191,36 +156,28 @@ private InputStatus nextStatus() {
191156
return InputStatus.END_OF_INPUT;
192157
}
193158

194-
if (noMoreSplits && startTimestampSec == UNINITIALIZED) {
195-
return InputStatus.END_OF_INPUT;
196-
}
197-
198159
availability = new CompletableFuture<>();
199-
scheduleAvailability(nanosUntilNextSecond());
160+
scheduleAvailability();
200161

201162
return InputStatus.NOTHING_AVAILABLE;
202163
}
203164

165+
private boolean isDue(long currentTimestampMillis) {
166+
return lastEmissionTimestampMillis == UNINITIALIZED
167+
|| currentTimestampMillis - lastEmissionTimestampMillis >= TimeUnit.SECONDS.toMillis(1);
168+
}
169+
204170
private void completeAvailability() {
205171
availability.complete(null);
206172
}
207173

208-
/** Schedules completion of the current availability future using nanosecond delay precision. */
209-
private void scheduleAvailability(long delayNanos) {
174+
/** Schedules completion of the current availability future one second later. */
175+
private void scheduleAvailability() {
210176
if (!closed) {
211-
availabilityExecutor.schedule(this::completeAvailability, delayNanos, TimeUnit.NANOSECONDS);
177+
// Capture the current future so stale timers cannot wake newer poll cycles
178+
var scheduledAvailability = availability;
179+
availabilityExecutor.schedule(
180+
() -> scheduledAvailability.complete(null), 1, TimeUnit.SECONDS);
212181
}
213182
}
214-
215-
/** Returns the current wall-clock epoch second used for sequence catch-up calculations. */
216-
private static long currentTimestampSec() {
217-
return Instant.now().getEpochSecond();
218-
}
219-
220-
/** Returns the remaining nanoseconds until the next wall-clock epoch-second boundary. */
221-
private static long nanosUntilNextSecond() {
222-
int currentNano = Instant.now().getNano();
223-
224-
return TimeUnit.SECONDS.toNanos(1L) - currentNano;
225-
}
226183
}

connectors/datagen-connectors/src/main/java/com/datasqrl/flinkrunner/connector/datagen/metronome/MetronomeSource.java

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import com.datasqrl.flinkrunner.connector.datagen.metronome.split.MetronomeSplitSerializer;
2323
import java.io.Serial;
2424
import javax.annotation.Nullable;
25-
import lombok.AllArgsConstructor;
2625
import org.apache.flink.api.connector.source.Boundedness;
2726
import org.apache.flink.api.connector.source.Source;
2827
import org.apache.flink.api.connector.source.SourceReader;
@@ -33,20 +32,18 @@
3332
import org.apache.flink.table.data.RowData;
3433

3534
/** Source that produces a single global metronome sequence with event-time timestamps. */
36-
@AllArgsConstructor
3735
public class MetronomeSource implements Source<RowData, MetronomeSplit, MetronomeEnumeratorState> {
3836

3937
@Serial private static final long serialVersionUID = 1L;
4038

41-
private final boolean replayOnFailure;
4239
@Nullable private final Long numberOfRows;
4340

4441
public MetronomeSource() {
45-
this(true, null);
42+
this(null);
4643
}
4744

4845
public MetronomeSource(@Nullable Long numberOfRows) {
49-
this(true, numberOfRows);
46+
this.numberOfRows = numberOfRows;
5047
}
5148

5249
@Override
@@ -56,7 +53,7 @@ public Boundedness getBoundedness() {
5653

5754
@Override
5855
public SourceReader<RowData, MetronomeSplit> createReader(SourceReaderContext readerContext) {
59-
return new MetronomeReader(readerContext, replayOnFailure, numberOfRows);
56+
return new MetronomeReader(readerContext, numberOfRows);
6057
}
6158

6259
@Override

connectors/datagen-connectors/src/main/java/com/datasqrl/flinkrunner/connector/datagen/metronome/MetronomeTableSource.java

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,11 @@ public class MetronomeTableSource implements ScanTableSource, SupportsLimitPushD
3232
private static final int SOURCE_PARALLELISM = 1;
3333

3434
private final DataType rowDataType;
35-
private final boolean replayOnFailure;
3635

3736
@Nullable private Long numberOfRows;
3837

3938
public MetronomeTableSource(DataType rowDataType) {
40-
this(rowDataType, true, null);
41-
}
42-
43-
public MetronomeTableSource(DataType rowDataType, boolean replayOnFailure) {
44-
this(rowDataType, replayOnFailure, null);
39+
this(rowDataType, null);
4540
}
4641

4742
@Override
@@ -51,13 +46,12 @@ public ChangelogMode getChangelogMode() {
5146

5247
@Override
5348
public ScanRuntimeProvider getScanRuntimeProvider(ScanContext scanContext) {
54-
return SourceProvider.of(
55-
new MetronomeSource(replayOnFailure, numberOfRows), SOURCE_PARALLELISM);
49+
return SourceProvider.of(new MetronomeSource(numberOfRows), SOURCE_PARALLELISM);
5650
}
5751

5852
@Override
5953
public DynamicTableSource copy() {
60-
return new MetronomeTableSource(rowDataType, replayOnFailure, numberOfRows);
54+
return new MetronomeTableSource(rowDataType, numberOfRows);
6155
}
6256

6357
@Override

connectors/datagen-connectors/src/main/java/com/datasqrl/flinkrunner/connector/datagen/metronome/enumerator/MetronomeEnumeratorStateSerializer.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,14 @@ public int getVersion() {
3232

3333
@Override
3434
public byte[] serialize(MetronomeEnumeratorState state) throws IOException {
35-
var out = new DataOutputSerializer(18);
35+
var out = new DataOutputSerializer(10);
3636
var pendingSplit = state.getPendingSplit();
3737

3838
out.writeBoolean(state.isAssigned());
3939
out.writeBoolean(pendingSplit != null);
4040

4141
if (pendingSplit != null) {
4242
out.writeLong(pendingSplit.lastEmittedNumber());
43-
out.writeLong(pendingSplit.startTimestampSec());
4443
}
4544

4645
return out.getCopyOfBuffer();
@@ -54,7 +53,7 @@ public MetronomeEnumeratorState deserialize(int version, byte[] serialized) thro
5453
boolean hasPendingSplit = in.readBoolean();
5554

5655
return hasPendingSplit
57-
? MetronomeEnumeratorState.pending(new MetronomeSplit(in.readLong(), in.readLong()))
56+
? MetronomeEnumeratorState.pending(new MetronomeSplit(in.readLong()))
5857
: MetronomeEnumeratorState.of(assigned);
5958
}
6059
}

connectors/datagen-connectors/src/main/java/com/datasqrl/flinkrunner/connector/datagen/metronome/split/MetronomeSplit.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,13 @@
1515
*/
1616
package com.datasqrl.flinkrunner.connector.datagen.metronome.split;
1717

18-
import com.datasqrl.flinkrunner.connector.datagen.metronome.MetronomeReader;
1918
import org.apache.flink.api.connector.source.SourceSplit;
2019

2120
/** Single source split carrying metronome reader progress across checkpoints and recovery. */
22-
public record MetronomeSplit(long lastEmittedNumber, long startTimestampSec)
23-
implements SourceSplit {
21+
public record MetronomeSplit(long lastEmittedNumber) implements SourceSplit {
2422

2523
public static MetronomeSplit initial() {
26-
return new MetronomeSplit(0L, MetronomeReader.UNINITIALIZED);
24+
return new MetronomeSplit(0L);
2725
}
2826

2927
@Override

connectors/datagen-connectors/src/main/java/com/datasqrl/flinkrunner/connector/datagen/metronome/split/MetronomeSplitSerializer.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@ public int getVersion() {
3030

3131
@Override
3232
public byte[] serialize(MetronomeSplit split) throws IOException {
33-
var out = new DataOutputSerializer(16);
33+
var out = new DataOutputSerializer(8);
3434
out.writeLong(split.lastEmittedNumber());
35-
out.writeLong(split.startTimestampSec());
3635

3736
return out.getCopyOfBuffer();
3837
}
@@ -41,6 +40,6 @@ public byte[] serialize(MetronomeSplit split) throws IOException {
4140
public MetronomeSplit deserialize(int version, byte[] serialized) throws IOException {
4241
var in = new DataInputDeserializer(serialized);
4342

44-
return new MetronomeSplit(in.readLong(), in.readLong());
43+
return new MetronomeSplit(in.readLong());
4544
}
4645
}

0 commit comments

Comments
 (0)