This is an automated email from the ASF dual-hosted git repository.
dybyte pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new a9ef95a276 [Fix] [Connector-V2] Fix Flink schema evolution hang caused
by XA transaction MDL deadlock with MySQL CDC (#10648)
a9ef95a276 is described below
commit a9ef95a276f049f5436fab0401c0ffe12c761bd9
Author: cloverdew <[email protected]>
AuthorDate: Sun May 24 01:52:59 2026 +0800
[Fix] [Connector-V2] Fix Flink schema evolution hang caused by XA
transaction MDL deadlock with MySQL CDC (#10648)
---
.../cdc/mysql/MysqlCDCWithFlinkSchemaChangeIT.java | 95 ++-
...ysql_with_flink_schema_change_exactly_once.conf | 55 ++
.../translation/flink/sink/FlinkSinkWriter.java | 1 -
.../flink/schema/BroadcastSchemaSinkOperator.java | 42 +-
.../translation/flink/schema/SchemaOperator.java | 934 +++++++--------------
.../schema/coordinator/LocalSchemaCoordinator.java | 63 +-
.../translation/flink/sink/FlinkSinkWriter.java | 1 -
.../flink/schema/SchemaOperatorTest.java | 400 +++++++++
8 files changed, 950 insertions(+), 641 deletions(-)
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/MysqlCDCWithFlinkSchemaChangeIT.java
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/MysqlCDCWithFlinkSchemaChangeIT.java
index 98bbd94063..37fea77fe2 100644
---
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/MysqlCDCWithFlinkSchemaChangeIT.java
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/MysqlCDCWithFlinkSchemaChangeIT.java
@@ -148,6 +148,97 @@ public class MysqlCDCWithFlinkSchemaChangeIT extends
TestSuiteBase implements Te
assertTableStructureAndData(MYSQL_DATABASE, SOURCE_TABLE, SINK_TABLE);
}
+ @Order(2)
+ @TestTemplate
+ public void testMysqlCdcWithSchemaEvolutionCaseExactlyOnce(TestContainer
container) {
+
+ shopDatabase.setTemplateName("shop").createAndInitialize();
+ CompletableFuture.runAsync(
+ () -> {
+ try {
+ container.executeJob(
+
"/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf");
+ } catch (Exception e) {
+ log.error("Commit task exception :" + e.getMessage());
+ throw new RuntimeException(e);
+ }
+ });
+
+ assertSchemaEvolution(MYSQL_DATABASE, SOURCE_TABLE, SINK_TABLE2);
+ }
+
+ private void assertSchemaEvolution(String database, String sourceTable,
String sinkTable) {
+ await().atMost(180000, TimeUnit.MILLISECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertIterableEquals(
+ query(String.format(QUERY, database,
sourceTable)),
+ query(String.format(QUERY, database,
sinkTable))));
+
+ // case1 add columns with cdc data at same time
+ shopDatabase.setTemplateName("add_columns").createAndInitialize();
+ await().atMost(180000, TimeUnit.MILLISECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertIterableEquals(
+ query(String.format(DESC, database,
sourceTable)),
+ query(String.format(DESC, database,
sinkTable))));
+ await().atMost(180000, TimeUnit.MILLISECONDS)
+ .untilAsserted(
+ () -> {
+ Assertions.assertIterableEquals(
+ query(
+ String.format(QUERY, database,
sourceTable)
+ + " where id >= 128"),
+ query(
+ String.format(QUERY, database,
sinkTable)
+ + " where id >= 128"));
+
+ Assertions.assertIterableEquals(
+ query(String.format(PROJECTION_QUERY,
database, sourceTable)),
+ query(String.format(PROJECTION_QUERY,
database, sinkTable)));
+
+ // The default value of add_column4 is
current_timestamp(),so the
+ // history data of sink table with this column may
be different from the
+ // source table because delay of apply schema
change.
+ String query =
+ String.format(
+ "SELECT t1.id AS table1_id,
t1.add_column4 AS table1_timestamp, "
+ + "t2.id AS table2_id,
t2.add_column4 AS table2_timestamp, "
+ +
"ABS(TIMESTAMPDIFF(SECOND, t1.add_column4, t2.add_column4)) AS time_diff "
+ + "FROM %s.%s t1 "
+ + "INNER JOIN %s.%s t2 ON
t1.id = t2.id",
+ database, sourceTable, database,
sinkTable);
+ try (Connection jdbcConnection =
getJdbcConnection();
+ Statement statement =
jdbcConnection.createStatement();
+ ResultSet resultSet =
statement.executeQuery(query); ) {
+ while (resultSet.next()) {
+ int timeDiff =
resultSet.getInt("time_diff");
+ Assertions.assertTrue(
+ timeDiff <= 60,
+ "Time difference exceeds 60
seconds: "
+ + timeDiff
+ + " seconds");
+ }
+ }
+ });
+
+ // case2 drop columns with cdc data at same time
+ assertCaseByDdlName("drop_columns", database, sourceTable, sinkTable);
+
+ // case3 change column name with cdc data at same time
+ assertCaseByDdlName("change_columns", database, sourceTable,
sinkTable);
+
+ // case4 modify column data type with cdc data at same time
+ assertCaseByDdlName("modify_columns", database, sourceTable,
sinkTable);
+ }
+
+ private void assertCaseByDdlName(
+ String drop_columns, String database, String sourceTable, String
sinkTable) {
+ shopDatabase.setTemplateName(drop_columns).createAndInitialize();
+ assertTableStructureAndData(database, sourceTable, sinkTable);
+ }
+
private void assertSchemaEvolutionForAddColumns(
String database, String sourceTable, String sinkTable) {
await().atMost(180000, TimeUnit.MILLISECONDS)
@@ -197,8 +288,8 @@ public class MysqlCDCWithFlinkSchemaChangeIT extends
TestSuiteBase implements Te
while (resultSet.next()) {
int timeDiff =
resultSet.getInt("time_diff");
Assertions.assertTrue(
- timeDiff <= 6,
- "Time difference exceeds 6
seconds: "
+ timeDiff <= 60,
+ "Time difference exceeds 60
seconds: "
+ timeDiff
+ " seconds");
}
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf
new file mode 100644
index 0000000000..09c88f3120
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf
@@ -0,0 +1,55 @@
+#
+# 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.
+#
+######
+###### This config file is a demonstration of streaming processing in
seatunnel config
+######
+
+env {
+ # You can set engine configuration here
+ parallelism = 1
+ job.mode = "STREAMING"
+ checkpoint.interval = 5000
+ read_limit.bytes_per_second=7000000
+ read_limit.rows_per_second=400
+}
+
+source {
+ MySQL-CDC {
+ server-id = 5652-5657
+ username = "st_user_source"
+ password = "mysqlpw"
+ table-names = ["shop.products"]
+ url = "jdbc:mysql://mysql_cdc_e2e:3306/shop"
+
+ schema-changes.enabled = true
+ }
+}
+
+sink {
+ jdbc {
+ url = "jdbc:mysql://mysql_cdc_e2e:3306/shop"
+ driver = "com.mysql.cj.jdbc.Driver"
+ user = "st_user_sink"
+ password = "mysqlpw"
+ generate_sink_sql = true
+ database = shop
+ table = mysql_cdc_e2e_sink_table_with_schema_change_exactly_once
+ primary_keys = ["id"]
+ is_exactly_once = true
+ xa_data_source_class_name = "com.mysql.cj.jdbc.MysqlXADataSource"
+ }
+}
diff --git
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-20/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-20/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
index 5241748f5e..e1e063bf19 100644
---
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-20/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
+++
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-20/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
@@ -130,7 +130,6 @@ public class FlinkSinkWriter<CommT, WriterStateT>
"FlinkSinkWriter applying SchemaChangeEvent for table: {}",
schemaChangeEvent.tableIdentifier());
- sinkWriter.prepareCommit();
if (!(sinkWriter instanceof SupportSchemaEvolutionSinkWriter)) {
log.warn(
"Sink writer {} does not support schema evolution,
ignoring SchemaChangeEvent for table: {}",
diff --git
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/BroadcastSchemaSinkOperator.java
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/BroadcastSchemaSinkOperator.java
index b4cc194211..7594737c96 100644
---
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/BroadcastSchemaSinkOperator.java
+++
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/BroadcastSchemaSinkOperator.java
@@ -160,33 +160,35 @@ public class BroadcastSchemaSinkOperator extends
AbstractStreamOperator<SeaTunne
}
int subtaskId = getRuntimeContext().getIndexOfThisSubtask();
log.info(
- "Subtask {} applying schema change immediately for table
{} (epoch {}, change: {}). This prevents deadlock by allowing checkpoint
barriers to propagate.",
+ "Subtask {} forwarding schema change to sink for table {}
(epoch {}, change: {}).",
subtaskId,
tableId,
epoch,
event.getClass().getSimpleName());
- try {
- emitApplySchemaEventToSink(event, epoch);
- lastProcessedEpoch.put(tableId, epoch);
+ // Forward to FlinkSinkWriter which performs the actual ALTER
TABLE and sends
+ // ACK to coordinator after completion. We do NOT send ACK here
because
+ // output.collect() may be asynchronous if operators are not
chained, and
+ // sending ACK before ALTER TABLE finishes would cause
SchemaOperator to
+ // release new-schema data before the sink table is actually
altered.
+ emitApplySchemaEventToSink(event, epoch);
+ lastProcessedEpoch.put(tableId, epoch);
- // send ACK to coordinator indicating this subtask has
processed the schema change
- coordinator.notifySchemaChangeApplied(tableId, epoch,
subtaskId, true);
-
- log.info(
- "Subtask {} processed schema change for table {}
(epoch {}) and sent ACK to coordinator.",
- subtaskId,
- tableId,
- epoch);
- } catch (Exception e) {
- coordinator.notifySchemaChangeApplied(tableId, epoch,
subtaskId, false);
- throw e;
- }
+ log.info(
+ "Subtask {} forwarded schema change for table {} (epoch
{}) to sink writer. "
+ + "ACK will be sent by FlinkSinkWriter after ALTER
TABLE completes.",
+ subtaskId,
+ tableId,
+ epoch);
} catch (SchemaValidationException | SchemaCoordinationException e) {
log.error("Schema broadcast or coordination error", e);
+ coordinator.notifySchemaChangeApplied(
+ tableId, epoch,
getRuntimeContext().getIndexOfThisSubtask(), false);
throw e;
} catch (Exception e) {
log.error("Schema change dispatch failed", e);
+ coordinator.notifySchemaChangeApplied(
+ tableId, epoch,
getRuntimeContext().getIndexOfThisSubtask(), false);
throw new SchemaEvolutionException(
SchemaEvolutionErrorCode.SCHEMA_EVENT_PROCESSING_FAILED,
e.getMessage(),
@@ -214,9 +216,11 @@ public class BroadcastSchemaSinkOperator extends
AbstractStreamOperator<SeaTunne
@Override
public void close() throws Exception {
+ int subtaskId = getRuntimeContext().getIndexOfThisSubtask();
+ if (coordinator != null) {
+ coordinator.unregisterSinkSubtask(subtaskId);
+ }
super.close();
- log.info(
- "BroadcastSchemaSinkOperator closed on subtask {}",
- getRuntimeContext().getIndexOfThisSubtask());
+ log.info("BroadcastSchemaSinkOperator closed on subtask {}",
subtaskId);
}
}
diff --git
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
index 3948e1c95a..0f993dcda0 100644
---
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
+++
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
@@ -25,6 +25,8 @@ import org.apache.seatunnel.api.table.catalog.TableIdentifier;
import org.apache.seatunnel.api.table.schema.SchemaChangeType;
import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
import org.apache.seatunnel.api.table.schema.event.TableEvent;
+import
org.apache.seatunnel.api.table.schema.exception.SchemaEvolutionErrorCode;
+import
org.apache.seatunnel.api.table.schema.exception.SchemaEvolutionException;
import
org.apache.seatunnel.api.table.schema.exception.SchemaValidationException;
import org.apache.seatunnel.api.table.type.SeaTunnelRow;
import
org.apache.seatunnel.translation.flink.schema.coordinator.LocalSchemaCoordinator;
@@ -42,34 +44,50 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import java.io.Serializable;
-import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.CompletableFuture;
+import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
-/** operators added to the source and transformer pipelines to handle schema
evolution */
+/**
+ * Operator placed after the source to handle schema evolution.
+ *
+ * <p>schema change events are NOT processed synchronously in {@link
#processElement}. Instead, they
+ * are buffered and deferred until an additional checkpoint cycle has
completed after the first
+ * checkpoint that observed the pending DDL. This wait ensures that when the
sink executes ALTER
+ * TABLE, all XA transactions from prior checkpoint cycles have been fully
committed by the {@code
+ * FlinkGlobalCommitter} (which runs asynchronously after {@code
notifyCheckpointComplete}), so
+ * their metadata locks are released and the ALTER TABLE can acquire an
exclusive MDL lock without
+ * deadlock.
+ *
+ * <p>Per checkpoint cycle, at most ONE schema change is applied. If multiple
DDLs arrive between
+ * two checkpoints, they are processed across successive checkpoint cycles.
+ */
@Slf4j
public class SchemaOperator extends AbstractStreamOperator<SeaTunnelRow>
implements OneInputStreamOperator<SeaTunnelRow, SeaTunnelRow> {
- private static final int MAX_BUFFERED_ROWS_PER_KEY = 100000;
+ private static final int MAX_BUFFERED_RECORDS = 100000;
+ private static final long SCHEMA_CHANGE_TIMEOUT_MS = 300_000L;
+ private static final int CHECKPOINT_WAIT_ROUNDS = 1;
+
private final Map<TableIdentifier, CatalogTable> localSchemaState;
private String jobId;
private final SupportSchemaEvolution source;
private final Config pluginConfig;
private volatile Long lastProcessedEventTime;
private transient LocalSchemaCoordinator coordinator;
- private transient Map<String, List<BufferedDataRow>> bufferedDataRows;
+ private transient Queue<BufferedRecord> pendingQueue;
private volatile boolean schemaChangePending = false;
- private volatile CompletableFuture<Boolean> pendingSchemaFuture = null;
- private volatile boolean stateDirty = false;
+ private long firstSeenCheckpointId = -1L;
private transient ListState<SchemaStateEntry> localSchemaStateStore;
private transient ListState<Long> lastProcessedEventTimeState;
private transient ListState<Boolean> schemaChangePendingState;
- private transient ListState<BufferedDataEntry> bufferedDataRowsState;
+ private transient ListState<BufferedRecordEntry> bufferedRecordsState;
+ private transient ListState<Long> firstSeenCheckpointIdState;
public SchemaOperator(String jobId, SupportSchemaEvolution source, Config
pluginConfig) {
this.jobId = jobId;
@@ -85,20 +103,16 @@ public class SchemaOperator extends
AbstractStreamOperator<SeaTunnelRow>
if (!flinkJobId.equals(this.jobId)) {
this.jobId = flinkJobId;
}
- this.bufferedDataRows = new ConcurrentHashMap<>();
- this.coordinator = LocalSchemaCoordinator.getInstance(this.jobId);
-
- // if schema change was pending and we have buffered data, handle
recovery scenario
- if (schemaChangePending && pendingSchemaFuture == null) {
- handleSchemaChangeRecovery();
+ if (this.pendingQueue == null) {
+ this.pendingQueue = new LinkedList<>();
}
+ this.coordinator = LocalSchemaCoordinator.getInstance(this.jobId);
log.info(
- "SchemaOperator opened for job: {}, recovered state -
lastProcessedEventTime: {}, schemaChangePending: {}, bufferedDataRows size: {}",
+ "SchemaOperator opened for job: {}, schemaChangePending: {},
pendingQueue size: {}",
this.jobId,
- this.lastProcessedEventTime,
this.schemaChangePending,
- bufferedDataRows.size());
+ this.pendingQueue.size());
}
@Override
@@ -110,686 +124,384 @@ public class SchemaOperator extends
AbstractStreamOperator<SeaTunnelRow>
return;
}
+ // detect schema change events
if ("__SCHEMA_CHANGE_EVENT__".equals(element.getTableId())
&& element.getOptions() != null) {
Object object = element.getOptions().get("schema_change_event");
if (object instanceof SchemaChangeEvent) {
- handleSchemaChangeEvent((SchemaChangeEvent) object);
+ handleSchemaChangeDetected((SchemaChangeEvent) object,
streamRecord.getTimestamp());
return;
}
}
+ // while a schema change is pending, buffer ALL subsequent records
if (schemaChangePending) {
- String tableId = element.getTableId();
- if (tableId != null && lastProcessedEventTime != null) {
- String key = createKey(tableId, lastProcessedEventTime);
- bufferedDataRows(key, element, streamRecord.getTimestamp());
- return;
- }
+ enqueueDataRecord(element, streamRecord.getTimestamp());
+ return;
}
output.collect(streamRecord);
}
- private boolean isSchemaEvolutionEnabled(Config pluginConfig) {
- if (pluginConfig.hasPath("schema-changes.enabled")) {
- return pluginConfig.getBoolean("schema-changes.enabled");
- }
-
- return false;
- }
-
- private String createKey(String tableId, Long eventTime) {
- return tableId + "#" + eventTime;
- }
-
- private void bufferedDataRows(String key, SeaTunnelRow element, long
timestamp) {
- try {
- BufferedDataRow bufferedRow = new BufferedDataRow(element,
timestamp);
-
- synchronized (this) {
- List<BufferedDataRow> bufferedList =
- bufferedDataRows.computeIfAbsent(key, k -> new
ArrayList<>());
-
- if (bufferedList.size() >= MAX_BUFFERED_ROWS_PER_KEY) {
- log.warn(
- "Buffer for key {} exceeded max size {}, dropping
oldest row",
- key,
- MAX_BUFFERED_ROWS_PER_KEY);
- bufferedList.remove(0);
- }
-
- bufferedList.add(bufferedRow);
- stateDirty = true;
-
- log.debug(
- "buffered data row for key: {}, total buffered: {}",
- key,
- bufferedList.size());
- }
- } catch (Exception e) {
- log.error("Failed to buffer data for key: {}, dropping this data
row", key, e);
- }
- }
-
- private void handleSchemaChangeEvent(SchemaChangeEvent schemaChangeEvent) {
+ private void handleSchemaChangeDetected(SchemaChangeEvent event, long
timestamp) {
List<SchemaChangeType> supportedTypes = source.supports();
if (supportedTypes == null || supportedTypes.isEmpty()) {
- log.info(
- "Source: {} does not support any schema change types,
skipping schema change event",
- source);
+ log.info("Source does not support any schema change types,
skipping");
return;
}
-
- if (!isSchemaChangeSupported(schemaChangeEvent, supportedTypes)) {
- log.warn(
- "Schema change type {} not supported by source {},
skipping",
- schemaChangeEvent.getEventType(),
- source);
+ if (!isSchemaChangeSupported(event, supportedTypes)) {
+ log.warn("Schema change type {} not supported, skipping",
event.getEventType());
return;
}
- processSchemaChangeEvent(schemaChangeEvent);
- }
-
- private boolean isSchemaChangeSupported(
- SchemaChangeEvent event, List<SchemaChangeType> supportedTypes) {
- switch (event.getEventType()) {
- case SCHEMA_CHANGE_ADD_COLUMN:
- return supportedTypes.contains(SchemaChangeType.ADD_COLUMN);
- case SCHEMA_CHANGE_DROP_COLUMN:
- return supportedTypes.contains(SchemaChangeType.DROP_COLUMN);
- case SCHEMA_CHANGE_MODIFY_COLUMN:
- return supportedTypes.contains(SchemaChangeType.UPDATE_COLUMN);
- case SCHEMA_CHANGE_CHANGE_COLUMN:
- return supportedTypes.contains(SchemaChangeType.RENAME_COLUMN);
- case SCHEMA_CHANGE_UPDATE_COLUMNS:
- return supportedTypes.contains(SchemaChangeType.ADD_COLUMN)
- ||
supportedTypes.contains(SchemaChangeType.DROP_COLUMN)
- ||
supportedTypes.contains(SchemaChangeType.UPDATE_COLUMN)
- ||
supportedTypes.contains(SchemaChangeType.RENAME_COLUMN);
- default:
- log.error("Unknown schema change event type: {}",
event.getEventType());
- throw SchemaValidationException.unsupportedChangeType(
- event.tableIdentifier(), jobId);
+ if (event instanceof TableEvent) {
+ event.setJobId(jobId);
}
- }
-
- private void processSchemaChangeEvent(SchemaChangeEvent schemaChangeEvent)
{
- TableIdentifier tableId = schemaChangeEvent.tableIdentifier();
- long eventTime = schemaChangeEvent.getCreatedTime();
-
- try {
- if (lastProcessedEventTime != null && eventTime <=
lastProcessedEventTime) {
- throw SchemaValidationException.outdatedEvent(
- tableId, jobId, eventTime, lastProcessedEventTime);
- }
-
- if (schemaChangeEvent instanceof TableEvent) {
- schemaChangeEvent.setJobId(jobId);
- }
-
- log.info(
- "Starting schema change processing for table: {}, job: {},
event time: {}",
- tableId,
- jobId,
- eventTime);
-
- String key = createKey(tableId.toString(), eventTime);
-
- // initialize buffer for this schema change
- synchronized (this) {
- List<BufferedDataRow> newBufferList = new ArrayList<>();
- bufferedDataRows.put(key, newBufferList);
- stateDirty = true;
- }
-
- schemaChangePending = true;
-
- sendSchemaChangeEventToDownstream(schemaChangeEvent);
- CatalogTable newSchema = schemaChangeEvent.getChangeAfter();
- if (newSchema != null) {
- localSchemaState.put(tableId, newSchema);
- log.debug("Updated local schema state for table: {}", tableId);
- }
- lastProcessedEventTime = eventTime;
-
- try {
- log.info(
- "Synchronously processing schema change for table {}
(epoch {}). Business data buffered.",
- tableId,
- eventTime);
- long timeoutMs = 300_000L;
- boolean success = coordinator.requestSchemaChange(tableId,
eventTime, timeoutMs);
-
- if (success) {
- if (schemaChangeEvent.getChangeAfter() != null) {
- localSchemaState.put(tableId,
schemaChangeEvent.getChangeAfter());
- }
- lastProcessedEventTime = eventTime;
- log.info(
- "Schema change for table {} (epoch {}) confirmed
successfully by all sink subtasks.",
- tableId,
- eventTime);
- } else {
- log.error(
- "Schema change for table {} (epoch {}) failed or
timed out.",
- tableId,
- eventTime);
- }
- } catch (Exception e) {
- log.error(
- "Error during synchronous schema change processing for
table {} (epoch {})",
- tableId,
- eventTime,
- e);
- } finally {
- schemaChangePending = false;
- pendingSchemaFuture = null;
- releaseBufferedData(key, tableId);
+ log.info(
+ "Schema change detected for table {} (epoch {}). "
+ + "Deferring until next checkpoint completes to avoid
XA/MDL deadlock.",
+ event.tableIdentifier(),
+ event.getCreatedTime());
- log.info(
- "Synchronous schema change processing completed for
table {}, data flow resumed",
- tableId);
- }
+ pendingQueue.add(BufferedRecord.schemaChange(event));
+ schemaChangePending = true;
+ }
- log.info(
- "Synchronous schema change processing completed for table
{}. Checkpoint barriers can propagate normally.",
- tableId);
- } catch (Exception e) {
- log.error("Error starting schema change processing", e);
- schemaChangePending = false;
- try {
- schemaChangePendingState.clear();
- schemaChangePendingState.add(false);
- } catch (Exception stateException) {
- log.error(
- "Error updating schemaChangePending state during error
handling",
- stateException);
- }
- pendingSchemaFuture = null;
+ private void enqueueDataRecord(SeaTunnelRow row, long timestamp) {
+ if (pendingQueue.size() >= MAX_BUFFERED_RECORDS) {
+ TableIdentifier tableIdentifier =
getPendingSchemaTableIdentifier();
+ throw new SchemaEvolutionException(
+ SchemaEvolutionErrorCode.SCHEMA_EVENT_PROCESSING_FAILED,
+ String.format(
+ "Pending schema buffer overflow (max=%d). "
+ + "Failing fast to avoid dropping schema
change control events.",
+ MAX_BUFFERED_RECORDS),
+ tableIdentifier,
+ jobId);
}
+ pendingQueue.add(BufferedRecord.data(row, timestamp));
}
- private void releaseBufferedData(String key, TableIdentifier tableId) {
- try {
- List<BufferedDataRow> bufferedRows;
- synchronized (this) {
- bufferedRows = bufferedDataRows.remove(key);
- stateDirty = true;
- }
-
- if (bufferedRows != null && !bufferedRows.isEmpty()) {
- log.info(
- "Releasing {} buffered data rows after schema change
processing for table {}",
- bufferedRows.size(),
- tableId);
-
- for (BufferedDataRow buffered : bufferedRows) {
- output.collect(new StreamRecord<>(buffered.row,
buffered.timestamp));
- }
-
- log.info(
- "Successfully released {} buffered rows for table {}",
- bufferedRows.size(),
- tableId);
+ private TableIdentifier getPendingSchemaTableIdentifier() {
+ for (BufferedRecord record : pendingQueue) {
+ if (record.isSchemaChange && record.schemaEvent != null) {
+ return record.schemaEvent.tableIdentifier();
}
-
- } catch (Exception e) {
- log.error(
- "CRITICAL: Failed to release buffered data for key: {}. "
- + "Data may be lost if this continues to fail!",
- key,
- e);
-
- try {
- Iterable<BufferedDataEntry> stateEntries =
bufferedDataRowsState.get();
- for (BufferedDataEntry entry : stateEntries) {
- if (entry.key.equals(key)) {
- List<BufferedDataRow> stateData = entry.bufferedRows;
- if (stateData != null && !stateData.isEmpty()) {
- synchronized (this) {
- bufferedDataRows.put(key, new
ArrayList<>(stateData));
- stateDirty = true;
- }
- log.info(
- "Restored {} rows to memory buffer for
retry",
- stateData.size());
- }
- break;
- }
- }
- } catch (Exception restoreException) {
- log.error("Failed to restore buffered data to memory",
restoreException);
- }
-
- throw e;
}
+ return null;
}
- private void handleSchemaChangeRecovery() {
- log.info(
- "Detected schema change pending after recovery with {}
buffered entries. "
- + "Querying sink state to determine correct recovery
action.",
- bufferedDataRows.size());
-
- try {
- // wait for sink operators to register their state providers with
retry mechanism
- waitForSinkStateProviders(10, 500);
-
- boolean allDataReleased = true;
- int totalReleased = 0;
-
- for (Map.Entry<String, List<BufferedDataRow>> entry :
bufferedDataRows.entrySet()) {
- String key = entry.getKey();
- List<BufferedDataRow> bufferedRows = entry.getValue();
-
- if (bufferedRows == null || bufferedRows.isEmpty()) {
- continue;
- }
-
- String[] keyParts = key.split("#");
- if (keyParts.length != 2) {
- log.warn("Invalid buffer key format: {}, releasing data",
key);
- releaseBufferedDataForKey(key, bufferedRows);
- totalReleased += bufferedRows.size();
- continue;
- }
+ /**
+ * Called by Flink after a checkpoint succeeds. Uses an extra completed
checkpoint round to
+ * ensure safety:
+ *
+ * <ul>
+ * <li><b>first time seeing the DDL: record {@link
#firstSeenCheckpointId} but do NOT
+ * broadcast the DDL yet. At this point the {@code
FlinkGlobalCommitter} may still be
+ * running {@code XA COMMIT} for this checkpoint's prepared
transactions, holding MDL
+ * locks on the sink table.
+ * <li><b>{@code checkpointId >= firstSeenCheckpointId +
CHECKPOINT_WAIT_ROUNDS} : the XA
+ * COMMIT from the earlier checkpoint cycle is guaranteed to have
finished (at least one
+ * additional checkpoint cycle has completed, which implies the
committer ran). The sink's
+ * ALTER TABLE will not encounter MDL lock, it is now safe to
broadcast the DDL.
+ * </ul>
+ */
+ @Override
+ public void notifyCheckpointComplete(long checkpointId) throws Exception {
+ super.notifyCheckpointComplete(checkpointId);
- String tableIdStr = keyParts[0];
- long epoch;
- try {
- epoch = Long.parseLong(keyParts[1]);
- } catch (NumberFormatException e) {
- log.warn("Invalid epoch in buffer key: {}, releasing
data", key);
- releaseBufferedDataForKey(key, bufferedRows);
- totalReleased += bufferedRows.size();
- continue;
- }
- TableIdentifier tableId;
- String[] parts = tableIdStr.split("\\.");
- if (parts.length < 3) {
- throw new IllegalArgumentException("Invalid table id
format: " + tableIdStr);
- }
- tableId = TableIdentifier.of(parts[0], parts[1], parts[2]);
-
- // query sink processing status using string representation
directly
- LocalSchemaCoordinator.SchemaProcessingStatus status =
- coordinator.querySchemaProcessingStatus(tableId,
epoch);
-
- switch (status) {
- case FULLY_PROCESSED:
- log.info(
- "Schema change for table {} epoch {} fully
processed, releasing {} buffered rows",
- tableIdStr,
- epoch,
- bufferedRows.size());
- releaseBufferedDataForKey(key, bufferedRows);
- totalReleased += bufferedRows.size();
- break;
-
- case NOT_PROCESSED:
- log.info(
- "Schema change for table {} epoch {} not
processed, need to restart coordination for {} buffered rows",
- tableIdStr,
- epoch,
- bufferedRows.size());
- restartSchemaChangeCoordination(tableId, epoch, key);
- allDataReleased = false;
- break;
-
- case PARTIALLY_PROCESSED:
- log.warn(
- "Schema change for table {} epoch {} partially
processed, need to restart coordination for {} buffered rows",
- tableIdStr,
- epoch,
- bufferedRows.size());
- restartSchemaChangeCoordination(tableId, epoch, key);
- allDataReleased = false;
- break;
-
- default:
- log.error(
- "Unknown schema processing status: {},
releasing data to avoid deadlock",
- status);
- releaseBufferedDataForKey(key, bufferedRows);
- totalReleased += bufferedRows.size();
- }
- }
+ if (!schemaChangePending || pendingQueue.isEmpty()) {
+ return;
+ }
- // only reset schemaChangePending if all data was released
- if (allDataReleased) {
- schemaChangePending = false;
- schemaChangePendingState.clear();
- schemaChangePendingState.add(false);
- log.info(
- "Recovery completed: Released {} buffered data rows
and resumed normal data flow.",
- totalReleased);
- } else {
- log.info(
- "Recovery in progress: Released {} buffered data rows,
{} entries still need coordination.",
- totalReleased,
- bufferedDataRows.size());
- }
+ BufferedRecord head = pendingQueue.peek();
+ while (head != null && !head.isSchemaChange) {
+ output.collect(new StreamRecord<>(head.row, head.timestamp));
+ pendingQueue.poll();
+ head = pendingQueue.peek();
+ }
+ if (head == null) {
+ schemaChangePending = false;
+ firstSeenCheckpointId = -1L;
+ return;
+ }
- } catch (Exception e) {
- log.error(
- "Error during schema change recovery, releasing all
buffered data to avoid deadlock",
- e);
- releaseAllBufferedData();
+ // first time seeing this DDL at head of queue — just record the
checkpoint id
+ if (firstSeenCheckpointId < 0) {
+ firstSeenCheckpointId = checkpointId;
+ log.info(
+ "Checkpoint {} completed. DDL for table {} (epoch {})
first seen. "
+ + "Waiting {} more checkpoint round(s) for XA
COMMIT to finish.",
+ checkpointId,
+ head.schemaEvent.tableIdentifier(),
+ head.schemaEvent.getCreatedTime(),
+ CHECKPOINT_WAIT_ROUNDS);
+ return;
}
- }
- private void waitForSinkStateProviders(int maxRetries, long
retryIntervalMs)
- throws InterruptedException {
- for (int i = 0; i < maxRetries; i++) {
- if (coordinator.querySchemaProcessingStatus(
- TableIdentifier.of("test", "test", "test"), 0L)
- != null) {
- log.info("Sink state providers registered after {} retries",
i);
- return;
- }
- Thread.sleep(retryIntervalMs);
+ if (checkpointId < firstSeenCheckpointId + CHECKPOINT_WAIT_ROUNDS) {
+ log.info(
+ "Checkpoint {} completed. Still waiting for DDL on table
{} (epoch {}). "
+ + "Need checkpoint >= {} (first seen at {}, wait
rounds = {}).",
+ checkpointId,
+ head.schemaEvent.tableIdentifier(),
+ head.schemaEvent.getCreatedTime(),
+ firstSeenCheckpointId + CHECKPOINT_WAIT_ROUNDS,
+ firstSeenCheckpointId,
+ CHECKPOINT_WAIT_ROUNDS);
+ return;
}
- log.warn(
- "Sink state providers not fully registered after {} retries,
proceeding anyway",
- maxRetries);
- }
- private void releaseBufferedDataForKey(String key, List<BufferedDataRow>
bufferedRows) {
- try {
- for (BufferedDataRow buffered : bufferedRows) {
- output.collect(new StreamRecord<>(buffered.row,
buffered.timestamp));
- }
+ long waitedSince = firstSeenCheckpointId;
+ SchemaChangeEvent event = head.schemaEvent;
+ TableIdentifier tableId = event.tableIdentifier();
+ long eventTime = event.getCreatedTime();
- synchronized (this) {
- bufferedDataRows.remove(key);
- stateDirty = true;
- }
- } catch (Exception e) {
- log.error("Failed to release buffered data for key: {}", key, e);
+ log.info(
+ "Checkpoint {} completed (waited since checkpoint {}). "
+ + "Applying deferred schema change for table {} (epoch
{}).",
+ checkpointId,
+ waitedSince,
+ tableId,
+ eventTime);
+
+ if (lastProcessedEventTime != null && eventTime <=
lastProcessedEventTime) {
+ log.warn(
+ "Skipping outdated schema change event (epoch {} <= last
processed {})",
+ eventTime,
+ lastProcessedEventTime);
+ pendingQueue.poll();
+ firstSeenCheckpointId = -1L;
+ drainDataUntilNextSchemaChange();
+ return;
}
- }
- private void restartSchemaChangeCoordination(TableIdentifier tableId, long
epoch, String key) {
- try {
- log.info("Restarting schema change coordination for table {} epoch
{}", tableId, epoch);
-
- // create a new future for this coordination
- CompletableFuture<Boolean> newFuture =
- CompletableFuture.supplyAsync(
- () -> {
- try {
- long timeoutMs = 300_000L;
- boolean success =
- coordinator.requestSchemaChange(
- tableId, epoch, timeoutMs);
-
- if (success) {
- log.info(
- "Restarted schema change
coordination successful for table {} epoch {}",
- tableId,
- epoch);
- } else {
- log.error(
- "Restarted schema change
coordination failed for table {} epoch {}",
- tableId,
- epoch);
- }
-
- return success;
- } catch (Exception e) {
- log.error(
- "Error in restarted schema change
coordination for table {} epoch {}",
- tableId,
- epoch,
- e);
- return false;
- }
- });
-
- newFuture.whenComplete(
- (success, throwable) -> {
- try {
- if (throwable != null) {
- log.error(
- "Restarted schema change future
completed with exception",
- throwable);
- }
-
- // release the buffered data
- List<BufferedDataRow> bufferedRows =
bufferedDataRows.get(key);
- if (bufferedRows != null) {
- releaseBufferedDataForKey(key, bufferedRows);
- log.info(
- "Released {} buffered rows after
restarted coordination for key {}",
- bufferedRows.size(),
- key);
- }
-
- // check if this was the last pending coordination
- if (bufferedDataRows.isEmpty()) {
- schemaChangePending = false;
- schemaChangePendingState.clear();
- schemaChangePendingState.add(false);
- log.info(
- "All schema change coordination
completed, resumed normal data flow");
- }
-
- } catch (Exception e) {
- log.error("Error in restarted coordination
completion handling", e);
- }
- });
-
- if (pendingSchemaFuture == null) {
- pendingSchemaFuture = newFuture;
- }
+ sendSchemaChangeEventToDownstream(event);
- } catch (Exception e) {
- log.error(
- "Failed to restart schema change coordination for table {}
epoch {}, releasing data",
+ boolean success =
+ coordinator.requestSchemaChange(tableId, eventTime,
SCHEMA_CHANGE_TIMEOUT_MS);
+ if (!success) {
+ throw new SchemaEvolutionException(
+ SchemaEvolutionErrorCode.SCHEMA_EVENT_PROCESSING_FAILED,
+ String.format(
+ "Schema change for table %s (epoch %d) failed
during sink coordination.",
+ tableId, eventTime),
tableId,
- epoch,
- e);
- List<BufferedDataRow> bufferedRows = bufferedDataRows.get(key);
- if (bufferedRows != null) {
- releaseBufferedDataForKey(key, bufferedRows);
- }
+ jobId);
}
- }
-
- private void releaseAllBufferedData() {
- try {
- int totalReleased = 0;
- synchronized (this) {
- for (Map.Entry<String, List<BufferedDataRow>> entry :
bufferedDataRows.entrySet()) {
- List<BufferedDataRow> bufferedRows = entry.getValue();
- if (bufferedRows != null && !bufferedRows.isEmpty()) {
- for (BufferedDataRow buffered : bufferedRows) {
- output.collect(new StreamRecord<>(buffered.row,
buffered.timestamp));
- }
- totalReleased += bufferedRows.size();
- }
- }
-
- bufferedDataRows.clear();
- stateDirty = true;
- }
+ log.info(
+ "Schema change for table {} (epoch {}) confirmed by all sink
subtasks.",
+ tableId,
+ eventTime);
- schemaChangePending = false;
- schemaChangePendingState.clear();
- schemaChangePendingState.add(false);
+ pendingQueue.poll();
+ firstSeenCheckpointId = -1L;
- log.info("Emergency recovery: Released {} buffered data rows",
totalReleased);
- } catch (Exception e) {
- log.error("Failed to release all buffered data during emergency
recovery", e);
+ CatalogTable newSchema = event.getChangeAfter();
+ if (newSchema != null) {
+ localSchemaState.put(tableId, newSchema);
}
- }
+ lastProcessedEventTime = eventTime;
- private void sendSchemaChangeEventToDownstream(SchemaChangeEvent
schemaChangeEvent) {
- log.info(
- "Broadcasting SchemaChangeEvent to all downstream sink
subtasks for table: {}",
- schemaChangeEvent.tableIdentifier());
- SeaTunnelRow broadcastRow = new SeaTunnelRow(0);
- Map<String, Object> options = new HashMap<>();
- options.put("schema_change_broadcast", schemaChangeEvent);
- broadcastRow.setOptions(options);
+ drainDataUntilNextSchemaChange();
- output.collect(new StreamRecord<>(broadcastRow));
log.info(
- "SchemaChangeEvent broadcast sent for table: {}",
- schemaChangeEvent.tableIdentifier());
+ "Schema change for table {} (epoch {}) processing complete.
pendingQueue remaining: {}",
+ tableId,
+ eventTime,
+ pendingQueue.size());
}
- @Override
- public void close() throws Exception {
- try {
- if (pendingSchemaFuture != null && !pendingSchemaFuture.isDone()) {
- log.info("Cancelling ongoing schema change request during
close");
- pendingSchemaFuture.cancel(true);
+ private void drainDataUntilNextSchemaChange() {
+ int released = 0;
+ while (!pendingQueue.isEmpty()) {
+ BufferedRecord record = pendingQueue.peek();
+ if (record.isSchemaChange) {
+ // another DDL will stop here, wait for next checkpoint cycle
+ log.info(
+ "Released {} buffered data records. Another schema
change pending, "
+ + "waiting for next checkpoint.",
+ released);
+ return;
}
- } catch (Exception e) {
- log.warn("Error during SchemaOperator cleanup", e);
- } finally {
- super.close();
+ pendingQueue.poll();
+ output.collect(new StreamRecord<>(record.row, record.timestamp));
+ released++;
}
+
+ // queue is empty
+ schemaChangePending = false;
+ log.info("Released {} buffered data records. Normal data flow
resumed.", released);
}
@Override
public void snapshotState(StateSnapshotContext context) throws Exception {
super.snapshotState(context);
- try {
- // clear and update lastProcessedEventTime
- lastProcessedEventTimeState.clear();
- if (lastProcessedEventTime != null) {
- lastProcessedEventTimeState.add(lastProcessedEventTime);
- }
+ lastProcessedEventTimeState.clear();
+ if (lastProcessedEventTime != null) {
+ lastProcessedEventTimeState.add(lastProcessedEventTime);
+ }
- // clear and update schemaChangePending
- schemaChangePendingState.clear();
- schemaChangePendingState.add(schemaChangePending);
+ schemaChangePendingState.clear();
+ schemaChangePendingState.add(schemaChangePending);
- // clear and update local schema state
- localSchemaStateStore.clear();
- for (Map.Entry<TableIdentifier, CatalogTable> entry :
localSchemaState.entrySet()) {
- localSchemaStateStore.add(new SchemaStateEntry(entry.getKey(),
entry.getValue()));
- }
+ firstSeenCheckpointIdState.clear();
+ firstSeenCheckpointIdState.add(firstSeenCheckpointId);
- // batch sync buffered data to state only when dirty
- if (stateDirty) {
- bufferedDataRowsState.clear();
- synchronized (this) {
- for (Map.Entry<String, List<BufferedDataRow>> entry :
- bufferedDataRows.entrySet()) {
- bufferedDataRowsState.add(
- new BufferedDataEntry(entry.getKey(),
entry.getValue()));
- }
- stateDirty = false;
- }
- }
+ localSchemaStateStore.clear();
+ for (Map.Entry<TableIdentifier, CatalogTable> entry :
localSchemaState.entrySet()) {
+ localSchemaStateStore.add(new SchemaStateEntry(entry.getKey(),
entry.getValue()));
+ }
- log.debug(
- "SchemaOperator state snapshot completed using operator
state for checkpoint: {}, lastProcessedEventTime: {}, schemaChangePending: {},
localSchemaState size: {}, bufferedDataRows size: {}",
- context.getCheckpointId(),
- lastProcessedEventTime,
- schemaChangePending,
- localSchemaState.size(),
- bufferedDataRows.size());
- } catch (Exception e) {
- log.error("Error during state snapshot", e);
- throw e;
+ bufferedRecordsState.clear();
+ for (BufferedRecord record : pendingQueue) {
+ bufferedRecordsState.add(
+ new BufferedRecordEntry(
+ record.isSchemaChange,
+ record.row,
+ record.timestamp,
+ record.schemaEvent));
}
+
+ log.debug(
+ "State snapshot for checkpoint {}: lastEventTime={},
pending={}, "
+ + "firstSeenCkpt={}, queueSize={}",
+ context.getCheckpointId(),
+ lastProcessedEventTime,
+ schemaChangePending,
+ firstSeenCheckpointId,
+ pendingQueue.size());
}
@Override
public void initializeState(StateInitializationContext context) throws
Exception {
super.initializeState(context);
- if (this.bufferedDataRows == null) {
- this.bufferedDataRows = new ConcurrentHashMap<>();
+ if (this.pendingQueue == null) {
+ this.pendingQueue = new LinkedList<>();
}
- ListStateDescriptor<SchemaStateEntry> localSchemaStateDescriptor =
+ ListStateDescriptor<SchemaStateEntry> schemaDescriptor =
new ListStateDescriptor<>("localSchemaState",
SchemaStateEntry.class);
-
- ListStateDescriptor<Long> lastProcessedEventTimeDescriptor =
+ ListStateDescriptor<Long> eventTimeDescriptor =
new ListStateDescriptor<>("lastProcessedEventTime",
Long.class);
-
- ListStateDescriptor<Boolean> schemaChangePendingDescriptor =
+ ListStateDescriptor<Boolean> pendingDescriptor =
new ListStateDescriptor<>("schemaChangePending",
Boolean.class);
+ ListStateDescriptor<BufferedRecordEntry> bufferedDescriptor =
+ new ListStateDescriptor<>("bufferedRecords",
BufferedRecordEntry.class);
+ ListStateDescriptor<Long> firstSeenCkptDescriptor =
+ new ListStateDescriptor<>("firstSeenCheckpointId", Long.class);
- ListStateDescriptor<BufferedDataEntry> bufferedDataRowsDescriptor =
- new ListStateDescriptor<>("bufferedDataRows",
BufferedDataEntry.class);
-
- this.localSchemaStateStore =
-
context.getOperatorStateStore().getListState(localSchemaStateDescriptor);
+ this.localSchemaStateStore =
context.getOperatorStateStore().getListState(schemaDescriptor);
this.lastProcessedEventTimeState =
-
context.getOperatorStateStore().getListState(lastProcessedEventTimeDescriptor);
+
context.getOperatorStateStore().getListState(eventTimeDescriptor);
this.schemaChangePendingState =
-
context.getOperatorStateStore().getListState(schemaChangePendingDescriptor);
- this.bufferedDataRowsState =
-
context.getOperatorStateStore().getListState(bufferedDataRowsDescriptor);
+
context.getOperatorStateStore().getListState(pendingDescriptor);
+ this.bufferedRecordsState =
+
context.getOperatorStateStore().getListState(bufferedDescriptor);
+ this.firstSeenCheckpointIdState =
+
context.getOperatorStateStore().getListState(firstSeenCkptDescriptor);
if (context.isRestored()) {
- // restore from operator state
- Iterable<Long> eventTimes = lastProcessedEventTimeState.get();
- for (Long eventTime : eventTimes) {
- this.lastProcessedEventTime = eventTime;
+ for (Long t : lastProcessedEventTimeState.get()) {
+ this.lastProcessedEventTime = t;
break;
}
-
- Iterable<Boolean> pendingFlags = schemaChangePendingState.get();
- for (Boolean pending : pendingFlags) {
- this.schemaChangePending = pending;
+ for (Boolean p : schemaChangePendingState.get()) {
+ this.schemaChangePending = p;
break;
}
-
- // restore schema state
- Iterable<SchemaStateEntry> schemaEntries =
localSchemaStateStore.get();
- for (SchemaStateEntry entry : schemaEntries) {
+ for (Long ckpt : firstSeenCheckpointIdState.get()) {
+ this.firstSeenCheckpointId = ckpt;
+ break;
+ }
+ for (SchemaStateEntry entry : localSchemaStateStore.get()) {
localSchemaState.put(entry.tableId, entry.catalogTable);
- log.info("Restored schema state for table: {}", entry.tableId);
}
-
- // restore buffered data rows
- Iterable<BufferedDataEntry> bufferedEntries =
bufferedDataRowsState.get();
- if (bufferedEntries != null) {
- synchronized (this) {
- for (BufferedDataEntry entry : bufferedEntries) {
- if (entry != null && entry.key != null &&
entry.bufferedRows != null) {
- bufferedDataRows.put(entry.key, new
ArrayList<>(entry.bufferedRows));
- log.info(
- "Restored {} buffered data rows for key:
{}",
- entry.bufferedRows.size(),
- entry.key);
- }
- }
+ for (BufferedRecordEntry entry : bufferedRecordsState.get()) {
+ if (entry.isSchemaChange) {
+
pendingQueue.add(BufferedRecord.schemaChange(entry.schemaEvent));
+ } else {
+ pendingQueue.add(BufferedRecord.data(entry.row,
entry.timestamp));
}
}
+ log.info(
+ "State restored: lastEventTime={}, pending={},
firstSeenCkpt={}, queueSize={}",
+ lastProcessedEventTime,
+ schemaChangePending,
+ firstSeenCheckpointId,
+ pendingQueue.size());
}
+ }
- log.info(
- "SchemaOperator state initialized using operator state -
lastProcessedEventTime: {}, schemaChangePending: {}, localSchemaState size: {},
bufferedDataRows size: {}",
- this.lastProcessedEventTime,
- this.schemaChangePending,
- localSchemaState.size(),
- bufferedDataRows.size());
+ private boolean isSchemaEvolutionEnabled(Config config) {
+ return config.hasPath("schema-changes.enabled")
+ && config.getBoolean("schema-changes.enabled");
}
- @Setter
- @Getter
- public static class BufferedDataRow implements Serializable {
- private static final long serialVersionUID = 1L;
+ private boolean isSchemaChangeSupported(
+ SchemaChangeEvent event, List<SchemaChangeType> supportedTypes) {
+ switch (event.getEventType()) {
+ case SCHEMA_CHANGE_ADD_COLUMN:
+ return supportedTypes.contains(SchemaChangeType.ADD_COLUMN);
+ case SCHEMA_CHANGE_DROP_COLUMN:
+ return supportedTypes.contains(SchemaChangeType.DROP_COLUMN);
+ case SCHEMA_CHANGE_MODIFY_COLUMN:
+ return supportedTypes.contains(SchemaChangeType.UPDATE_COLUMN);
+ case SCHEMA_CHANGE_CHANGE_COLUMN:
+ return supportedTypes.contains(SchemaChangeType.RENAME_COLUMN);
+ case SCHEMA_CHANGE_UPDATE_COLUMNS:
+ return supportedTypes.contains(SchemaChangeType.ADD_COLUMN)
+ ||
supportedTypes.contains(SchemaChangeType.DROP_COLUMN)
+ ||
supportedTypes.contains(SchemaChangeType.UPDATE_COLUMN)
+ ||
supportedTypes.contains(SchemaChangeType.RENAME_COLUMN);
+ default:
+ log.error("Unknown schema change event type: {}",
event.getEventType());
+ throw SchemaValidationException.unsupportedChangeType(
+ event.tableIdentifier(), jobId);
+ }
+ }
- private SeaTunnelRow row;
- private long timestamp;
+ private void sendSchemaChangeEventToDownstream(SchemaChangeEvent
schemaChangeEvent) {
+ log.info(
+ "Broadcasting SchemaChangeEvent to downstream for table: {}",
+ schemaChangeEvent.tableIdentifier());
+ SeaTunnelRow broadcastRow = new SeaTunnelRow(0);
+ Map<String, Object> options = new HashMap<>();
+ options.put("schema_change_broadcast", schemaChangeEvent);
+ broadcastRow.setOptions(options);
+ output.collect(new StreamRecord<>(broadcastRow));
+ }
- public BufferedDataRow() {}
+ @Override
+ public void close() throws Exception {
+ super.close();
+ }
- public BufferedDataRow(SeaTunnelRow row, long timestamp) {
+ static class BufferedRecord {
+ final boolean isSchemaChange;
+ final SeaTunnelRow row;
+ final long timestamp;
+ final SchemaChangeEvent schemaEvent;
+
+ private BufferedRecord(
+ boolean isSchemaChange,
+ SeaTunnelRow row,
+ long timestamp,
+ SchemaChangeEvent schemaEvent) {
+ this.isSchemaChange = isSchemaChange;
this.row = row;
this.timestamp = timestamp;
+ this.schemaEvent = schemaEvent;
+ }
+
+ static BufferedRecord data(SeaTunnelRow row, long timestamp) {
+ return new BufferedRecord(false, row, timestamp, null);
+ }
+
+ static BufferedRecord schemaChange(SchemaChangeEvent event) {
+ return new BufferedRecord(true, null, 0L, event);
}
}
@@ -797,7 +509,6 @@ public class SchemaOperator extends
AbstractStreamOperator<SeaTunnelRow>
@Getter
public static class SchemaStateEntry implements Serializable {
private static final long serialVersionUID = 1L;
-
private TableIdentifier tableId;
private CatalogTable catalogTable;
@@ -811,17 +522,24 @@ public class SchemaOperator extends
AbstractStreamOperator<SeaTunnelRow>
@Setter
@Getter
- public static class BufferedDataEntry implements Serializable {
+ public static class BufferedRecordEntry implements Serializable {
private static final long serialVersionUID = 1L;
+ private boolean isSchemaChange;
+ private SeaTunnelRow row;
+ private long timestamp;
+ private SchemaChangeEvent schemaEvent;
- private String key;
- private List<BufferedDataRow> bufferedRows;
-
- public BufferedDataEntry() {}
+ public BufferedRecordEntry() {}
- public BufferedDataEntry(String key, List<BufferedDataRow>
bufferedRows) {
- this.key = key;
- this.bufferedRows = bufferedRows;
+ public BufferedRecordEntry(
+ boolean isSchemaChange,
+ SeaTunnelRow row,
+ long timestamp,
+ SchemaChangeEvent schemaEvent) {
+ this.isSchemaChange = isSchemaChange;
+ this.row = row;
+ this.timestamp = timestamp;
+ this.schemaEvent = schemaEvent;
}
}
}
diff --git
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
index 41310992a1..8d99f433a3 100644
---
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
+++
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
@@ -59,7 +59,7 @@ public class LocalSchemaCoordinator {
private static final long CLEANUP_INTERVAL_MS = 60_000L;
private final String jobId;
private final long requestTtlMs;
- private volatile int sinkParallelism = 0;
+ private final Set<Integer> activeSinkSubtasks =
ConcurrentHashMap.newKeySet();
private final Map<String, TimestampedPendingRequest> pendingRequests =
new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> receivedAcks = new
ConcurrentHashMap<>();
@@ -106,19 +106,61 @@ public class LocalSchemaCoordinator {
.get();
}
+ /**
+ * @deprecated Sink parallelism is now tracked dynamically via {@link
+ * #registerSinkStateProvider} and {@link #unregisterSinkSubtask}.
This method is kept for
+ * backward compatibility but is effectively a no-op.
+ */
+ @Deprecated
public void registerSinkParallelism(int parallelism) {
- this.sinkParallelism = parallelism;
log.info(
- "Registered sink parallelism: {} for schema change
coordination in jobId: {}",
+ "Registered sink parallelism hint: {} for jobId: {} (active
tracking used instead)",
parallelism,
jobId);
}
public void registerSinkStateProvider(int subtaskId, SinkStateProvider
provider) {
sinkStateProviders.put(subtaskId, provider);
+ activeSinkSubtasks.add(subtaskId);
log.info("Registered sink state provider for subtask {} in jobId: {}",
subtaskId, jobId);
}
+ public void unregisterSinkSubtask(int subtaskId) {
+ boolean removed = activeSinkSubtasks.remove(subtaskId);
+ sinkStateProviders.remove(subtaskId);
+ if (!removed) {
+ return;
+ }
+ int remaining = activeSinkSubtasks.size();
+ log.info(
+ "Sink subtask {} unregistered (closed). Active sink subtasks
remaining: {} in jobId: {}",
+ subtaskId,
+ remaining,
+ jobId);
+
+ for (Map.Entry<String, TimestampedPendingRequest> entry :
pendingRequests.entrySet()) {
+ String key = entry.getKey();
+ TimestampedPendingRequest request = entry.getValue();
+ Set<Integer> applied = receivedAcks.get(key);
+
+ int expectedActive = Math.max(remaining, 1);
+ if (applied != null && applied.size() >= expectedActive) {
+ if (request.appliedPhaseCompleteAtomic.compareAndSet(false,
true)) {
+ boolean allSuccess = request.allSuccess.get();
+ request.future.complete(allSuccess);
+ log.info(
+ "After subtask {} unregistered, all {} active
subtasks have applied "
+ + "schema change for table {} (epoch {}).
Completing request with result: {}",
+ subtaskId,
+ expectedActive,
+ request.tableId,
+ request.epoch,
+ allSuccess);
+ }
+ }
+ }
+ }
+
public SchemaProcessingStatus querySchemaProcessingStatus(TableIdentifier
tableId, long epoch) {
if (sinkStateProviders.isEmpty()) {
log.warn(
@@ -176,10 +218,10 @@ public class LocalSchemaCoordinator {
public boolean requestSchemaChange(TableIdentifier tableId, long epoch,
long timeoutMs)
throws InterruptedException, SchemaCoordinationException {
String key = tableId.toString() + "#" + epoch;
- int expectedAcks = sinkParallelism;
+ int expectedAcks = activeSinkSubtasks.size();
if (expectedAcks == 0) {
log.warn(
- "Sink parallelism not registered yet. Cannot coordinate
schema change for table {} (epoch {}). "
+ "No active sink subtasks. Cannot coordinate schema change
for table {} (epoch {}). "
+ "Assuming success to avoid deadlock.",
tableId,
epoch);
@@ -270,6 +312,8 @@ public class LocalSchemaCoordinator {
}
appliedSubtasks.add(subtaskId);
+ int currentExpected = Math.min(request.expectedAcks,
activeSinkSubtasks.size());
+ currentExpected = Math.max(currentExpected, 1);
log.info(
"Subtask {} applied schema change for table {} (epoch {}),
success: {}. {}/{} subtasks applied.",
subtaskId,
@@ -277,20 +321,19 @@ public class LocalSchemaCoordinator {
epoch,
success,
appliedSubtasks.size(),
- request.expectedAcks);
+ currentExpected);
if (!success) {
request.allSuccess.set(false);
}
- // if all subtasks have applied, complete the future
- if (appliedSubtasks.size() >= request.expectedAcks) {
+ if (appliedSubtasks.size() >= currentExpected) {
if (request.appliedPhaseCompleteAtomic.compareAndSet(false, true))
{
boolean allSuccess = request.allSuccess.get();
request.future.complete(allSuccess);
log.info(
- "All {} subtasks have applied schema change for table
{} (epoch {}). Completing request with result: {}",
- request.expectedAcks,
+ "All {} active subtasks have applied schema change for
table {} (epoch {}). Completing request with result: {}",
+ currentExpected,
tableId,
epoch,
allSuccess);
diff --git
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
index cd60a52614..f776520acd 100644
---
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
+++
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriter.java
@@ -141,7 +141,6 @@ public class FlinkSinkWriter<InputT, CommT, WriterStateT>
"FlinkSinkWriter applying SchemaChangeEvent for table: {}",
schemaChangeEvent.tableIdentifier());
- sinkWriter.prepareCommit();
if (!(sinkWriter instanceof SupportSchemaEvolutionSinkWriter)) {
log.warn(
"Sink writer {} does not support schema evolution,
ignoring SchemaChangeEvent for table: {}",
diff --git
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/schema/SchemaOperatorTest.java
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/schema/SchemaOperatorTest.java
new file mode 100644
index 0000000000..fd3b7d2126
--- /dev/null
+++
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/schema/SchemaOperatorTest.java
@@ -0,0 +1,400 @@
+/*
+ * 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.seatunnel.translation.flink.schema;
+
+import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory;
+
+import org.apache.seatunnel.api.source.SupportSchemaEvolution;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.schema.SchemaChangeType;
+import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent;
+import
org.apache.seatunnel.api.table.schema.exception.SchemaCoordinationException;
+import
org.apache.seatunnel.api.table.schema.exception.SchemaEvolutionException;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import
org.apache.seatunnel.translation.flink.schema.coordinator.LocalSchemaCoordinator;
+
+import org.apache.flink.api.common.JobID;
+import org.apache.flink.api.common.state.BroadcastState;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.state.MapStateDescriptor;
+import org.apache.flink.api.common.state.OperatorStateStore;
+import org.apache.flink.runtime.state.StateInitializationContext;
+import org.apache.flink.runtime.state.StateSnapshotContext;
+import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
+import org.apache.flink.streaming.api.operators.Output;
+import org.apache.flink.streaming.api.operators.StreamOperatorStateHandler;
+import org.apache.flink.streaming.api.operators.StreamingRuntimeContext;
+import org.apache.flink.streaming.api.watermark.Watermark;
+import org.apache.flink.streaming.runtime.streamrecord.LatencyMarker;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.runtime.watermarkstatus.WatermarkStatus;
+import org.apache.flink.util.OutputTag;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class SchemaOperatorTest {
+
+ @Test
+ void testWaitRoundBeforeReleasingBufferedRecords() throws Exception {
+ LocalSchemaCoordinator coordinator =
Mockito.mock(LocalSchemaCoordinator.class);
+ Mockito.when(
+ coordinator.requestSchemaChange(
+ Mockito.any(), Mockito.anyLong(),
Mockito.anyLong()))
+ .thenReturn(true);
+
+ OperatorTestContext context = createOperator(false);
+ setField(context.operator, "coordinator", coordinator);
+
+ AlterTableAddColumnEvent event = createSchemaChangeEvent();
+ SeaTunnelRow row = createDataRow("row-after-schema");
+
+ context.operator.processElement(new
StreamRecord<>(createSchemaRow(event), 100L));
+ context.operator.processElement(new StreamRecord<>(row, 101L));
+
+ context.operator.notifyCheckpointComplete(10L);
+
+ assertTrue(context.output.records.isEmpty());
+ assertEquals(10L, getLongField(context.operator,
"firstSeenCheckpointId"));
+ assertEquals(2, getPendingQueue(context.operator).size());
+ Mockito.verifyNoInteractions(coordinator);
+
+ context.operator.notifyCheckpointComplete(11L);
+
+ assertEquals(2, context.output.records.size());
+ assertSchemaBroadcast(context.output.records.get(0), event);
+ assertEquals(row, context.output.records.get(1).getValue());
+ assertFalse(getBooleanField(context.operator, "schemaChangePending"));
+ assertEquals(-1L, getLongField(context.operator,
"firstSeenCheckpointId"));
+ assertTrue(getPendingQueue(context.operator).isEmpty());
+ Mockito.verify(coordinator)
+ .requestSchemaChange(event.tableIdentifier(),
event.getCreatedTime(), 300_000L);
+ }
+
+ @Test
+ void testSnapshotRestoreWithPendingSchemaChange() throws Exception {
+ OperatorStateStoreStub stateStore = new OperatorStateStoreStub();
+ OperatorTestContext originalContext = createOperator(stateStore,
false);
+
+ AlterTableAddColumnEvent event = createSchemaChangeEvent();
+ SeaTunnelRow row = createDataRow("buffered-across-restore");
+
+ originalContext.operator.processElement(new
StreamRecord<>(createSchemaRow(event), 200L));
+ originalContext.operator.processElement(new StreamRecord<>(row, 201L));
+ originalContext.operator.notifyCheckpointComplete(20L);
+ originalContext.operator.snapshotState(snapshotContext(20L));
+
+ LocalSchemaCoordinator restoredCoordinator =
Mockito.mock(LocalSchemaCoordinator.class);
+ Mockito.when(
+ restoredCoordinator.requestSchemaChange(
+ Mockito.any(), Mockito.anyLong(),
Mockito.anyLong()))
+ .thenReturn(true);
+
+ OperatorTestContext restoredContext = createOperator(stateStore, true);
+ setField(restoredContext.operator, "coordinator", restoredCoordinator);
+
+ assertEquals(20L, getLongField(restoredContext.operator,
"firstSeenCheckpointId"));
+ assertEquals(2, getPendingQueue(restoredContext.operator).size());
+ assertTrue(getBooleanField(restoredContext.operator,
"schemaChangePending"));
+
+ restoredContext.operator.notifyCheckpointComplete(21L);
+
+ assertEquals(2, restoredContext.output.records.size());
+ assertSchemaBroadcast(restoredContext.output.records.get(0), event);
+ assertEquals(row, restoredContext.output.records.get(1).getValue());
+ assertTrue(getPendingQueue(restoredContext.operator).isEmpty());
+ assertFalse(getBooleanField(restoredContext.operator,
"schemaChangePending"));
+ Mockito.verify(restoredCoordinator)
+ .requestSchemaChange(event.tableIdentifier(),
event.getCreatedTime(), 300_000L);
+ }
+
+ @Test
+ void testCoordinationFailureKeepsBufferedRecordsBlocked() throws Exception
{
+ LocalSchemaCoordinator coordinator =
Mockito.mock(LocalSchemaCoordinator.class);
+ AlterTableAddColumnEvent event = createSchemaChangeEvent();
+ Mockito.when(
+ coordinator.requestSchemaChange(
+ Mockito.any(), Mockito.anyLong(),
Mockito.anyLong()))
+ .thenThrow(
+ SchemaCoordinationException.timeout(
+ event.tableIdentifier(),
+ "job-under-test",
+ 1L,
+ new RuntimeException("timeout")));
+
+ OperatorTestContext context = createOperator(false);
+ setField(context.operator, "coordinator", coordinator);
+
+ SeaTunnelRow row = createDataRow("must-stay-buffered");
+ context.operator.processElement(new
StreamRecord<>(createSchemaRow(event), 300L));
+ context.operator.processElement(new StreamRecord<>(row, 301L));
+ context.operator.notifyCheckpointComplete(30L);
+
+ assertThrows(
+ SchemaEvolutionException.class,
+ () -> context.operator.notifyCheckpointComplete(31L));
+
+ assertEquals(1, context.output.records.size());
+ assertSchemaBroadcast(context.output.records.get(0), event);
+ assertTrue(getBooleanField(context.operator, "schemaChangePending"));
+ assertEquals(30L, getLongField(context.operator,
"firstSeenCheckpointId"));
+ Queue<SchemaOperator.BufferedRecord> pendingQueue =
getPendingQueue(context.operator);
+ assertEquals(2, pendingQueue.size());
+ assertTrue(pendingQueue.peek().isSchemaChange);
+ }
+
+ private static OperatorTestContext createOperator(boolean restored) throws
Exception {
+ return createOperator(new OperatorStateStoreStub(), restored);
+ }
+
+ private static OperatorTestContext createOperator(
+ OperatorStateStoreStub stateStore, boolean restored) throws
Exception {
+ SupportSchemaEvolution source =
Mockito.mock(SupportSchemaEvolution.class);
+ Mockito.when(source.supports())
+
.thenReturn(Collections.singletonList(SchemaChangeType.ADD_COLUMN));
+
+ SchemaOperator operator =
+ new SchemaOperator(
+ "bootstrap-job-id",
+ source,
+ ConfigFactory.parseString("schema-changes.enabled =
true"));
+
+ CollectingOutput output = new CollectingOutput();
+ setField(operator, AbstractStreamOperator.class, "output", output);
+ setField(operator, AbstractStreamOperator.class, "runtimeContext",
runtimeContext());
+ setField(
+ operator,
+ AbstractStreamOperator.class,
+ "stateHandler",
+ Mockito.mock(StreamOperatorStateHandler.class));
+
+ StateInitializationContext initializationContext =
+ Mockito.mock(StateInitializationContext.class);
+
Mockito.when(initializationContext.getOperatorStateStore()).thenReturn(stateStore);
+ Mockito.when(initializationContext.isRestored()).thenReturn(restored);
+
+ operator.initializeState(initializationContext);
+ operator.open();
+ return new OperatorTestContext(operator, output);
+ }
+
+ private static StreamingRuntimeContext runtimeContext() {
+ StreamingRuntimeContext runtimeContext =
Mockito.mock(StreamingRuntimeContext.class);
+ Mockito.when(runtimeContext.getJobId()).thenReturn(new JobID());
+ return runtimeContext;
+ }
+
+ private static StateSnapshotContext snapshotContext(long checkpointId)
throws Exception {
+ StateSnapshotContext snapshotContext =
Mockito.mock(StateSnapshotContext.class);
+
Mockito.when(snapshotContext.getCheckpointId()).thenReturn(checkpointId);
+ return snapshotContext;
+ }
+
+ private static AlterTableAddColumnEvent createSchemaChangeEvent() {
+ return AlterTableAddColumnEvent.add(
+ TableIdentifier.of("catalog", "database", "table"),
+ PhysicalColumn.of("added_col", BasicType.STRING_TYPE, 64L,
true, null, null));
+ }
+
+ private static SeaTunnelRow createSchemaRow(AlterTableAddColumnEvent
event) {
+ SeaTunnelRow row = new SeaTunnelRow(0);
+ row.setTableId("__SCHEMA_CHANGE_EVENT__");
+ Map<String, Object> options = new LinkedHashMap<>();
+ options.put("schema_change_event", event);
+ row.setOptions(options);
+ return row;
+ }
+
+ private static SeaTunnelRow createDataRow(String value) {
+ SeaTunnelRow row = new SeaTunnelRow(1);
+ row.setTableId("catalog.database.table");
+ row.setField(0, value);
+ return row;
+ }
+
+ private static void assertSchemaBroadcast(
+ StreamRecord<SeaTunnelRow> record, AlterTableAddColumnEvent event)
{
+ Object broadcastEvent =
record.getValue().getOptions().get("schema_change_broadcast");
+ assertInstanceOf(AlterTableAddColumnEvent.class, broadcastEvent);
+ assertEquals(event, broadcastEvent);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Queue<SchemaOperator.BufferedRecord>
getPendingQueue(SchemaOperator operator)
+ throws Exception {
+ return (Queue<SchemaOperator.BufferedRecord>) getField(operator,
"pendingQueue");
+ }
+
+ private static boolean getBooleanField(Object target, String fieldName)
throws Exception {
+ return (boolean) getField(target, fieldName);
+ }
+
+ private static long getLongField(Object target, String fieldName) throws
Exception {
+ return (long) getField(target, fieldName);
+ }
+
+ private static Object getField(Object target, String fieldName) throws
Exception {
+ Field field = findField(target.getClass(), fieldName);
+ field.setAccessible(true);
+ return field.get(target);
+ }
+
+ private static void setField(Object target, String fieldName, Object
value) throws Exception {
+ Field field = findField(target.getClass(), fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+
+ private static void setField(Object target, Class<?> owner, String
fieldName, Object value)
+ throws Exception {
+ Field field = owner.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+
+ private static Field findField(Class<?> type, String fieldName) throws
NoSuchFieldException {
+ Class<?> current = type;
+ while (current != null) {
+ try {
+ return current.getDeclaredField(fieldName);
+ } catch (NoSuchFieldException ignored) {
+ current = current.getSuperclass();
+ }
+ }
+ throw new NoSuchFieldException(fieldName);
+ }
+
+ private static final class OperatorTestContext {
+ private final SchemaOperator operator;
+ private final CollectingOutput output;
+
+ private OperatorTestContext(SchemaOperator operator, CollectingOutput
output) {
+ this.operator = operator;
+ this.output = output;
+ }
+ }
+
+ private static final class CollectingOutput implements
Output<StreamRecord<SeaTunnelRow>> {
+ private final List<StreamRecord<SeaTunnelRow>> records = new
ArrayList<>();
+
+ @Override
+ public void collect(StreamRecord<SeaTunnelRow> record) {
+ records.add(record);
+ }
+
+ @Override
+ public void close() {}
+
+ @Override
+ public void emitWatermark(Watermark mark) {}
+
+ @Override
+ public void emitWatermarkStatus(WatermarkStatus watermarkStatus) {}
+
+ @Override
+ public <X> void collect(OutputTag<X> outputTag, StreamRecord<X>
record) {}
+
+ @Override
+ public void emitLatencyMarker(LatencyMarker latencyMarker) {}
+ }
+
+ private static final class OperatorStateStoreStub implements
OperatorStateStore {
+ private final Map<String, TestingListState<?>> listStates = new
LinkedHashMap<>();
+
+ @Override
+ public <K, V> BroadcastState<K, V> getBroadcastState(
+ MapStateDescriptor<K, V> stateDescriptor) {
+ throw new UnsupportedOperationException("Broadcast state is not
needed in this test");
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public <S> ListState<S> getListState(ListStateDescriptor<S>
stateDescriptor) {
+ return (ListState<S>)
+ listStates.computeIfAbsent(
+ stateDescriptor.getName(), ignored -> new
TestingListState<>());
+ }
+
+ @Override
+ public <S> ListState<S> getUnionListState(ListStateDescriptor<S>
stateDescriptor) {
+ return getListState(stateDescriptor);
+ }
+
+ @Override
+ public Set<String> getRegisteredStateNames() {
+ return listStates.keySet();
+ }
+
+ @Override
+ public Set<String> getRegisteredBroadcastStateNames() {
+ return Collections.emptySet();
+ }
+ }
+
+ private static final class TestingListState<T> implements ListState<T> {
+ private final List<T> values = new ArrayList<>();
+
+ @Override
+ public Iterable<T> get() {
+ return new ArrayList<>(values);
+ }
+
+ @Override
+ public void add(T value) {
+ values.add(value);
+ }
+
+ @Override
+ public void update(List<T> values) {
+ this.values.clear();
+ if (values != null) {
+ this.values.addAll(values);
+ }
+ }
+
+ @Override
+ public void addAll(List<T> values) {
+ if (values != null) {
+ this.values.addAll(values);
+ }
+ }
+
+ @Override
+ public void clear() {
+ values.clear();
+ }
+ }
+}