This is an automated email from the ASF dual-hosted git repository.
loserwang1024 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 c1998ea96 [FLINK-40982][pipeline-connector/fluss] Make ADD COLUMN
replay idempotent (#4574)
c1998ea96 is described below
commit c1998ea9633a4c10b752706d2f09328f8b933a00
Author: Xiaobing Fang <[email protected]>
AuthorDate: Wed Oct 21 15:13:07 2026 +0800
[FLINK-40982][pipeline-connector/fluss] Make ADD COLUMN replay idempotent
(#4574)
---
.../fluss/sink/FlussMetaDataApplier.java | 98 ++++-
.../fluss/sink/FlussMetadataApplierTest.java | 452 ++++++++++++++++++++-
.../fluss/sink/FlussSinkPipelineITCase.java | 54 +++
3 files changed, 598 insertions(+), 6 deletions(-)
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetaDataApplier.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetaDataApplier.java
index 3622d4992..3ec152430 100644
---
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetaDataApplier.java
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetaDataApplier.java
@@ -17,6 +17,7 @@
package org.apache.flink.cdc.connectors.fluss.sink;
+import org.apache.flink.cdc.common.annotation.VisibleForTesting;
import org.apache.flink.cdc.common.event.AddColumnEvent;
import org.apache.flink.cdc.common.event.CreateTableEvent;
import org.apache.flink.cdc.common.event.DropTableEvent;
@@ -35,11 +36,15 @@ import org.apache.fluss.client.Connection;
import org.apache.fluss.client.ConnectionFactory;
import org.apache.fluss.client.admin.Admin;
import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.InvalidAlterTableException;
import org.apache.fluss.metadata.DatabaseDescriptor;
import org.apache.fluss.metadata.TableChange;
import org.apache.fluss.metadata.TableDescriptor;
import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.types.RowType;
+import org.apache.fluss.utils.ExceptionUtils;
+import org.apache.fluss.utils.ExponentialBackoff;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -62,6 +67,11 @@ import static
org.apache.flink.cdc.connectors.fluss.utils.FlussConversions.toFlu
/** {@link MetadataApplier} for fluss. */
public class FlussMetaDataApplier implements MetadataApplier,
ExistingTableSchemaExpansionSupport {
private static final Logger LOG =
LoggerFactory.getLogger(FlussMetaDataApplier.class);
+ private static final int MAX_SCHEMA_READ_RETRIES = 3;
+
+ // Zero jitter keeps the backoff sequence deterministic for tests.
+ private static final ExponentialBackoff SCHEMA_READ_BACKOFF =
+ new ExponentialBackoff(100L, 2, 400L, 0.0);
private final Configuration flussClientConfig;
private final Map<String, String> tableProperties;
private final Map<String, List<String>> bucketKeysMap;
@@ -218,13 +228,99 @@ public class FlussMetaDataApplier implements
MetadataApplier, ExistingTableSchem
Admin admin = connection.getAdmin()) {
TableId tableId = event.tableId();
TablePath tablePath = new TablePath(tableId.getSchemaName(),
tableId.getTableName());
- admin.alterTable(tablePath, tableChanges, true).get();
+ applyAddColumnChanges(admin, tablePath, tableChanges);
+ } catch (InterruptedException e) {
+ // Wrapping loses the exception type, so keep the cancellation
signal alive.
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
} catch (Exception e) {
LOG.error("Failed to apply schema change {}", event, e);
throw new RuntimeException(e);
}
}
+ @VisibleForTesting
+ void applyAddColumnChanges(Admin admin, TablePath tablePath,
List<TableChange> tableChanges)
+ throws Exception {
+ if (tableChanges.isEmpty()) {
+ return;
+ }
+ try {
+ admin.alterTable(tablePath, tableChanges, false).get();
+ } catch (Exception e) {
+ List<TableChange> remainingChanges =
+ reconcileWithTargetSchema(admin, tablePath, tableChanges,
e);
+ if (!remainingChanges.isEmpty()) {
+ // Reconcile once; concurrent changes or further stale reads
must fail normally.
+ admin.alterTable(tablePath, remainingChanges, false).get();
+ }
+ }
+ }
+
+ /**
+ * Returns the changes the target schema still needs, or rethrows {@code
cause} when the failed
+ * ADD is not a replay of an already applied one.
+ */
+ private List<TableChange> reconcileWithTargetSchema(
+ Admin admin, TablePath tablePath, List<TableChange> tableChanges,
Exception cause)
+ throws Exception {
+ if (!(ExceptionUtils.stripExecutionException(cause)
+ instanceof InvalidAlterTableException)) {
+ throw cause;
+ }
+ Set<String> requestedNames = new HashSet<>();
+ for (TableChange change : tableChanges) {
+ if (!requestedNames.add(((TableChange.AddColumn)
change).getName())) {
+ throw cause;
+ }
+ }
+ List<TableChange> remainingChanges =
+ getRemainingChanges(admin.getTableInfo(tablePath).get(),
tableChanges);
+ // alterTable is served by the CoordinatorServer while getTableInfo is
served by a random
+ // TabletServer reading ZooKeeper without sync(), so the read can lag
behind the write that
+ // made this ADD fail. Retry before concluding the failure is
unrelated to a replay.
+ for (int attempt = 0;
+ remainingChanges.size() == tableChanges.size() && attempt <
MAX_SCHEMA_READ_RETRIES;
+ attempt++) {
+ Thread.sleep(SCHEMA_READ_BACKOFF.backoff(attempt));
+ remainingChanges =
+ getRemainingChanges(admin.getTableInfo(tablePath).get(),
tableChanges);
+ }
+ if (remainingChanges.size() == tableChanges.size()) {
+ // Nothing was already applied, so the failure cannot be a replay.
+ throw cause;
+ }
+ return remainingChanges;
+ }
+
+ /**
+ * Filters out the changes already reflected in {@code tableInfo}. Field
ids are deliberately
+ * ignored: {@code toFlussType} derives them as -1 while the server
assigns real ones.
+ */
+ private List<TableChange> getRemainingChanges(
+ TableInfo tableInfo, List<TableChange> tableChanges) {
+ RowType rowType = tableInfo.getRowType();
+ List<TableChange> remainingChanges = new ArrayList<>();
+ for (TableChange change : tableChanges) {
+ TableChange.AddColumn addition = (TableChange.AddColumn) change;
+ int index = rowType.getFieldIndex(addition.getName());
+ if (index < 0) {
+ remainingChanges.add(change);
+ } else if
(!rowType.getTypeAt(index).equals(addition.getDataType())) {
+ throw new IllegalArgumentException(
+ "Column "
+ + addition.getName()
+ + " already exists in table "
+ + tableInfo.getTablePath()
+ + " with type "
+ + rowType.getTypeAt(index)
+ + ", but ADD COLUMN requests "
+ + addition.getDataType());
+ }
+ }
+ return remainingChanges;
+ }
+
private void sanityCheck(
TableDescriptor inferredFlussTable,
TableInfo currentTableInfo,
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
index dface1798..1faf881f8 100644
---
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
@@ -25,6 +25,7 @@ import org.apache.flink.cdc.common.event.TableId;
import org.apache.flink.cdc.common.pipeline.SchemaChangeBehavior;
import org.apache.flink.cdc.common.schema.Column;
import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataType;
import org.apache.flink.cdc.common.types.DataTypes;
import org.apache.flink.cdc.common.types.IntType;
import
org.apache.flink.cdc.runtime.operators.schema.common.ExistingTableSchemaExpander;
@@ -33,8 +34,12 @@ import org.apache.flink.table.api.ValidationException;
import org.apache.fluss.client.Connection;
import org.apache.fluss.client.ConnectionFactory;
import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.exception.InvalidAlterTableException;
import org.apache.fluss.exception.InvalidConfigException;
+import org.apache.fluss.exception.TableNotExistException;
+import org.apache.fluss.exception.UnknownServerException;
import org.apache.fluss.metadata.DatabaseDescriptor;
+import org.apache.fluss.metadata.TableChange;
import org.apache.fluss.metadata.TableDescriptor;
import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.metadata.TablePath;
@@ -44,18 +49,28 @@ import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
import static
org.apache.flink.cdc.connectors.fluss.utils.FlussConversions.toFlussSchema;
+import static
org.apache.flink.cdc.connectors.fluss.utils.FlussConversions.toFlussType;
import static org.apache.fluss.config.ConfigOptions.TABLE_REPLICATION_FACTOR;
import static org.apache.fluss.types.DataTypeChecks.equalsWithFieldId;
import static org.assertj.core.api.Assertions.assertThat;
@@ -120,8 +135,8 @@ public class FlussMetadataApplierTest {
"row_col"
};
- org.apache.flink.cdc.common.types.DataType[] cdcDataTypes =
- new org.apache.flink.cdc.common.types.DataType[] {
+ DataType[] cdcDataTypes =
+ new DataType[] {
DataTypes.BINARY(10),
DataTypes.VARBINARY(10),
DataTypes.BYTES(),
@@ -296,8 +311,7 @@ public class FlussMetadataApplierTest {
void testUnsupportedType() throws Exception {
String[] fieldNames = new String[] {"timestamp_tz_col"};
- org.apache.flink.cdc.common.types.DataType[] cdcDataTypes =
- new org.apache.flink.cdc.common.types.DataType[]
{DataTypes.TIMESTAMP_TZ()};
+ DataType[] cdcDataTypes = new DataType[] {DataTypes.TIMESTAMP_TZ()};
try (FlussMetaDataApplier applier =
new FlussMetaDataApplier(
@@ -600,7 +614,8 @@ public class FlussMetadataApplierTest {
Collections.singletonList(
AddColumnEvent.last(oldColumn)))))
.rootCause()
- .hasMessageContaining("Column name already exists.");
+ .hasMessageContaining("Column name already exists in
table")
+ .hasMessageContaining("with type INT, but ADD COLUMN
requests STRING");
applier.applySchemaChange(
new AddColumnEvent(
tableId,
Collections.singletonList(AddColumnEvent.last(newColumn))));
@@ -615,6 +630,433 @@ public class FlussMetadataApplierTest {
}
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testReplayAddColumns(boolean partiallyApplied) throws Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME, "replay_columns");
+ TablePath tablePath = new TablePath(DATABASE_NAME, "replay_columns");
+ Column name = Column.physicalColumn("name", DataTypes.VARCHAR(100));
+ Column details =
+ Column.physicalColumn(
+ "details",
+ DataTypes.ROW(DataTypes.FIELD("amount",
DataTypes.DECIMAL(10, 2))));
+ AddColumnEvent event =
+ new AddColumnEvent(
+ tableId,
+ Arrays.asList(AddColumnEvent.last(name),
AddColumnEvent.last(details)));
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ applier.applySchemaChange(
+ new CreateTableEvent(
+ tableId,
+ Schema.newBuilder().physicalColumn("id",
DataTypes.INT()).build()));
+ applier.applySchemaChange(
+ partiallyApplied
+ ? new AddColumnEvent(
+ tableId,
Collections.singletonList(AddColumnEvent.last(name)))
+ : event);
+ }
+
+ // A restored job has a new applier, but the external schema has not
rolled back.
+ try (FlussMetaDataApplier restoredApplier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ restoredApplier.applySchemaChange(event);
+ TableInfo afterReplay = admin.getTableInfo(tablePath).get();
+ assertThat(afterReplay.getSchema())
+ .isEqualTo(
+ toFlussSchema(
+ Schema.newBuilder()
+ .physicalColumn("id",
DataTypes.INT())
+ .physicalColumn("name",
DataTypes.STRING())
+ .column(details)
+ .build()));
+ restoredApplier.applySchemaChange(event);
+ assertThat(admin.getTableInfo(tablePath).get().getSchemaId())
+ .isEqualTo(afterReplay.getSchemaId());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testReplayAddColumnsWithStaleSchema(boolean partiallyApplied) throws
Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME, "stale_columns");
+ TablePath tablePath = new TablePath(DATABASE_NAME, "stale_columns");
+ Column first = Column.physicalColumn("a", DataTypes.INT());
+ Column second = Column.physicalColumn("b", DataTypes.INT());
+ AddColumnEvent event =
+ new AddColumnEvent(
+ tableId,
+ Arrays.asList(AddColumnEvent.last(first),
AddColumnEvent.last(second)));
+ List<TableChange> changes = addIntColumns("a", "b");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ applier.applySchemaChange(
+ new CreateTableEvent(
+ tableId,
+ Schema.newBuilder().physicalColumn("id",
DataTypes.INT()).build()));
+ TableInfo staleTableInfo = admin.getTableInfo(tablePath).get();
+ applier.applySchemaChange(
+ partiallyApplied
+ ? new AddColumnEvent(
+ tableId,
Collections.singletonList(AddColumnEvent.last(first)))
+ : event);
+ AtomicBoolean returnStaleSchema = new AtomicBoolean(true);
+ Admin staleAdmin =
+ (Admin)
+ Proxy.newProxyInstance(
+ Admin.class.getClassLoader(),
+ new Class<?>[] {Admin.class},
+ (proxy, method, args) -> {
+ if
(method.getName().equals("getTableInfo")
+ &&
returnStaleSchema.getAndSet(false)) {
+ return
CompletableFuture.completedFuture(
+ staleTableInfo);
+ }
+ return method.invoke(admin, args);
+ });
+
+ applier.applyAddColumnChanges(staleAdmin, tablePath, changes);
+
+ TableInfo afterReplay = admin.getTableInfo(tablePath).get();
+ assertThat(afterReplay.getSchema())
+ .isEqualTo(
+ toFlussSchema(
+ Schema.newBuilder()
+ .physicalColumn("id",
DataTypes.INT())
+ .column(first)
+ .column(second)
+ .build()));
+ applier.applySchemaChange(event);
+ assertThat(admin.getTableInfo(tablePath).get().getSchemaId())
+ .isEqualTo(afterReplay.getSchemaId());
+ }
+ }
+
+ @Test
+ @Timeout(30)
+ void testReplayAddColumnFailsWhenSchemaRemainsStale() throws Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME,
"persistently_stale_schema");
+ TablePath tablePath = new TablePath(DATABASE_NAME,
"persistently_stale_schema");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ applier.applySchemaChange(
+ new CreateTableEvent(
+ tableId,
+ Schema.newBuilder().physicalColumn("id",
DataTypes.INT()).build()));
+ TableInfo staleTableInfo = admin.getTableInfo(tablePath).get();
+ List<TableChange> changes = addIntColumns("a");
+ admin.alterTable(tablePath, changes, false).get();
+ TableInfo beforeReplay = admin.getTableInfo(tablePath).get();
+ AtomicInteger schemaReads = new AtomicInteger();
+ AtomicInteger alterCalls = new AtomicInteger();
+ Admin staleAdmin =
+ (Admin)
+ Proxy.newProxyInstance(
+ Admin.class.getClassLoader(),
+ new Class<?>[] {Admin.class},
+ (proxy, method, args) -> {
+ if
(method.getName().equals("getTableInfo")) {
+ schemaReads.incrementAndGet();
+ return
CompletableFuture.completedFuture(
+ staleTableInfo);
+ }
+ if
(method.getName().equals("alterTable")) {
+ alterCalls.incrementAndGet();
+ }
+ return method.invoke(admin, args);
+ });
+
+ assertThatThrownBy(() -> applier.applyAddColumnChanges(staleAdmin,
tablePath, changes))
+ .rootCause()
+ .isInstanceOf(InvalidAlterTableException.class)
+ .hasMessageContaining("Column a already exists");
+ assertThat(schemaReads.get()).isBetween(2, 4);
+ assertThat(alterCalls.get()).isEqualTo(1);
+ assertThat(admin.getTableInfo(tablePath).get().getSchemaId())
+ .isEqualTo(beforeReplay.getSchemaId());
+ }
+ }
+
+ @Test
+ void testReplayAddColumnsPropagatesConcurrentAdditionFailure() throws
Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME,
"concurrent_addition");
+ TablePath tablePath = new TablePath(DATABASE_NAME,
"concurrent_addition");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ applier.applySchemaChange(
+ new CreateTableEvent(
+ tableId,
+ Schema.newBuilder().physicalColumn("id",
DataTypes.INT()).build()));
+ admin.alterTable(tablePath, addIntColumns("a"), false).get();
+ AtomicInteger schemaReads = new AtomicInteger();
+ AtomicInteger alterCalls = new AtomicInteger();
+ Admin racingAdmin =
+ (Admin)
+ Proxy.newProxyInstance(
+ Admin.class.getClassLoader(),
+ new Class<?>[] {Admin.class},
+ (proxy, method, args) -> {
+ if
(method.getName().equals("alterTable")
+ &&
alterCalls.incrementAndGet() == 2) {
+ // Another writer adds b after we
have observed a.
+ admin.alterTable(tablePath,
addIntColumns("b"), false)
+ .get();
+ }
+ if
(method.getName().equals("getTableInfo")) {
+ schemaReads.incrementAndGet();
+ }
+ return method.invoke(admin, args);
+ });
+
+ assertThatThrownBy(
+ () ->
+ applier.applyAddColumnChanges(
+ racingAdmin, tablePath,
addIntColumns("a", "b")))
+ .rootCause()
+ .isInstanceOf(InvalidAlterTableException.class)
+ .hasMessageContaining("Column b already exists");
+
+ TableInfo afterReplay = admin.getTableInfo(tablePath).get();
+ assertThat(afterReplay.getSchema())
+ .isEqualTo(
+ toFlussSchema(
+ Schema.newBuilder()
+ .physicalColumn("id",
DataTypes.INT())
+ .physicalColumn("a",
DataTypes.INT())
+ .physicalColumn("b",
DataTypes.INT())
+ .build()));
+ assertThat(alterCalls.get()).isEqualTo(2);
+ assertThat(schemaReads.get()).isEqualTo(1);
+ applier.applyAddColumnChanges(admin, tablePath, addIntColumns("a",
"b"));
+ assertThat(admin.getTableInfo(tablePath).get().getSchemaId())
+ .isEqualTo(afterReplay.getSchemaId());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testSchemaReadRetryPropagatesInterruption(boolean pendingRead) throws
Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME,
"interrupted_schema_read");
+ TablePath tablePath = new TablePath(DATABASE_NAME,
"interrupted_schema_read");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ applier.applySchemaChange(
+ new CreateTableEvent(
+ tableId,
+ Schema.newBuilder().physicalColumn("id",
DataTypes.INT()).build()));
+ TableInfo staleTableInfo = admin.getTableInfo(tablePath).get();
+ admin.alterTable(tablePath, addIntColumns("a"), false).get();
+ AtomicInteger schemaReads = new AtomicInteger();
+ Admin interruptedAdmin =
+ (Admin)
+ Proxy.newProxyInstance(
+ Admin.class.getClassLoader(),
+ new Class<?>[] {Admin.class},
+ (proxy, method, args) -> {
+ if
(method.getName().equals("getTableInfo")) {
+ schemaReads.incrementAndGet();
+ Thread.currentThread().interrupt();
+ return pendingRead
+ ? new CompletableFuture<>()
+ :
CompletableFuture.completedFuture(
+ staleTableInfo);
+ }
+ return method.invoke(admin, args);
+ });
+ try {
+ assertThatThrownBy(
+ () ->
+ applier.applyAddColumnChanges(
+ interruptedAdmin, tablePath,
addIntColumns("a")))
+ .isInstanceOf(InterruptedException.class);
+ assertThat(schemaReads.get()).isEqualTo(1);
+ } finally {
+ Thread.interrupted();
+ }
+ }
+ }
+
+ private static List<TableChange> addIntColumns(String... names) {
+ List<TableChange> changes = new ArrayList<>();
+ for (String name : names) {
+ changes.add(
+ TableChange.addColumn(
+ name,
+ toFlussType(DataTypes.INT()),
+ null,
+ TableChange.ColumnPosition.last()));
+ }
+ return changes;
+ }
+
+ @ParameterizedTest
+ @MethodSource("conflictingColumnTypes")
+ void testReplayAddColumnWithConflictingType(DataType requestedType) throws
Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME,
"conflicting_columns");
+ TablePath tablePath = new TablePath(DATABASE_NAME,
"conflicting_columns");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ applier.applySchemaChange(
+ new CreateTableEvent(
+ tableId,
+ Schema.newBuilder()
+ .physicalColumn("amount",
DataTypes.DECIMAL(10, 2))
+ .build()));
+ TableInfo beforeReplay = admin.getTableInfo(tablePath).get();
+ AddColumnEvent event =
+ new AddColumnEvent(
+ tableId,
+ Arrays.asList(
+ AddColumnEvent.last(
+ Column.physicalColumn("missing",
DataTypes.INT())),
+ AddColumnEvent.last(
+ Column.physicalColumn("amount",
requestedType))));
+ assertThatThrownBy(() -> applier.applySchemaChange(event))
+ .rootCause()
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Column amount already exists in
table")
+ .hasMessageContaining("but ADD COLUMN requests");
+ TableInfo afterReplay = admin.getTableInfo(tablePath).get();
+
assertThat(afterReplay.getSchema()).isEqualTo(beforeReplay.getSchema());
+
assertThat(afterReplay.getSchemaId()).isEqualTo(beforeReplay.getSchemaId());
+ }
+ }
+
+ private static Stream<DataType> conflictingColumnTypes() {
+ return Stream.of(
+ DataTypes.STRING(),
+ DataTypes.DECIMAL(12, 2),
+ DataTypes.DECIMAL(10, 3),
+ DataTypes.DECIMAL(10, 2).notNull());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ @Timeout(30)
+ void testInvalidAddColumnIsNotIgnored(boolean partiallyApplied) throws
Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME, "invalid_columns");
+ TablePath tablePath = new TablePath(DATABASE_NAME, "invalid_columns");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ applier.applySchemaChange(
+ new CreateTableEvent(
+ tableId,
+ Schema.newBuilder().physicalColumn("id",
DataTypes.INT()).build()));
+ TableInfo beforeAddition = admin.getTableInfo(tablePath).get();
+ List<AddColumnEvent.ColumnWithPosition> columns = new
ArrayList<>();
+ if (partiallyApplied) {
+ columns.add(AddColumnEvent.last(Column.physicalColumn("id",
DataTypes.INT())));
+ }
+ columns.add(
+ AddColumnEvent.last(
+ Column.physicalColumn("required",
DataTypes.INT().notNull())));
+
+ assertThatThrownBy(
+ () -> applier.applySchemaChange(new
AddColumnEvent(tableId, columns)))
+ .rootCause()
+ .isInstanceOf(UnknownServerException.class)
+ .hasMessageContaining("Column required must be nullable");
+ TableInfo afterAddition = admin.getTableInfo(tablePath).get();
+
assertThat(afterAddition.getSchema()).isEqualTo(beforeAddition.getSchema());
+
assertThat(afterAddition.getSchemaId()).isEqualTo(beforeAddition.getSchemaId());
+ }
+ }
+
+ @ParameterizedTest
+ @CsvSource({"false, false", "true, false", "true, true"})
+ @Timeout(30)
+ void testDuplicateAddColumnsAreRejected(boolean columnExists, boolean
addMissingColumn)
+ throws Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME,
"duplicate_additions");
+ TablePath tablePath = new TablePath(DATABASE_NAME,
"duplicate_additions");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ Schema.Builder schema = Schema.newBuilder().physicalColumn("id",
DataTypes.INT());
+ if (columnExists) {
+ schema.physicalColumn("name", DataTypes.STRING());
+ }
+ applier.applySchemaChange(new CreateTableEvent(tableId,
schema.build()));
+ TableInfo beforeAddition = admin.getTableInfo(tablePath).get();
+ List<AddColumnEvent.ColumnWithPosition> additions =
+ new ArrayList<>(
+ Collections.nCopies(
+ 2,
+ AddColumnEvent.last(
+ Column.physicalColumn("name",
DataTypes.STRING()))));
+ if (addMissingColumn) {
+ additions.add(AddColumnEvent.last(Column.physicalColumn("age",
DataTypes.INT())));
+ }
+ AddColumnEvent event = new AddColumnEvent(tableId, additions);
+ assertThatThrownBy(() -> applier.applySchemaChange(event))
+ .rootCause()
+ .isInstanceOf(InvalidAlterTableException.class)
+ .hasMessageContaining("Column name already exists");
+ TableInfo afterAddition = admin.getTableInfo(tablePath).get();
+
assertThat(afterAddition.getSchema()).isEqualTo(beforeAddition.getSchema());
+
assertThat(afterAddition.getSchemaId()).isEqualTo(beforeAddition.getSchemaId());
+ }
+ }
+
+ @Test
+ void testAddColumnToMissingTable() throws Exception {
+ TableId tableId = TableId.tableId(DATABASE_NAME, "missing_table");
+ try (FlussMetaDataApplier applier =
+ new FlussMetaDataApplier(
+ FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap())) {
+ assertThatThrownBy(
+ () ->
+ applier.applySchemaChange(
+ new AddColumnEvent(
+ tableId,
+ Collections.singletonList(
+
AddColumnEvent.last(
+
Column.physicalColumn(
+
"id",
+
DataTypes.INT()))))))
+ .rootCause()
+ .isInstanceOf(TableNotExistException.class);
+ }
+ }
+
@Test
void testRecreateTableWithDifferentSchema() throws Exception {
TableId tableId = TableId.tableId("default_namespace", DATABASE_NAME,
"table1");
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussSinkPipelineITCase.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussSinkPipelineITCase.java
index 455f1e8cf..c0532faf5 100644
---
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussSinkPipelineITCase.java
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussSinkPipelineITCase.java
@@ -57,6 +57,8 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.util.ArrayList;
@@ -709,6 +711,58 @@ public class FlussSinkPipelineITCase {
"+I[1, a, null, null]", "+I[2, b, null, null]", "+I[3,
c, null, cc]"));
}
+ @ParameterizedTest
+ @CsvSource({"true,true", "true,false", "false,true", "false,false"})
+ void testWriteAfterAddColumnReplay(boolean primaryKeyTable, boolean
partiallyApplied)
+ throws Exception {
+ tBatchEnv
+ .executeSql(
+ String.format(
+ "CREATE TABLE %s.%s (payload STRING, id STRING
NOT NULL, b STRING%s%s)",
+ TABLE_1.getSchemaName(),
+ TABLE_1.getTableName(),
+ partiallyApplied ? "" : ", a STRING",
+ primaryKeyTable ? ", PRIMARY KEY (id) NOT
ENFORCED" : ""))
+ .await();
+
+ Schema.Builder schemaBuilder =
+ Schema.newBuilder()
+ .physicalColumn("id", DataTypes.STRING().notNull())
+ .physicalColumn("payload", DataTypes.STRING());
+ if (primaryKeyTable) {
+ schemaBuilder.primaryKey("id");
+ }
+ BinaryRecordDataGenerator generator =
+ new BinaryRecordDataGenerator(
+ RowType.of(
+ DataTypes.STRING().notNull(),
+ DataTypes.STRING(),
+ DataTypes.STRING(),
+ DataTypes.STRING()));
+ List<Event> events =
+ Arrays.asList(
+ new CreateTableEvent(TABLE_1, schemaBuilder.build()),
+ new AddColumnEvent(
+ TABLE_1,
+ Arrays.asList(
+ AddColumnEvent.last(
+ Column.physicalColumn("a",
DataTypes.STRING())),
+ AddColumnEvent.last(
+ Column.physicalColumn("b",
DataTypes.STRING())))),
+ DataChangeEvent.insertEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+
BinaryStringData.fromString("value-1"),
+
BinaryStringData.fromString("a-101"),
+
BinaryStringData.fromString("b-202")
+ })));
+
+ composeAndExecuteInEvolveMode(Collections.singletonList(events));
+ checkResult(TABLE_1, Collections.singletonList("+I[value-1, 1, b-202,
a-101]"));
+ }
+
private void composeAndExecuteInEvolveMode(List<List<Event>>
customSourceEvents)
throws Exception {
composeAndExecute(