This is an automated email from the ASF dual-hosted git repository.
leonardBang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git
The following commit(s) were added to refs/heads/master by this push:
new b5152df63 [minor][pipeline-connector/sqlserver] Align MySQL's schema
restore process instead of per-record JDBC
b5152df63 is described below
commit b5152df634bc79fed05125ee748016bb48e7af12
Author: Leonard Xu <[email protected]>
AuthorDate: Thu Jul 2 16:33:56 2026 +0800
[minor][pipeline-connector/sqlserver] Align MySQL's schema restore process
instead of per-record JDBC
This closes #4446.
Co-authored-by: Claude Opus 4.7 <[email protected]>
---
.../reader/SqlServerPipelineRecordEmitter.java | 59 +--
.../SqlServerPipelineSavepointRestoreITCase.java | 428 +++++++++++++++++++++
.../reader/SqlServerPipelineRecordEmitterTest.java | 147 +++++++
.../source/reader/IncrementalSourceReader.java | 3 +
.../source/reader/IncrementalSourceReaderTest.java | 172 +++++++++
5 files changed, 784 insertions(+), 25 deletions(-)
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/main/java/org/apache/flink/cdc/connectors/sqlserver/source/reader/SqlServerPipelineRecordEmitter.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/main/java/org/apache/flink/cdc/connectors/sqlserver/source/reader/SqlServerPipelineRecordEmitter.java
index 42bc908b6..cc789ed40 100644
---
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/main/java/org/apache/flink/cdc/connectors/sqlserver/source/reader/SqlServerPipelineRecordEmitter.java
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/main/java/org/apache/flink/cdc/connectors/sqlserver/source/reader/SqlServerPipelineRecordEmitter.java
@@ -23,6 +23,8 @@ import org.apache.flink.cdc.common.event.TableId;
import org.apache.flink.cdc.common.schema.Schema;
import org.apache.flink.cdc.connectors.base.options.StartupOptions;
import org.apache.flink.cdc.connectors.base.source.meta.offset.OffsetFactory;
+import org.apache.flink.cdc.connectors.base.source.meta.split.SnapshotSplit;
+import org.apache.flink.cdc.connectors.base.source.meta.split.SourceSplitBase;
import org.apache.flink.cdc.connectors.base.source.meta.split.SourceSplitState;
import org.apache.flink.cdc.connectors.base.source.metrics.SourceReaderMetrics;
import
org.apache.flink.cdc.connectors.base.source.reader.IncrementalSourceRecordEmitter;
@@ -39,6 +41,7 @@ import
io.debezium.relational.history.TableChanges.TableChange;
import org.apache.kafka.connect.source.SourceRecord;
import java.sql.SQLException;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -83,7 +86,6 @@ public class SqlServerPipelineRecordEmitter<T> extends
IncrementalSourceRecordEm
((DebeziumEventDeserializationSchema)
debeziumDeserializationSchema)
.getCreateTableEventCache();
this.isBounded =
StartupOptions.snapshot().equals(sourceConfig.getStartupOptions());
- generateCreateTableEvents();
}
@Override
@@ -91,8 +93,7 @@ public class SqlServerPipelineRecordEmitter<T> extends
IncrementalSourceRecordEm
SourceRecord element, SourceOutput<T> output, SourceSplitState
splitState)
throws Exception {
if (isSchemaChangeEvent(element) && splitState.isStreamSplitState()) {
- restoreCreateTableEventsFromSplitSchemas(
- splitState.asStreamSplitState().getTableSchemas());
+
cacheCreateTableEventsFromSchemas(splitState.asStreamSplitState().getTableSchemas());
}
if (shouldEmitAllCreateTableEventsInSnapshotMode && isBounded) {
@@ -104,15 +105,25 @@ public class SqlServerPipelineRecordEmitter<T> extends
IncrementalSourceRecordEm
// to downstream to avoid checkpoint timeout.
io.debezium.relational.TableId tableId =
splitState.asSnapshotSplitState().toSourceSplit().getTableId();
- emitCreateTableEventIfNeeded(tableId, output);
+ emitCreateTableEventIfNeeded(tableId, output, splitState);
} else if (isDataChangeRecord(element)) {
// Handle data change events, schema change events are handled
downstream directly
io.debezium.relational.TableId tableId = getTableId(element);
- emitCreateTableEventIfNeeded(tableId, output);
+ emitCreateTableEventIfNeeded(tableId, output, splitState);
}
super.processElement(element, output, splitState);
}
+ @Override
+ public void applySplit(SourceSplitBase split) {
+ if (isBounded && createTableEventCache.isEmpty() && split instanceof
SnapshotSplit) {
+ // TableSchemas in SnapshotSplit only contains one table.
+ createTableEventCache.putAll(generateCreateTableEvents());
+ } else {
+ cacheCreateTableEventsFromSchemas(split.getTableSchemas());
+ }
+ }
+
@SuppressWarnings("unchecked")
private void emitAllCreateTableEvents(SourceOutput<T> output) {
createTableEventCache.forEach(
@@ -124,33 +135,28 @@ public class SqlServerPipelineRecordEmitter<T> extends
IncrementalSourceRecordEm
@SuppressWarnings("unchecked")
private void emitCreateTableEventIfNeeded(
- io.debezium.relational.TableId tableId, SourceOutput<T> output) {
+ io.debezium.relational.TableId tableId,
+ SourceOutput<T> output,
+ SourceSplitState splitState) {
if (alreadySendCreateTableTables.contains(tableId)) {
return;
}
+
cacheCreateTableEventsFromSchemas(splitState.toSourceSplit().getTableSchemas());
CreateTableEvent createTableEvent = createTableEventCache.get(tableId);
- if (createTableEvent != null) {
- output.collect((T) createTableEvent);
- } else {
- // Table not in cache, fetch schema from database
- try (SqlServerConnection jdbc =
-
createSqlServerConnection(sourceConfig.getDbzConnectorConfig())) {
- createTableEvent = buildCreateTableEvent(jdbc, tableId);
- output.collect((T) createTableEvent);
- createTableEventCache.put(tableId, createTableEvent);
- } catch (SQLException e) {
- throw new RuntimeException("Failed to get table schema for " +
tableId, e);
- }
+ if (createTableEvent == null) {
+ throw new IllegalStateException(
+ "Missing CreateTableEvent for table "
+ + tableId
+ + ". Table schema should have been restored before
processing records.");
}
+ output.collect((T) createTableEvent);
alreadySendCreateTableTables.add(tableId);
}
- private void restoreCreateTableEventsFromSplitSchemas(
+ private void cacheCreateTableEventsFromSchemas(
Map<io.debezium.relational.TableId, TableChange> tableSchemas) {
- if (!sourceConfig.isIncludeSchemaChanges()
- || tableSchemas == null
- || tableSchemas.isEmpty()) {
+ if (tableSchemas == null || tableSchemas.isEmpty()) {
return;
}
for (Map.Entry<io.debezium.relational.TableId, TableChange> entry :
@@ -160,7 +166,7 @@ public class SqlServerPipelineRecordEmitter<T> extends
IncrementalSourceRecordEm
if (tableId == null || tableChange == null ||
tableChange.getTable() == null) {
continue;
}
- createTableEventCache.putIfAbsent(
+ createTableEventCache.put(
tableId,
buildCreateTableEvent(
tableId,
SqlServerSchemaUtils.toSchema(tableChange.getTable())));
@@ -179,16 +185,19 @@ public class SqlServerPipelineRecordEmitter<T> extends
IncrementalSourceRecordEm
TableId.tableId(tableId.catalog(), tableId.schema(),
tableId.table()), schema);
}
- private void generateCreateTableEvents() {
+ private Map<io.debezium.relational.TableId, CreateTableEvent>
generateCreateTableEvents() {
try (SqlServerConnection jdbc =
createSqlServerConnection(sourceConfig.getDbzConnectorConfig())) {
+ Map<io.debezium.relational.TableId, CreateTableEvent>
createTableEvents =
+ new HashMap<>();
List<io.debezium.relational.TableId> capturedTableIds =
SqlServerConnectionUtils.listTables(
jdbc, sourceConfig.getTableFilters(),
sourceConfig.getDatabaseList());
for (io.debezium.relational.TableId tableId : capturedTableIds) {
CreateTableEvent createTableEvent =
buildCreateTableEvent(jdbc, tableId);
- createTableEventCache.put(tableId, createTableEvent);
+ createTableEvents.put(tableId, createTableEvent);
}
+ return createTableEvents;
} catch (SQLException e) {
throw new RuntimeException("Cannot start emitter to fetch table
schema.", e);
}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/test/java/org/apache/flink/cdc/connectors/sqlserver/source/SqlServerPipelineSavepointRestoreITCase.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/test/java/org/apache/flink/cdc/connectors/sqlserver/source/SqlServerPipelineSavepointRestoreITCase.java
new file mode 100644
index 000000000..51d863925
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/test/java/org/apache/flink/cdc/connectors/sqlserver/source/SqlServerPipelineSavepointRestoreITCase.java
@@ -0,0 +1,428 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.sqlserver.source;
+
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.cdc.common.data.binary.BinaryRecordData;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.AlterColumnTypeEvent;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.DropColumnEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.PhysicalColumn;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.source.FlinkSourceProvider;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.connectors.base.options.StartupOptions;
+import org.apache.flink.cdc.connectors.sqlserver.SqlServerTestBase;
+import
org.apache.flink.cdc.connectors.sqlserver.factory.SqlServerDataSourceFactory;
+import
org.apache.flink.cdc.connectors.sqlserver.source.config.SqlServerSourceConfigFactory;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.cdc.runtime.typeutils.EventTypeInfo;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.StateRecoveryOptions;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.core.execution.SavepointFormatType;
+import org.apache.flink.runtime.checkpoint.CheckpointException;
+import org.apache.flink.streaming.api.datastream.DataStreamSource;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.functions.sink.SinkFunction;
+import org.apache.flink.streaming.util.RestartStrategyUtils;
+import org.apache.flink.table.planner.factories.TestValuesTableFactory;
+import org.apache.flink.util.ExceptionUtils;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static
org.testcontainers.containers.MSSQLServerContainer.MS_SQL_SERVER_PORT;
+
+/** Savepoint/restore IT for SQL Server pipeline source schema evolution. */
+class SqlServerPipelineSavepointRestoreITCase extends SqlServerTestBase {
+
+ private static final String DATABASE_NAME = "customer";
+ private static final Map<String, BlockingQueue<Event>> EVENTS_BY_SINK =
+ new ConcurrentHashMap<>();
+
+ @BeforeEach
+ void before() {
+ initializeSqlServerTable(DATABASE_NAME);
+ TestValuesTableFactory.clearAllData();
+ EVENTS_BY_SINK.clear();
+ }
+
+ @Test
+ @Timeout(value = 300, unit = TimeUnit.SECONDS)
+ void testSchemaEvolutionContinuesAfterSavepointRestore() throws Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME, "dbo", "customers");
+ Schema schemaV1 = schemaV1();
+ Schema schemaV2 = schemaV2();
+ Schema schemaV3 = schemaV3();
+ Schema schemaV4 = schemaV4();
+
+ SqlServerSourceConfigFactory configFactory =
+ (SqlServerSourceConfigFactory)
+ new SqlServerSourceConfigFactory()
+ .hostname(MSSQL_SERVER_CONTAINER.getHost())
+
.port(MSSQL_SERVER_CONTAINER.getMappedPort(MS_SQL_SERVER_PORT))
+ .username(MSSQL_SERVER_CONTAINER.getUsername())
+ .password(MSSQL_SERVER_CONTAINER.getPassword())
+ .databaseList(DATABASE_NAME)
+ .tableList("dbo.customers")
+ .startupOptions(StartupOptions.initial())
+ .includeSchemaChanges(true)
+ .serverTimeZone("UTC");
+
+ String beforeSinkId = "before-" + UUID.randomUUID();
+ String afterSinkId = "after-" + UUID.randomUUID();
+ EVENTS_BY_SINK.put(beforeSinkId, new LinkedBlockingQueue<>());
+ EVENTS_BY_SINK.put(afterSinkId, new LinkedBlockingQueue<>());
+
+ Path savepointDir =
Files.createTempDirectory("sqlserver-schema-restore-test");
+ String savepointDirectory = savepointDir.toAbsolutePath().toString();
+
+ JobClient firstJobClient = null;
+ JobClient restoredJobClient = null;
+ try {
+ StreamExecutionEnvironment firstEnv =
getStreamExecutionEnvironment(null, 1);
+ DataStreamSource<Event> firstSource =
+ createSource(firstEnv, configFactory,
SqlServerDataSourceFactory.IDENTIFIER);
+ firstSource.addSink(new EventQueueSink(beforeSinkId)).name("Event
queue sink");
+ firstJobClient =
firstEnv.executeAsync("SqlServerSchemaRestoreBefore");
+
+ List<Event> firstRoundEvents = drainSinkResults(beforeSinkId, 22);
+ assertThat(
+ firstRoundEvents.stream()
+ .filter(CreateTableEvent.class::isInstance)
+ .collect(Collectors.toList()))
+ .containsExactly(new CreateTableEvent(tableId, schemaV1));
+ assertThat(
+ firstRoundEvents.stream()
+ .filter(event -> !(event instanceof
CreateTableEvent))
+ .collect(Collectors.toList()))
+
.containsExactlyInAnyOrderElementsOf(getSnapshotExpected(tableId, schemaV1));
+
+ Thread.sleep(2000L);
+ String savepointPath = triggerSavepointWithRetry(firstJobClient,
savepointDirectory);
+ firstJobClient.cancel().get();
+ firstJobClient = null;
+
+ StreamExecutionEnvironment restoredEnv =
+ getStreamExecutionEnvironment(savepointPath, 1);
+ DataStreamSource<Event> restoredSource =
+ createSource(restoredEnv, configFactory,
SqlServerDataSourceFactory.IDENTIFIER);
+ restoredSource.addSink(new
EventQueueSink(afterSinkId)).name("Event queue sink");
+ restoredJobClient =
restoredEnv.executeAsync("SqlServerSchemaRestoreAfter");
+
+ Thread.sleep(5000L);
+
+ try (Connection connection = getJdbcConnection();
+ Statement statement = connection.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ statement.execute("ALTER TABLE dbo.customers ADD ext INT");
+ statement.execute(
+ "EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', "
+ + "@source_name = 'customers', @role_name =
NULL, "
+ + "@supports_net_changes = 0,
@capture_instance = 'dbo_customers_v2';");
+ statement.execute(
+ "INSERT INTO dbo.customers VALUES "
+ + "(10000, 'Alice', 'Beijing', '123567891234',
17);");
+ }
+
+ assertThat(drainSinkResults(afterSinkId, 3))
+ .containsExactly(
+ new AddColumnEvent(
+ tableId,
+ Collections.singletonList(
+ new
AddColumnEvent.ColumnWithPosition(
+ new PhysicalColumn(
+ "ext",
DataTypes.INT(), null),
+
AddColumnEvent.ColumnPosition.AFTER,
+ "phone_number"))),
+ new CreateTableEvent(tableId, schemaV2),
+ DataChangeEvent.insertEvent(
+ tableId,
+ generate(
+ schemaV2,
+ 10000,
+ "Alice",
+ "Beijing",
+ "123567891234",
+ 17)));
+
+ try (Connection connection = getJdbcConnection();
+ Statement statement = connection.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ statement.execute(
+ "EXEC sys.sp_cdc_disable_table @source_schema = 'dbo',
"
+ + "@source_name = 'customers', "
+ + "@capture_instance = 'dbo_customers';");
+ statement.execute("ALTER TABLE dbo.customers ALTER COLUMN ext
FLOAT");
+ statement.execute(
+ "EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', "
+ + "@source_name = 'customers', @role_name =
NULL, "
+ + "@supports_net_changes = 0,
@capture_instance = 'dbo_customers_v3';");
+ statement.execute(
+ "INSERT INTO dbo.customers VALUES "
+ + "(10001, 'Bob', 'Chongqing', '123567891234',
2.718281828);");
+ }
+
+ assertThat(drainSinkResults(afterSinkId, 2))
+ .containsExactly(
+ new AlterColumnTypeEvent(
+ tableId,
+ Collections.singletonMap("ext", (DataType)
DataTypes.DOUBLE()),
+ Collections.singletonMap("ext", (DataType)
DataTypes.INT()),
+ Collections.emptyMap()),
+ DataChangeEvent.insertEvent(
+ tableId,
+ generate(
+ schemaV3,
+ 10001,
+ "Bob",
+ "Chongqing",
+ "123567891234",
+ 2.718281828)));
+
+ try (Connection connection = getJdbcConnection();
+ Statement statement = connection.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ statement.execute(
+ "EXEC sys.sp_cdc_disable_table @source_schema = 'dbo',
"
+ + "@source_name = 'customers', "
+ + "@capture_instance = 'dbo_customers_v2';");
+ statement.execute("ALTER TABLE dbo.customers DROP COLUMN ext");
+ statement.execute(
+ "EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', "
+ + "@source_name = 'customers', @role_name =
NULL, "
+ + "@supports_net_changes = 0,
@capture_instance = 'dbo_customers_v4';");
+ statement.execute(
+ "INSERT INTO dbo.customers VALUES "
+ + "(10002, 'Cicada', 'Urumqi',
'123567891234');");
+ }
+
+ assertThat(drainSinkResults(afterSinkId, 2))
+ .containsExactly(
+ new DropColumnEvent(tableId,
Collections.singletonList("ext")),
+ DataChangeEvent.insertEvent(
+ tableId,
+ generate(schemaV4, 10002, "Cicada",
"Urumqi", "123567891234")));
+ } finally {
+ if (restoredJobClient != null) {
+ restoredJobClient.cancel().get();
+ }
+ if (firstJobClient != null) {
+ firstJobClient.cancel().get();
+ }
+ }
+ }
+
+ private DataStreamSource<Event> createSource(
+ StreamExecutionEnvironment env,
+ SqlServerSourceConfigFactory configFactory,
+ String sourceName) {
+ FlinkSourceProvider sourceProvider =
+ (FlinkSourceProvider)
+ new
SqlServerDataSource(configFactory).getEventSourceProvider();
+ return env.fromSource(
+ sourceProvider.getSource(),
+ WatermarkStrategy.noWatermarks(),
+ sourceName,
+ new EventTypeInfo());
+ }
+
+ private List<Event> drainSinkResults(String sinkId, int size)
+ throws InterruptedException, TimeoutException {
+ BlockingQueue<Event> queue =
Objects.requireNonNull(EVENTS_BY_SINK.get(sinkId));
+ List<Event> result = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ Event event = queue.poll(60L, TimeUnit.SECONDS);
+ if (event == null) {
+ throw new TimeoutException(
+ String.format(
+ "Timed out waiting for event %d/%d from sink
%s.",
+ i + 1, size, sinkId));
+ }
+ result.add(event);
+ }
+ return result;
+ }
+
+ private Schema schemaV1() {
+ return Schema.newBuilder()
+ .physicalColumn("id", DataTypes.INT().notNull())
+ .physicalColumn("name", DataTypes.VARCHAR(255).notNull(),
null, "flink")
+ .physicalColumn("address", DataTypes.VARCHAR(1024))
+ .physicalColumn("phone_number", DataTypes.VARCHAR(512))
+ .primaryKey(Collections.singletonList("id"))
+ .build();
+ }
+
+ private Schema schemaV2() {
+ return Schema.newBuilder()
+ .physicalColumn("id", DataTypes.INT().notNull())
+ .physicalColumn("name", DataTypes.VARCHAR(255).notNull(),
null, "flink")
+ .physicalColumn("address", DataTypes.VARCHAR(1024))
+ .physicalColumn("phone_number", DataTypes.VARCHAR(512))
+ .physicalColumn("ext", DataTypes.INT())
+ .primaryKey(Collections.singletonList("id"))
+ .build();
+ }
+
+ private Schema schemaV3() {
+ return Schema.newBuilder()
+ .physicalColumn("id", DataTypes.INT().notNull())
+ .physicalColumn("name", DataTypes.VARCHAR(255).notNull(),
null, "flink")
+ .physicalColumn("address", DataTypes.VARCHAR(1024))
+ .physicalColumn("phone_number", DataTypes.VARCHAR(512))
+ .physicalColumn("ext", DataTypes.DOUBLE())
+ .primaryKey(Collections.singletonList("id"))
+ .build();
+ }
+
+ private Schema schemaV4() {
+ return Schema.newBuilder()
+ .physicalColumn("id", DataTypes.INT().notNull())
+ .physicalColumn("name", DataTypes.VARCHAR(255).notNull(),
null, "flink")
+ .physicalColumn("address", DataTypes.VARCHAR(1024))
+ .physicalColumn("phone_number", DataTypes.VARCHAR(512))
+ .primaryKey(Collections.singletonList("id"))
+ .build();
+ }
+
+ private List<Event> getSnapshotExpected(TableId tableId, Schema schema) {
+ return Stream.of(
+ generate(schema, 101, "user_1", "Shanghai",
"123567891234"),
+ generate(schema, 102, "user_2", "Shanghai",
"123567891234"),
+ generate(schema, 103, "user_3", "Shanghai",
"123567891234"),
+ generate(schema, 109, "user_4", "Shanghai",
"123567891234"),
+ generate(schema, 110, "user_5", "Shanghai",
"123567891234"),
+ generate(schema, 111, "user_6", "Shanghai",
"123567891234"),
+ generate(schema, 118, "user_7", "Shanghai",
"123567891234"),
+ generate(schema, 121, "user_8", "Shanghai",
"123567891234"),
+ generate(schema, 123, "user_9", "Shanghai",
"123567891234"),
+ generate(schema, 1009, "user_10", "Shanghai",
"123567891234"),
+ generate(schema, 1010, "user_11", "Shanghai",
"123567891234"),
+ generate(schema, 1011, "user_12", "Shanghai",
"123567891234"),
+ generate(schema, 1012, "user_13", "Shanghai",
"123567891234"),
+ generate(schema, 1013, "user_14", "Shanghai",
"123567891234"),
+ generate(schema, 1014, "user_15", "Shanghai",
"123567891234"),
+ generate(schema, 1015, "user_16", "Shanghai",
"123567891234"),
+ generate(schema, 1016, "user_17", "Shanghai",
"123567891234"),
+ generate(schema, 1017, "user_18", "Shanghai",
"123567891234"),
+ generate(schema, 1018, "user_19", "Shanghai",
"123567891234"),
+ generate(schema, 1019, "user_20", "Shanghai",
"123567891234"),
+ generate(schema, 2000, "user_21", "Shanghai",
"123567891234"))
+ .map(record -> DataChangeEvent.insertEvent(tableId, record))
+ .collect(Collectors.toList());
+ }
+
+ private BinaryRecordData generate(Schema schema, Object... fields) {
+ BinaryRecordDataGenerator generator =
+ new
BinaryRecordDataGenerator(schema.getColumnDataTypes().toArray(new DataType[0]));
+ return generator.generate(
+ Stream.of(fields)
+ .map(
+ field ->
+ field instanceof String
+ ?
BinaryStringData.fromString((String) field)
+ : field)
+ .toArray());
+ }
+
+ private String triggerSavepointWithRetry(JobClient jobClient, String
savepointDirectory)
+ throws ExecutionException, InterruptedException {
+ int retryTimes = 0;
+ while (retryTimes < 600) {
+ try {
+ return jobClient
+ .triggerSavepoint(savepointDirectory,
SavepointFormatType.DEFAULT)
+ .get();
+ } catch (Exception e) {
+ if (ExceptionUtils.findThrowable(e, CheckpointException.class)
+ .filter(
+ exception ->
+ exception
+ .getMessage()
+ .contains("Checkpoint
triggering task"))
+ .isPresent()) {
+ Thread.sleep(100L);
+ retryTimes++;
+ } else {
+ throw e;
+ }
+ }
+ }
+ throw new AssertionError(
+ String.format(
+ "Failed to trigger savepoint in directory '%s' after
%d retries.",
+ savepointDirectory, retryTimes));
+ }
+
+ private StreamExecutionEnvironment getStreamExecutionEnvironment(
+ String savepointPath, int parallelism) {
+ Configuration configuration = new Configuration();
+ if (savepointPath != null) {
+ configuration.set(StateRecoveryOptions.SAVEPOINT_PATH,
savepointPath);
+ }
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(configuration);
+ env.setParallelism(parallelism);
+ env.enableCheckpointing(500L);
+ RestartStrategyUtils.configureFixedDelayRestartStrategy(env, 3, 1000L);
+ return env;
+ }
+
+ private static class EventQueueSink implements SinkFunction<Event> {
+
+ private final String sinkId;
+
+ private EventQueueSink(String sinkId) {
+ this.sinkId = sinkId;
+ }
+
+ @Override
+ public void invoke(Event value, Context context) {
+ Objects.requireNonNull(EVENTS_BY_SINK.get(sinkId)).add(value);
+ }
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/test/java/org/apache/flink/cdc/connectors/sqlserver/source/reader/SqlServerPipelineRecordEmitterTest.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/test/java/org/apache/flink/cdc/connectors/sqlserver/source/reader/SqlServerPipelineRecordEmitterTest.java
new file mode 100644
index 000000000..ee5c0417a
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-sqlserver/src/test/java/org/apache/flink/cdc/connectors/sqlserver/source/reader/SqlServerPipelineRecordEmitterTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.sqlserver.source.reader;
+
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.connectors.base.options.StartupOptions;
+import org.apache.flink.cdc.connectors.base.source.meta.split.SnapshotSplit;
+import
org.apache.flink.cdc.connectors.sqlserver.source.SqlServerEventDeserializer;
+import
org.apache.flink.cdc.connectors.sqlserver.source.config.SqlServerSourceConfig;
+import org.apache.flink.cdc.connectors.sqlserver.utils.SqlServerSchemaUtils;
+import org.apache.flink.cdc.debezium.table.DebeziumChangelogMode;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.RowType;
+
+import io.debezium.config.Configuration;
+import io.debezium.relational.Column;
+import io.debezium.relational.Table;
+import io.debezium.relational.TableEditor;
+import io.debezium.relational.TableId;
+import io.debezium.relational.history.TableChanges;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Properties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link SqlServerPipelineRecordEmitter}. */
+class SqlServerPipelineRecordEmitterTest {
+
+ private static final TableId DBZ_TABLE_ID = new TableId("db0", "dbo",
"users");
+ private static final org.apache.flink.cdc.common.event.TableId
CDC_TABLE_ID =
+ org.apache.flink.cdc.common.event.TableId.tableId("db0", "dbo",
"users");
+
+ @Test
+ void testApplySplitUsesRestoredSchemaOverExistingCache() {
+ SqlServerEventDeserializer deserializer =
+ new SqlServerEventDeserializer(DebeziumChangelogMode.ALL,
true);
+ deserializer
+ .getCreateTableEventCache()
+ .put(
+ DBZ_TABLE_ID,
+ new CreateTableEvent(
+ CDC_TABLE_ID,
+ schema(Collections.singletonList(col("id",
false, 1)))));
+
+ SqlServerPipelineRecordEmitter<Event> emitter =
+ new SqlServerPipelineRecordEmitter<>(
+ deserializer, null, createSourceConfig(), null, null);
+
+ emitter.applySplit(
+ new SnapshotSplit(
+ DBZ_TABLE_ID,
+ "db0.dbo.users:0",
+ RowType.of(new IntType()),
+ null,
+ null,
+ null,
+ Collections.singletonMap(
+ DBZ_TABLE_ID,
+ new TableChanges.TableChange(
+ TableChanges.TableChangeType.CREATE,
+ table(
+ Arrays.asList(
+ col("id", false, 1),
+ col("age", true,
2)))))));
+
+ assertThat(
+ deserializer
+ .getCreateTableEventCache()
+ .get(DBZ_TABLE_ID)
+ .getSchema()
+ .getColumnNames())
+ .containsExactly("id", "age");
+ }
+
+ private static SqlServerSourceConfig createSourceConfig() {
+ return new SqlServerSourceConfig(
+ StartupOptions.initial(),
+ Collections.singletonList("db0"),
+ Collections.singletonList("dbo.users"),
+ 8096,
+ 100,
+ 1000.0,
+ 0.05,
+ true,
+ false,
+ new Properties(),
+ Configuration.empty(),
+ "com.microsoft.sqlserver.jdbc.SQLServerDriver",
+ "localhost",
+ 1433,
+ "user",
+ "password",
+ 1024,
+ "UTC",
+ Duration.ofSeconds(30),
+ 3,
+ 1,
+ null,
+ false,
+ false,
+ false);
+ }
+
+ private static Schema schema(java.util.List<Column> columns) {
+ return SqlServerSchemaUtils.toSchema(table(columns));
+ }
+
+ private static Column col(String name, boolean optional, int position) {
+ return Column.editor()
+ .name(name)
+ .jdbcType(java.sql.Types.INTEGER)
+ .type("INT", "INT")
+ .position(position)
+ .optional(optional)
+ .create();
+ }
+
+ private static Table table(java.util.List<Column> columns) {
+ TableEditor editor = Table.editor().tableId(DBZ_TABLE_ID);
+ columns.forEach(editor::addColumn);
+ if (!columns.isEmpty()) {
+ editor.setPrimaryKeyNames("id");
+ }
+ return editor.create();
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/source/reader/IncrementalSourceReader.java
b/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/source/reader/IncrementalSourceReader.java
index 402d51074..15666dc76 100644
---
a/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/source/reader/IncrementalSourceReader.java
+++
b/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/source/reader/IncrementalSourceReader.java
@@ -142,6 +142,9 @@ public class IncrementalSourceReader<T, C extends
SourceConfig>
@Override
protected SourceSplitState initializedState(SourceSplitBase split) {
+ if (recordEmitter instanceof IncrementalSourceRecordEmitter) {
+ ((IncrementalSourceRecordEmitter<?>)
recordEmitter).applySplit(split);
+ }
if (split.isSnapshotSplit()) {
return new SnapshotSplitState(split.asSnapshotSplit());
} else {
diff --git
a/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/test/java/org/apache/flink/cdc/connectors/base/source/reader/IncrementalSourceReaderTest.java
b/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/test/java/org/apache/flink/cdc/connectors/base/source/reader/IncrementalSourceReaderTest.java
new file mode 100644
index 000000000..dc544e308
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/test/java/org/apache/flink/cdc/connectors/base/source/reader/IncrementalSourceReaderTest.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.base.source.reader;
+
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.cdc.connectors.base.config.SourceConfig;
+import org.apache.flink.cdc.connectors.base.source.meta.offset.Offset;
+import org.apache.flink.cdc.connectors.base.source.meta.offset.OffsetFactory;
+import org.apache.flink.cdc.connectors.base.source.meta.split.SnapshotSplit;
+import org.apache.flink.cdc.connectors.base.source.meta.split.SourceRecords;
+import org.apache.flink.cdc.connectors.base.source.meta.split.SourceSplitBase;
+import
org.apache.flink.cdc.connectors.base.source.meta.split.SourceSplitSerializer;
+import org.apache.flink.cdc.connectors.base.source.meta.split.SourceSplitState;
+import org.apache.flink.cdc.connectors.base.source.metrics.SourceReaderMetrics;
+import org.apache.flink.cdc.debezium.DebeziumDeserializationSchema;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
+import
org.apache.flink.connector.base.source.reader.synchronization.FutureCompletingBlockingQueue;
+import org.apache.flink.connector.testutils.source.reader.TestingReaderContext;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.util.Collector;
+
+import io.debezium.relational.TableId;
+import org.apache.kafka.connect.source.SourceRecord;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link IncrementalSourceReader}. */
+class IncrementalSourceReaderTest {
+
+ @Test
+ void testInitializedStateAppliesSplitToRecordEmitter() {
+ TestingReaderContext readerContext = new TestingReaderContext();
+ TrackingRecordEmitter recordEmitter =
+ new TrackingRecordEmitter(new
SourceReaderMetrics(readerContext.metricGroup()));
+ TestingIncrementalSourceReader reader =
+ new TestingIncrementalSourceReader(
+ recordEmitter, new
IncrementalSourceReaderContext(readerContext));
+
+ SnapshotSplit split =
+ new SnapshotSplit(
+ new TableId("catalog", "schema", "table"),
+ "split-0",
+ RowType.of(new IntType()),
+ null,
+ null,
+ null,
+ Collections.emptyMap());
+
+ reader.initialize(split);
+
+ assertThat(recordEmitter.appliedSplit).isSameAs(split);
+ }
+
+ private static class TestingIncrementalSourceReader
+ extends IncrementalSourceReader<Object, SourceConfig> {
+
+ private TestingIncrementalSourceReader(
+ TrackingRecordEmitter recordEmitter,
+ IncrementalSourceReaderContext incrementalSourceReaderContext)
{
+ super(
+ new
FutureCompletingBlockingQueue<RecordsWithSplitIds<SourceRecords>>(),
+ createSplitReaderSupplier(),
+ recordEmitter,
+ new Configuration(),
+ incrementalSourceReaderContext,
+ null,
+ new SourceSplitSerializer() {
+ @Override
+ public OffsetFactory getOffsetFactory() {
+ return TEST_OFFSET_FACTORY;
+ }
+ },
+ null);
+ }
+
+ private static Supplier<IncrementalSourceSplitReader<SourceConfig>>
+ createSplitReaderSupplier() {
+ return () -> null;
+ }
+
+ private SourceSplitState initialize(SourceSplitBase split) {
+ return initializedState(split);
+ }
+ }
+
+ private static class TrackingRecordEmitter extends
IncrementalSourceRecordEmitter<Object> {
+
+ private SourceSplitBase appliedSplit;
+
+ private TrackingRecordEmitter(SourceReaderMetrics sourceReaderMetrics)
{
+ super(
+ new NoOpDebeziumDeserializationSchema(),
+ sourceReaderMetrics,
+ false,
+ TEST_OFFSET_FACTORY);
+ }
+
+ @Override
+ public void applySplit(SourceSplitBase split) {
+ this.appliedSplit = split;
+ }
+ }
+
+ private static class NoOpDebeziumDeserializationSchema
+ implements DebeziumDeserializationSchema<Object> {
+
+ @Override
+ public void deserialize(SourceRecord record, Collector<Object> out) {
+ // no-op
+ }
+
+ @Override
+ public TypeInformation<Object> getProducedType() {
+ return TypeInformation.of(Object.class);
+ }
+ }
+
+ private static final OffsetFactory TEST_OFFSET_FACTORY =
+ new OffsetFactory() {
+ @Override
+ public Offset newOffset(Map<String, String> offset) {
+ return null;
+ }
+
+ @Override
+ public Offset newOffset(String filename, Long position) {
+ return null;
+ }
+
+ @Override
+ public Offset newOffset(Long position) {
+ return null;
+ }
+
+ @Override
+ public Offset createTimestampOffset(long timestampMillis) {
+ return null;
+ }
+
+ @Override
+ public Offset createInitialOffset() {
+ return null;
+ }
+
+ @Override
+ public Offset createNoStoppingOffset() {
+ return null;
+ }
+ };
+}