Skip to content

Commit e9ffaec

Browse files
authored
feat: Option to skip replaying events after failure recovery in metronome source (#337)
1 parent d0bba29 commit e9ffaec

5 files changed

Lines changed: 132 additions & 15 deletions

File tree

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
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;
2122
import org.apache.flink.table.api.ValidationException;
2223
import org.apache.flink.table.connector.source.DynamicTableSource;
2324
import org.apache.flink.table.factories.DynamicTableSourceFactory;
@@ -32,14 +33,22 @@ public class MetronomeDynamicTableSourceFactory implements DynamicTableSourceFac
3233

3334
public static final String IDENTIFIER = "metronome";
3435

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+
3543
@Override
3644
public DynamicTableSource createDynamicTableSource(Context context) {
37-
FactoryUtil.createTableFactoryHelper(this, context).validate();
45+
var helper = FactoryUtil.createTableFactoryHelper(this, context);
46+
helper.validate();
3847

3948
var rowDataType = context.getPhysicalRowDataType();
4049
validateSchema(rowDataType);
4150

42-
return new MetronomeTableSource(rowDataType);
51+
return new MetronomeTableSource(rowDataType, helper.getOptions().get(REPLAY_ON_FAILURE));
4352
}
4453

4554
@Override
@@ -54,7 +63,7 @@ public Set<ConfigOption<?>> requiredOptions() {
5463

5564
@Override
5665
public Set<ConfigOption<?>> optionalOptions() {
57-
return Set.of();
66+
return Set.of(REPLAY_ON_FAILURE);
5867
}
5968

6069
private static void validateSchema(DataType rowDataType) {

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,34 @@ 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;
4142
@Nullable private final Long numberOfRows;
4243
private final ScheduledExecutorService availabilityExecutor;
4344

4445
private CompletableFuture<Void> availability;
4546
private boolean noMoreSplits;
4647
private boolean closed;
4748
private boolean splitAssigned;
49+
private boolean skipReplayOnRecovery;
4850
private long lastEmittedNumber;
4951
private long startTimestampSec;
5052

51-
public MetronomeReader(SourceReaderContext readerContext, @Nullable Long numberOfRows) {
53+
public MetronomeReader(
54+
SourceReaderContext readerContext, boolean replayOnFailure, @Nullable Long numberOfRows) {
5255
this.readerContext = readerContext;
5356
this.numberOfRows = numberOfRows;
57+
this.replayOnFailure = replayOnFailure;
5458
this.availability = new CompletableFuture<>();
5559
this.availabilityExecutor =
5660
Executors.newSingleThreadScheduledExecutor(new ExecutorThreadFactory("metronome-source"));
5761
this.lastEmittedNumber = 0L;
5862
this.startTimestampSec = UNINITIALIZED;
5963
}
6064

65+
public MetronomeReader(SourceReaderContext readerContext, @Nullable Long numberOfRows) {
66+
this(readerContext, true, numberOfRows);
67+
}
68+
6169
@Override
6270
public void start() {
6371
readerContext.sendSplitRequest();
@@ -68,9 +76,10 @@ public void start() {
6876
* second boundary.
6977
*
7078
* <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 or recovers from a checkpoint, repeated
72-
* calls emit all missing sequence numbers with the current wall-clock timestamp until the source
73-
* catches up.
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.
7483
*/
7584
@Override
7685
public InputStatus pollNext(ReaderOutput<RowData> output) {
@@ -92,16 +101,27 @@ public InputStatus pollNext(ReaderOutput<RowData> output) {
92101
targetNumber = Math.min(targetNumber, numberOfRows);
93102
}
94103

104+
// One-shot checkpoint recovery skip; normal late wakeups still replay.
105+
boolean skipReplay = skipReplayOnRecovery;
106+
skipReplayOnRecovery = false;
107+
95108
if (lastEmittedNumber < targetNumber) {
96109
long nextNumber = lastEmittedNumber + 1;
97110
long eventTimestampMillis = currentTimestampSec * 1000L;
98111
var row =
99112
GenericRowData.of(
100113
nextNumber, TimestampData.fromInstant(Instant.ofEpochSecond(currentTimestampSec)));
101114
lastEmittedNumber = nextNumber;
115+
if (skipReplay) {
116+
// Realign time so the restored backlog is dropped after this record.
117+
startTimestampSec = currentTimestampSec - lastEmittedNumber;
118+
}
102119
output.collect(row, eventTimestampMillis);
103120

104-
return lastEmittedNumber < targetNumber ? InputStatus.MORE_AVAILABLE : nextStatus();
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();
105125
}
106126

107127
return nextStatus();
@@ -111,7 +131,7 @@ public InputStatus pollNext(ReaderOutput<RowData> output) {
111131
* Snapshots the assigned split as the reader's progress state.
112132
*
113133
* <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.
134+
* sequence number and the source's effective start epoch second.
115135
*/
116136
@Override
117137
public List<MetronomeSplit> snapshotState(long checkpointId) {
@@ -134,6 +154,9 @@ public void addSplits(List<MetronomeSplit> splits) {
134154
for (var split : splits) {
135155
lastEmittedNumber = split.lastEmittedNumber();
136156
startTimestampSec = split.startTimestampSec();
157+
// Non-initial split means checkpoint/savepoint recovery.
158+
skipReplayOnRecovery =
159+
!replayOnFailure && (startTimestampSec != UNINITIALIZED || lastEmittedNumber > 0L);
137160
splitAssigned = true;
138161
break;
139162
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,15 @@ public class MetronomeSource implements Source<RowData, MetronomeSplit, Metronom
3838

3939
@Serial private static final long serialVersionUID = 1L;
4040

41+
private final boolean replayOnFailure;
4142
@Nullable private final Long numberOfRows;
4243

4344
public MetronomeSource() {
44-
this(null);
45+
this(true, null);
46+
}
47+
48+
public MetronomeSource(@Nullable Long numberOfRows) {
49+
this(true, numberOfRows);
4550
}
4651

4752
@Override
@@ -51,7 +56,7 @@ public Boundedness getBoundedness() {
5156

5257
@Override
5358
public SourceReader<RowData, MetronomeSplit> createReader(SourceReaderContext readerContext) {
54-
return new MetronomeReader(readerContext, numberOfRows);
59+
return new MetronomeReader(readerContext, replayOnFailure, numberOfRows);
5560
}
5661

5762
@Override

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,16 @@ 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;
3536

3637
@Nullable private Long numberOfRows;
3738

3839
public MetronomeTableSource(DataType rowDataType) {
39-
this(rowDataType, null);
40+
this(rowDataType, true, null);
41+
}
42+
43+
public MetronomeTableSource(DataType rowDataType, boolean replayOnFailure) {
44+
this(rowDataType, replayOnFailure, null);
4045
}
4146

4247
@Override
@@ -46,12 +51,13 @@ public ChangelogMode getChangelogMode() {
4651

4752
@Override
4853
public ScanRuntimeProvider getScanRuntimeProvider(ScanContext scanContext) {
49-
return SourceProvider.of(new MetronomeSource(numberOfRows), SOURCE_PARALLELISM);
54+
return SourceProvider.of(
55+
new MetronomeSource(replayOnFailure, numberOfRows), SOURCE_PARALLELISM);
5056
}
5157

5258
@Override
5359
public DynamicTableSource copy() {
54-
return new MetronomeTableSource(rowDataType, numberOfRows);
60+
return new MetronomeTableSource(rowDataType, replayOnFailure, numberOfRows);
5561
}
5662

5763
@Override

connectors/datagen-connectors/src/test/java/com/datasqrl/flinkrunner/connector/datagen/metronome/MetronomeSourceIT.java

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.time.Instant;
2222
import java.util.ArrayList;
2323
import java.util.List;
24+
import java.util.concurrent.TimeUnit;
2425
import org.apache.flink.api.common.eventtime.Watermark;
2526
import org.apache.flink.api.connector.source.ReaderOutput;
2627
import org.apache.flink.api.connector.source.SourceEvent;
@@ -72,10 +73,28 @@ ts TIMESTAMP_LTZ(3),
7273
.containsExactly(Row.of(1L), Row.of(2L), Row.of(3L));
7374
}
7475

76+
@Test
77+
@Timeout(30)
78+
void acceptsDisabledReplayOnFailureOptionInSqlDdl() throws Exception {
79+
tEnv.executeSql(
80+
"""
81+
CREATE TABLE metronome_source_no_replay (
82+
num BIGINT,
83+
ts TIMESTAMP_LTZ(3),
84+
WATERMARK FOR ts AS ts
85+
) WITH (
86+
'connector' = 'metronome',
87+
'replay-on-failure' = 'false'
88+
)""");
89+
90+
assertThat(collectRows("SELECT num FROM metronome_source_no_replay", 1))
91+
.containsExactly(Row.of(1L));
92+
}
93+
7594
@Test
7695
void restoresReaderProgressWithCurrentTimestampFromCheckpointedSplit() {
7796
long startTimestampSec = Instant.now().getEpochSecond() - 3L;
78-
var reader = new MetronomeReader(unusedReaderContext(), 3L);
97+
var reader = new MetronomeReader(unusedReaderContext(), true, 3L);
7998
var output = new CollectingReaderOutput();
8099

81100
try {
@@ -95,6 +114,55 @@ void restoresReaderProgressWithCurrentTimestampFromCheckpointedSplit() {
95114
}
96115
}
97116

117+
@Test
118+
void skipsMissedEventsOnCheckpointRecoveryWhenReplayOnFailureIsDisabled() {
119+
long startTimestampSec = Instant.now().getEpochSecond() - 3L;
120+
var reader = new MetronomeReader(unusedReaderContext(), false, 5L);
121+
var output = new CollectingReaderOutput();
122+
123+
try {
124+
reader.addSplits(List.of(new MetronomeSplit(1L, startTimestampSec)));
125+
126+
long beforePollSec = Instant.now().getEpochSecond();
127+
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
128+
long afterPollSec = Instant.now().getEpochSecond();
129+
130+
assertThat(output.numbers()).containsExactly(2L);
131+
assertThat(output.eventTimestampSecs().get(0)).isBetween(beforePollSec, afterPollSec);
132+
assertThat(output.rowTimestampSecs().get(0)).isBetween(beforePollSec, afterPollSec);
133+
134+
var snapshot = reader.snapshotState(1L);
135+
assertThat(snapshot).hasSize(1);
136+
assertThat(snapshot.get(0).lastEmittedNumber()).isEqualTo(2L);
137+
assertThat(snapshot.get(0).startTimestampSec())
138+
.isBetween(beforePollSec - 2L, afterPollSec - 2L);
139+
} finally {
140+
reader.close();
141+
}
142+
}
143+
144+
@Test
145+
void replaysNormalWakeUpLatenessWhenReplayOnFailureIsDisabled() throws Exception {
146+
long startTimestampSec = Instant.now().getEpochSecond() - 3L;
147+
var reader = new MetronomeReader(unusedReaderContext(), false, 5L);
148+
var output = new CollectingReaderOutput();
149+
150+
try {
151+
reader.addSplits(List.of(new MetronomeSplit(1L, startTimestampSec)));
152+
153+
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
154+
var snapshot = reader.snapshotState(1L);
155+
assertThat(snapshot).hasSize(1);
156+
157+
sleepUntilAtLeastSecond(snapshot.get(0).startTimestampSec() + 4L);
158+
159+
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.MORE_AVAILABLE);
160+
assertThat(output.numbers()).containsExactly(2L, 3L);
161+
} finally {
162+
reader.close();
163+
}
164+
}
165+
98166
private List<Row> collectRows(String sql, int rowCount) throws Exception {
99167
List<Row> rows = new ArrayList<>();
100168
try (CloseableIterator<Row> iterator = tEnv.executeSql(sql).collect()) {
@@ -140,6 +208,12 @@ public UserCodeClassLoader getUserCodeClassLoader() {
140208
};
141209
}
142210

211+
private static void sleepUntilAtLeastSecond(long epochSecond) throws InterruptedException {
212+
while (Instant.now().getEpochSecond() < epochSecond) {
213+
TimeUnit.MILLISECONDS.sleep(10L);
214+
}
215+
}
216+
143217
private static final class CollectingReaderOutput implements ReaderOutput<RowData> {
144218

145219
private final List<RowData> rows = new ArrayList<>();

0 commit comments

Comments
 (0)