This is an automated email from the ASF dual-hosted git repository.

polyzos pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new 5d1e18f1f [lake/iceberg] Support ADD COLUMN schema evolution for 
Iceberg lake catalog (#2593)
5d1e18f1f is described below

commit 5d1e18f1f28f40d0456e12641b6789529de359ed
Author: Anton Borisov <[email protected]>
AuthorDate: Mon May 25 12:09:50 2026 +0100

    [lake/iceberg] Support ADD COLUMN schema evolution for Iceberg lake catalog 
(#2593)
    
    * Iceberg Schema Evolution ADD COLUMN
    
    * improve tests by adding rows after alter
    
    ---------
    
    Co-authored-by: ipolyzos <[email protected]>
---
 .../fluss/lake/iceberg/IcebergLakeCatalog.java     | 167 ++++++-
 .../fluss/lake/iceberg/IcebergLakeCatalogTest.java | 523 +++++++++++++++++++++
 .../tiering/IcebergSchemaEvolutionITCase.java      | 422 +++++++++++++++++
 3 files changed, 1092 insertions(+), 20 deletions(-)

diff --git 
a/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java
 
b/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java
index 9bd9d54e6..78a49c3ac 100644
--- 
a/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java
+++ 
b/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java
@@ -19,6 +19,7 @@ package org.apache.fluss.lake.iceberg;
 
 import org.apache.fluss.annotation.VisibleForTesting;
 import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.InvalidAlterTableException;
 import org.apache.fluss.exception.InvalidTableException;
 import org.apache.fluss.exception.TableAlreadyExistException;
 import org.apache.fluss.exception.TableNotExistException;
@@ -36,6 +37,7 @@ import org.apache.iceberg.SortOrder;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.UpdateProperties;
+import org.apache.iceberg.UpdateSchema;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
@@ -44,8 +46,11 @@ import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
 import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.TypeUtil;
 import org.apache.iceberg.types.Types;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -133,35 +138,157 @@ public class IcebergLakeCatalog implements LakeCatalog {
             throws TableNotExistException {
         try {
             Table table = 
icebergCatalog.loadTable(toIcebergTableIdentifier(tablePath));
-            UpdateProperties updateProperties = table.updateProperties();
-            for (TableChange tableChange : tableChanges) {
-                if (tableChange instanceof TableChange.SetOption) {
-                    TableChange.SetOption option = (TableChange.SetOption) 
tableChange;
-                    checkArgument(
-                            !RESERVED_PROPERTIES.contains(option.getKey()),
-                            "Cannot set table property '%s'",
-                            option.getKey());
-                    updateProperties.set(
-                            convertFlussPropertyKeyToIceberg(option.getKey()), 
option.getValue());
-                } else if (tableChange instanceof TableChange.ResetOption) {
-                    TableChange.ResetOption option = (TableChange.ResetOption) 
tableChange;
-                    checkArgument(
-                            !RESERVED_PROPERTIES.contains(option.getKey()),
-                            "Cannot reset table property '%s'",
-                            option.getKey());
-                    
updateProperties.remove(convertFlussPropertyKeyToIceberg(option.getKey()));
+
+            List<TableChange> schemaChanges = new ArrayList<>();
+            List<TableChange> propertyChanges = new ArrayList<>();
+            for (TableChange change : tableChanges) {
+                if (change instanceof TableChange.SchemaChange) {
+                    schemaChanges.add(change);
                 } else {
-                    throw new UnsupportedOperationException(
-                            "Unsupported table change: " + 
tableChange.getClass());
+                    propertyChanges.add(change);
                 }
             }
 
-            updateProperties.commit();
+            if (!schemaChanges.isEmpty()) {
+                applySchemaChanges(table, schemaChanges, context);
+            }
+
+            if (!propertyChanges.isEmpty()) {
+                applyPropertyChanges(table, propertyChanges);
+            }
         } catch (NoSuchTableException e) {
             throw new TableNotExistException("Table " + tablePath + " does not 
exist.");
         }
     }
 
+    private void applyPropertyChanges(Table table, List<TableChange> 
propertyChanges) {
+        UpdateProperties updateProperties = table.updateProperties();
+        for (TableChange tableChange : propertyChanges) {
+            if (tableChange instanceof TableChange.SetOption) {
+                TableChange.SetOption option = (TableChange.SetOption) 
tableChange;
+                checkArgument(
+                        !RESERVED_PROPERTIES.contains(option.getKey()),
+                        "Cannot set table property '%s'",
+                        option.getKey());
+                updateProperties.set(
+                        convertFlussPropertyKeyToIceberg(option.getKey()), 
option.getValue());
+            } else if (tableChange instanceof TableChange.ResetOption) {
+                TableChange.ResetOption option = (TableChange.ResetOption) 
tableChange;
+                checkArgument(
+                        !RESERVED_PROPERTIES.contains(option.getKey()),
+                        "Cannot reset table property '%s'",
+                        option.getKey());
+                
updateProperties.remove(convertFlussPropertyKeyToIceberg(option.getKey()));
+            } else {
+                throw new UnsupportedOperationException(
+                        "Unsupported table change: " + tableChange.getClass());
+            }
+        }
+        updateProperties.commit();
+    }
+
+    private void applySchemaChanges(Table table, List<TableChange> 
schemaChanges, Context context) {
+        Schema currentIcebergSchema = table.schema();
+
+        // Check schema compatibility to handle crash recovery idempotency.
+        boolean skipAddColumns;
+        if (isIcebergSchemaCompatible(currentIcebergSchema, 
context.getCurrentTable())) {
+            // Iceberg schema matches current Fluss schema, apply all changes.
+            skipAddColumns = false;
+        } else if (isIcebergSchemaCompatible(currentIcebergSchema, 
context.getExpectedTable())) {
+            // Iceberg schema already matches expected (post-alter) schema,
+            // skip AddColumn changes since they were already applied.
+            skipAddColumns = true;
+        } else {
+            throw new InvalidAlterTableException(
+                    String.format(
+                            "Iceberg schema is not compatible with Fluss 
schema: "
+                                    + "Iceberg schema: %s, Fluss schema: %s. "
+                                    + "therefore you need to add the diff 
columns all at once, "
+                                    + "rather than applying other table 
changes: %s.",
+                            currentIcebergSchema,
+                            context.getCurrentTable().getSchema(),
+                            schemaChanges));
+        }
+
+        UpdateSchema updateSchema = table.updateSchema();
+        String firstSystemColumnName = 
SYSTEM_COLUMNS.keySet().iterator().next();
+        boolean hasChanges = false;
+
+        for (TableChange tableChange : schemaChanges) {
+            if (tableChange instanceof TableChange.AddColumn) {
+                if (skipAddColumns) {
+                    continue;
+                }
+                TableChange.AddColumn addColumn = (TableChange.AddColumn) 
tableChange;
+
+                if (!(addColumn.getPosition() instanceof TableChange.Last)) {
+                    throw new UnsupportedOperationException(
+                            "Only support to add column at last for iceberg 
table.");
+                }
+
+                org.apache.fluss.types.DataType flussDataType = 
addColumn.getDataType();
+                if (!flussDataType.isNullable()) {
+                    throw new UnsupportedOperationException(
+                            "Only support to add nullable column for iceberg 
table.");
+                }
+
+                Type icebergType = flussDataType.accept(new 
FlussDataTypeToIcebergDataType());
+                updateSchema.addColumn(addColumn.getName(), icebergType, 
addColumn.getComment());
+                updateSchema.moveBefore(addColumn.getName(), 
firstSystemColumnName);
+                hasChanges = true;
+            } else {
+                throw new UnsupportedOperationException(
+                        "Unsupported table change: " + tableChange.getClass());
+            }
+        }
+
+        if (hasChanges) {
+            updateSchema.commit();
+        }
+    }
+
+    /**
+     * Checks whether the current Iceberg schema is compatible with the given 
Fluss table
+     * descriptor. Compatibility means the user columns and system columns 
match in name, type, and
+     * nullability (ignoring Iceberg-assigned field IDs).
+     *
+     * <p>Iceberg reassigns field IDs during table creation, so the IDs in the 
stored schema differ
+     * from those we generate via {@link #convertToIcebergSchema}. We 
normalize both schemas to the
+     * same fresh ID space using {@link TypeUtil#assignIncreasingFreshIds} 
before comparing, so that
+     * {@link Type#equals} works correctly for all types including complex 
ones (Map, List, Struct).
+     */
+    @VisibleForTesting
+    boolean isIcebergSchemaCompatible(
+            Schema icebergSchema, @Nullable TableDescriptor 
flussTableDescriptor) {
+        if (flussTableDescriptor == null) {
+            return false;
+        }
+        // isPkTable=false: identifier fields don't affect the comparison
+        Schema expectedSchema = convertToIcebergSchema(flussTableDescriptor, 
false);
+
+        Schema normalizedIceberg = 
TypeUtil.assignIncreasingFreshIds(icebergSchema);
+        Schema normalizedExpected = 
TypeUtil.assignIncreasingFreshIds(expectedSchema);
+
+        List<Types.NestedField> currentFields = normalizedIceberg.columns();
+        List<Types.NestedField> expectedFields = normalizedExpected.columns();
+
+        if (currentFields.size() != expectedFields.size()) {
+            return false;
+        }
+
+        for (int i = 0; i < currentFields.size(); i++) {
+            Types.NestedField current = currentFields.get(i);
+            Types.NestedField expected = expectedFields.get(i);
+            if (!current.name().equals(expected.name())
+                    || !current.type().equals(expected.type())
+                    || current.isOptional() != expected.isOptional()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private TableIdentifier toIcebergTableIdentifier(TablePath tablePath) {
         return TableIdentifier.of(tablePath.getDatabaseName(), 
tablePath.getTableName());
     }
diff --git 
a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java
 
b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java
index 0625f1506..5bae68d3c 100644
--- 
a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java
+++ 
b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java
@@ -19,6 +19,7 @@ package org.apache.fluss.lake.iceberg;
 
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.InvalidAlterTableException;
 import org.apache.fluss.exception.InvalidTableException;
 import org.apache.fluss.exception.TableNotExistException;
 import org.apache.fluss.lake.lakestorage.TestingLakeCatalogContext;
@@ -26,6 +27,7 @@ import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.TableChange;
 import org.apache.fluss.metadata.TableDescriptor;
 import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.server.coordinator.SchemaUpdate;
 import org.apache.fluss.types.DataTypes;
 
 import org.apache.iceberg.PartitionField;
@@ -50,6 +52,7 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 import static org.apache.fluss.metadata.TableDescriptor.BUCKET_COLUMN_NAME;
 import static org.apache.fluss.metadata.TableDescriptor.OFFSET_COLUMN_NAME;
@@ -60,6 +63,14 @@ import static 
org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
 /** Unit test for {@link IcebergLakeCatalog}. */
 class IcebergLakeCatalogTest {
 
+    private static final Schema FLUSS_SCHEMA =
+            Schema.newBuilder()
+                    .column("id", DataTypes.BIGINT())
+                    .column("name", DataTypes.STRING())
+                    .column("amount", DataTypes.INT())
+                    .column("address", DataTypes.STRING())
+                    .build();
+
     @TempDir private File tempWarehouseDir;
 
     private IcebergLakeCatalog flussIcebergCatalog;
@@ -624,4 +635,516 @@ class IcebergLakeCatalogTest {
                     .hasMessage("Cannot reset table property '%s'", property);
         }
     }
+
+    @Test
+    void testAlterTableAddColumnLastNullable() {
+        String database = "test_alter_add_col_db";
+        String tableName = "test_alter_add_col_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_col",
+                                DataTypes.INT(),
+                                "new_col comment",
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        List<String> fieldNames =
+                table.schema().columns().stream()
+                        .map(Types.NestedField::name)
+                        .collect(Collectors.toList());
+        assertThat(fieldNames)
+                .containsExactly(
+                        "id",
+                        "name",
+                        "amount",
+                        "address",
+                        "new_col",
+                        BUCKET_COLUMN_NAME,
+                        OFFSET_COLUMN_NAME,
+                        TIMESTAMP_COLUMN_NAME);
+
+        // Verify the new column's type and nullability
+        Types.NestedField newCol = table.schema().findField("new_col");
+        assertThat(newCol.type()).isEqualTo(Types.IntegerType.get());
+        assertThat(newCol.isOptional()).isTrue();
+        assertThat(newCol.doc()).isEqualTo("new_col comment");
+    }
+
+    @Test
+    void testAlterTableAddColumnNotLast() {
+        String database = "test_alter_add_col_not_last_db";
+        String tableName = "test_alter_add_col_not_last_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_col",
+                                DataTypes.INT(),
+                                null,
+                                TableChange.ColumnPosition.first()));
+
+        // Use a simple context since SchemaUpdate rejects non-LAST position
+        TableDescriptor td = getTableDescriptor(FLUSS_SCHEMA);
+        assertThatThrownBy(
+                        () ->
+                                flussIcebergCatalog.alterTable(
+                                        tablePath, changes, new 
TestingLakeCatalogContext(td)))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessage("Only support to add column at last for iceberg 
table.");
+    }
+
+    @Test
+    void testAlterTableAddColumnNotNullable() {
+        String database = "test_alter_add_col_not_nullable_db";
+        String tableName = "test_alter_add_col_not_nullable_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_col",
+                                DataTypes.INT().copy(false),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        // Use a simple context since SchemaUpdate rejects non-nullable columns
+        TableDescriptor td = getTableDescriptor(FLUSS_SCHEMA);
+        assertThatThrownBy(
+                        () ->
+                                flussIcebergCatalog.alterTable(
+                                        tablePath, changes, new 
TestingLakeCatalogContext(td)))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessage("Only support to add nullable column for iceberg 
table.");
+    }
+
+    @Test
+    void testAlterTableAddExistingColumns() {
+        String database = "test_alter_add_existing_col_db";
+        String tableName = "test_alter_add_existing_col_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        // Simulate crash recovery: add columns to iceberg first, then retry
+        List<TableChange> changes =
+                Arrays.asList(
+                        TableChange.addColumn(
+                                "new_column",
+                                DataTypes.INT(),
+                                null,
+                                TableChange.ColumnPosition.last()),
+                        TableChange.addColumn(
+                                "new_column2",
+                                DataTypes.STRING(),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        // First call succeeds - adds columns to iceberg
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        // Second call with same changes should succeed (idempotent via schema 
compatibility)
+        // because iceberg schema now matches the expected schema
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+    }
+
+    @Test
+    void testAlterTableAddColumnWhenIcebergSchemaNotMatch() {
+        String database = "test_alter_add_col_schema_mismatch_db";
+        String tableName = "test_alter_add_col_schema_mismatch_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_col",
+                                DataTypes.INT(),
+                                "new_col comment",
+                                TableChange.ColumnPosition.last()));
+
+        // Test column number mismatch: pass a wider Fluss schema that doesn't 
match iceberg
+        Schema widerFlussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("name", DataTypes.STRING())
+                        .column("amount", DataTypes.INT())
+                        .column("address", DataTypes.STRING())
+                        .column("phone", DataTypes.INT())
+                        .build();
+        assertThatThrownBy(
+                        () ->
+                                flussIcebergCatalog.alterTable(
+                                        tablePath,
+                                        changes,
+                                        
getLakeCatalogContext(widerFlussSchema, changes)))
+                .isInstanceOf(InvalidAlterTableException.class)
+                .hasMessageContaining("Iceberg schema is not compatible with 
Fluss schema");
+
+        // Test column order mismatch
+        Schema disorderFlussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("amount", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .column("address", DataTypes.STRING())
+                        .build();
+        assertThatThrownBy(
+                        () ->
+                                flussIcebergCatalog.alterTable(
+                                        tablePath,
+                                        changes,
+                                        
getLakeCatalogContext(disorderFlussSchema, changes)))
+                .isInstanceOf(InvalidAlterTableException.class)
+                .hasMessageContaining("Iceberg schema is not compatible with 
Fluss schema");
+    }
+
+    @Test
+    void testAlterTableAddColumnWithComplexTypeTable() {
+        String database = "test_alter_add_col_complex_db";
+        String tableName = "test_alter_add_col_complex_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+
+        // Create table with complex types (Map, Array)
+        Schema complexSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("tags", DataTypes.ARRAY(DataTypes.STRING()))
+                        .column("metadata", DataTypes.MAP(DataTypes.STRING(), 
DataTypes.INT()))
+                        .build();
+
+        TableDescriptor td = getTableDescriptor(complexSchema);
+        flussIcebergCatalog.createTable(tablePath, td, new 
TestingLakeCatalogContext());
+
+        // ADD COLUMN should succeed despite Iceberg reassigning field IDs for 
complex types
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_col",
+                                DataTypes.STRING(),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(complexSchema, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        List<String> fieldNames =
+                table.schema().columns().stream()
+                        .map(Types.NestedField::name)
+                        .collect(Collectors.toList());
+        assertThat(fieldNames)
+                .containsExactly(
+                        "id",
+                        "tags",
+                        "metadata",
+                        "new_col",
+                        BUCKET_COLUMN_NAME,
+                        OFFSET_COLUMN_NAME,
+                        TIMESTAMP_COLUMN_NAME);
+
+        // Verify the new column
+        Types.NestedField newCol = table.schema().findField("new_col");
+        assertThat(newCol.type()).isEqualTo(Types.StringType.get());
+        assertThat(newCol.isOptional()).isTrue();
+    }
+
+    @Test
+    void testAlterTableAddColumnIdempotencyWithComplexTypes() {
+        String database = "test_alter_idempotent_complex_db";
+        String tableName = "test_alter_idempotent_complex_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+
+        Schema complexSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("data", DataTypes.MAP(DataTypes.STRING(), 
DataTypes.STRING()))
+                        .build();
+
+        TableDescriptor td = getTableDescriptor(complexSchema);
+        flussIcebergCatalog.createTable(tablePath, td, new 
TestingLakeCatalogContext());
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_col",
+                                DataTypes.INT(),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        // First call succeeds
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(complexSchema, 
changes));
+
+        // Second call (crash recovery) should succeed via idempotency check
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(complexSchema, 
changes));
+    }
+
+    @Test
+    void testAlterTableAddArrayColumn() {
+        String database = "test_alter_add_array_db";
+        String tableName = "test_alter_add_array_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_arr",
+                                DataTypes.ARRAY(DataTypes.STRING()),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        Types.NestedField field = table.schema().findField("new_arr");
+        assertThat(field).isNotNull();
+        assertThat(field.isOptional()).isTrue();
+        assertThat(field.type().isListType()).isTrue();
+        Types.ListType listType = field.type().asListType();
+        assertThat(listType.elementType()).isEqualTo(Types.StringType.get());
+        assertThat(listType.elementId()).isNotEqualTo(field.fieldId());
+        assertThat(table.schema().columns())
+                .extracting(Types.NestedField::name)
+                .containsSubsequence("new_arr", BUCKET_COLUMN_NAME);
+    }
+
+    @Test
+    void testAlterTableAddMapColumn() {
+        String database = "test_alter_add_map_db";
+        String tableName = "test_alter_add_map_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_map",
+                                DataTypes.MAP(DataTypes.STRING(), 
DataTypes.INT()),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        Types.NestedField field = table.schema().findField("new_map");
+        assertThat(field.type().isMapType()).isTrue();
+        Types.MapType mapType = field.type().asMapType();
+        assertThat(mapType.keyType()).isEqualTo(Types.StringType.get());
+        assertThat(mapType.valueType()).isEqualTo(Types.IntegerType.get());
+        assertThat(mapType.keyId()).isNotEqualTo(mapType.valueId());
+        assertThat(mapType.keyId()).isNotEqualTo(field.fieldId());
+        assertThat(mapType.valueId()).isNotEqualTo(field.fieldId());
+    }
+
+    @Test
+    void testAlterTableAddRowColumn() {
+        String database = "test_alter_add_row_db";
+        String tableName = "test_alter_add_row_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_row",
+                                DataTypes.ROW(
+                                        DataTypes.FIELD("a", DataTypes.INT()),
+                                        DataTypes.FIELD("b", 
DataTypes.STRING())),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        Types.NestedField field = table.schema().findField("new_row");
+        assertThat(field.type().isStructType()).isTrue();
+        Types.StructType struct = field.type().asStructType();
+        
assertThat(struct.fields()).extracting(Types.NestedField::name).containsExactly("a",
 "b");
+        
assertThat(struct.field("a").type()).isEqualTo(Types.IntegerType.get());
+        assertThat(struct.field("b").type()).isEqualTo(Types.StringType.get());
+        
assertThat(struct.field("a").fieldId()).isNotEqualTo(struct.field("b").fieldId());
+        assertThat(struct.field("a").fieldId()).isNotEqualTo(field.fieldId());
+    }
+
+    @Test
+    void testAlterTableAddNestedComplexColumn() {
+        String database = "test_alter_add_nested_db";
+        String tableName = "test_alter_add_nested_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_nested",
+                                DataTypes.ARRAY(
+                                        DataTypes.ROW(
+                                                DataTypes.FIELD("x", 
DataTypes.INT()),
+                                                DataTypes.FIELD("y", 
DataTypes.STRING()))),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        Types.NestedField field = table.schema().findField("new_nested");
+        Types.ListType listType = field.type().asListType();
+        Types.StructType struct = listType.elementType().asStructType();
+        
assertThat(struct.fields()).extracting(Types.NestedField::name).containsExactly("x",
 "y");
+
+        Set<Integer> ids = new HashSet<>();
+        ids.add(field.fieldId());
+        ids.add(listType.elementId());
+        ids.add(struct.field("x").fieldId());
+        ids.add(struct.field("y").fieldId());
+        assertThat(ids).hasSize(4);
+    }
+
+    @Test
+    void testAlterTableAddDecimalColumnPreservesPrecisionScale() {
+        String database = "test_alter_add_decimal_db";
+        String tableName = "test_alter_add_decimal_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_dec",
+                                DataTypes.DECIMAL(20, 5),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        assertThat(table.schema().findField("new_dec").type())
+                .isEqualTo(Types.DecimalType.of(20, 5));
+    }
+
+    @Test
+    void testAlterTableAddTimestampVariants() {
+        String database = "test_alter_add_ts_db";
+        String tableName = "test_alter_add_ts_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+        createLogTable(database, tableName);
+
+        List<TableChange> changes =
+                Arrays.asList(
+                        TableChange.addColumn(
+                                "new_ts",
+                                DataTypes.TIMESTAMP(6),
+                                null,
+                                TableChange.ColumnPosition.last()),
+                        TableChange.addColumn(
+                                "new_ts_ltz",
+                                DataTypes.TIMESTAMP_LTZ(6),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        flussIcebergCatalog.alterTable(
+                tablePath, changes, getLakeCatalogContext(FLUSS_SCHEMA, 
changes));
+
+        Table table =
+                flussIcebergCatalog
+                        .getIcebergCatalog()
+                        .loadTable(TableIdentifier.of(database, tableName));
+        assertThat(table.schema().findField("new_ts").type())
+                .isEqualTo(Types.TimestampType.withoutZone());
+        assertThat(table.schema().findField("new_ts_ltz").type())
+                .isEqualTo(Types.TimestampType.withZone());
+    }
+
+    @Test
+    void testAlterTableComplexTypeMismatch() {
+        String database = "test_alter_complex_mismatch_db";
+        String tableName = "test_alter_complex_mismatch_table";
+        TablePath tablePath = TablePath.of(database, tableName);
+
+        Schema actualSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("tags", DataTypes.ARRAY(DataTypes.STRING()))
+                        .build();
+        flussIcebergCatalog.createTable(
+                tablePath, getTableDescriptor(actualSchema), new 
TestingLakeCatalogContext());
+
+        Schema mismatched =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("tags", DataTypes.ARRAY(DataTypes.INT()))
+                        .build();
+        List<TableChange> changes =
+                Collections.singletonList(
+                        TableChange.addColumn(
+                                "new_col",
+                                DataTypes.STRING(),
+                                null,
+                                TableChange.ColumnPosition.last()));
+
+        assertThatThrownBy(
+                        () ->
+                                flussIcebergCatalog.alterTable(
+                                        tablePath,
+                                        changes,
+                                        getLakeCatalogContext(mismatched, 
changes)))
+                .isInstanceOf(InvalidAlterTableException.class)
+                .hasMessageContaining("Iceberg schema is not compatible with 
Fluss schema");
+    }
+
+    private void createLogTable(String database, String tableName) {
+        TableDescriptor td = getTableDescriptor(FLUSS_SCHEMA);
+        TablePath tablePath = TablePath.of(database, tableName);
+        flussIcebergCatalog.createTable(tablePath, td, new 
TestingLakeCatalogContext());
+    }
+
+    private TestingLakeCatalogContext getLakeCatalogContext(
+            Schema schema, List<TableChange> schemaChanges) {
+        Schema expectedSchema = SchemaUpdate.applySchemaChanges(schema, 
schemaChanges);
+        return new TestingLakeCatalogContext(
+                getTableDescriptor(schema), 
getTableDescriptor(expectedSchema));
+    }
+
+    private TableDescriptor getTableDescriptor(Schema schema) {
+        return 
TableDescriptor.builder().schema(schema).distributedBy(3).build();
+    }
 }
diff --git 
a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergSchemaEvolutionITCase.java
 
b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergSchemaEvolutionITCase.java
new file mode 100644
index 000000000..d4177a305
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergSchemaEvolutionITCase.java
@@ -0,0 +1,422 @@
+/*
+ * 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.fluss.lake.iceberg.tiering;
+
+import org.apache.fluss.lake.iceberg.testutils.FlinkIcebergTieringTestBase;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableChange;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.types.DataTypes;
+
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.apache.fluss.lake.iceberg.utils.IcebergConversions.toIceberg;
+import static org.apache.fluss.metadata.TableDescriptor.BUCKET_COLUMN_NAME;
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** ITCase for schema evolution (ADD COLUMN) with Iceberg tiering. */
+class IcebergSchemaEvolutionITCase extends FlinkIcebergTieringTestBase {
+
+    private static final String DEFAULT_DB = "fluss";
+
+    private static StreamExecutionEnvironment execEnv;
+
+    private static final Schema INITIAL_LOG_SCHEMA =
+            Schema.newBuilder()
+                    .column("f_int", DataTypes.INT())
+                    .column("f_str", DataTypes.STRING())
+                    .build();
+
+    private static final Schema INITIAL_PK_SCHEMA =
+            Schema.newBuilder()
+                    .column("f_int", DataTypes.INT())
+                    .column("f_str", DataTypes.STRING())
+                    .primaryKey("f_int")
+                    .build();
+
+    private static final Schema COMPLEX_LOG_SCHEMA =
+            Schema.newBuilder()
+                    .column("f_int", DataTypes.INT())
+                    .column("f_str", DataTypes.STRING())
+                    .column("f_tags", DataTypes.ARRAY(DataTypes.STRING()))
+                    .column("f_meta", DataTypes.MAP(DataTypes.STRING(), 
DataTypes.INT()))
+                    .build();
+
+    @BeforeAll
+    protected static void beforeAll() {
+        FlinkIcebergTieringTestBase.beforeAll();
+        execEnv = StreamExecutionEnvironment.getExecutionEnvironment();
+        execEnv.setParallelism(2);
+        execEnv.enableCheckpointing(1000);
+    }
+
+    @Test
+    void testSchemaEvolutionLogTable() throws Exception {
+        TablePath tablePath = TablePath.of(DEFAULT_DB, "schemaEvoLogTable");
+        long tableId = createLogTable(tablePath, 1, false, INITIAL_LOG_SCHEMA);
+        TableBucket bucket = new TableBucket(tableId, 0);
+
+        // Write initial rows and start tiering
+        List<InternalRow> initialRows = Arrays.asList(row(1, "v1"), row(2, 
"v2"), row(3, "v3"));
+        writeRows(tablePath, initialRows, true);
+
+        JobClient jobClient = buildTieringJob(execEnv);
+        try {
+            // Wait until initial data is tiered
+            assertReplicaStatus(bucket, 3);
+            checkDataInIcebergAppendOnlyTable(tablePath, initialRows, 0);
+
+            // Verify initial Iceberg schema has no extra columns
+            org.apache.iceberg.Table icebergTable = 
icebergCatalog.loadTable(toIceberg(tablePath));
+            assertThat(icebergTable.schema().findField("f_new")).isNull();
+
+            // ALTER TABLE ADD COLUMN via Admin API
+            admin.alterTable(
+                            tablePath,
+                            Collections.singletonList(
+                                    TableChange.addColumn(
+                                            "f_new",
+                                            DataTypes.STRING(),
+                                            null,
+                                            
TableChange.ColumnPosition.last())),
+                            false)
+                    .get();
+
+            // Verify the Iceberg schema now has the new column before system 
columns
+            icebergTable.refresh();
+            Types.NestedField newField = 
icebergTable.schema().findField("f_new");
+            assertThat(newField).isNotNull();
+            assertThat(newField.type()).isEqualTo(Types.StringType.get());
+            assertThat(newField.isOptional()).isTrue();
+
+            // Verify column ordering: new column should be before __bucket
+            List<String> fieldNames =
+                    icebergTable.schema().columns().stream()
+                            .map(Types.NestedField::name)
+                            .collect(Collectors.toList());
+            assertThat(fieldNames.indexOf("f_new"))
+                    .isLessThan(fieldNames.indexOf(BUCKET_COLUMN_NAME));
+
+            // Post-ALTER rows: new rows carry f_new values; old rows must 
surface NULL
+            // via Iceberg field-ID schema evolution.
+            List<InternalRow> postAlterRows =
+                    Arrays.asList(row(4, "v4", "newA"), row(5, "v5", "newB"));
+            writeRows(tablePath, postAlterRows, true);
+            assertReplicaStatus(bucket, 5);
+
+            icebergTable.refresh();
+            List<Record> records = getIcebergRecords(tablePath);
+            assertThat(records).hasSize(5);
+            Map<Integer, Object> fNewByFInt = new HashMap<>();
+            for (Record record : records) {
+                fNewByFInt.put((Integer) record.getField("f_int"), 
record.getField("f_new"));
+            }
+            assertThat(fNewByFInt.get(1)).isNull();
+            assertThat(fNewByFInt.get(2)).isNull();
+            assertThat(fNewByFInt.get(3)).isNull();
+            assertThat(fNewByFInt.get(4)).isEqualTo("newA");
+            assertThat(fNewByFInt.get(5)).isEqualTo("newB");
+        } finally {
+            jobClient.cancel().get();
+        }
+    }
+
+    @Test
+    void testSchemaEvolutionPkTable() throws Exception {
+        TablePath tablePath = TablePath.of(DEFAULT_DB, "schemaEvoPkTable");
+        long tableId = createPkTable(tablePath, 1, false, INITIAL_PK_SCHEMA);
+        TableBucket bucket = new TableBucket(tableId, 0);
+
+        // Write initial rows and trigger snapshot
+        List<InternalRow> initialRows = Arrays.asList(row(1, "v1"), row(2, 
"v2"), row(3, "v3"));
+        writeRows(tablePath, initialRows, false);
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tablePath);
+
+        JobClient jobClient = buildTieringJob(execEnv);
+        try {
+            // Wait until initial data is tiered
+            assertReplicaStatus(bucket, 3);
+
+            // Verify initial data in Iceberg
+            List<Record> records = getIcebergRecords(tablePath);
+            assertThat(records).hasSize(3);
+
+            // ALTER TABLE ADD COLUMN
+            admin.alterTable(
+                            tablePath,
+                            Collections.singletonList(
+                                    TableChange.addColumn(
+                                            "f_new",
+                                            DataTypes.INT(),
+                                            "new column",
+                                            
TableChange.ColumnPosition.last())),
+                            false)
+                    .get();
+
+            // Verify the Iceberg schema now has the new column
+            org.apache.iceberg.Table icebergTable = 
icebergCatalog.loadTable(toIceberg(tablePath));
+            Types.NestedField newField = 
icebergTable.schema().findField("f_new");
+            assertThat(newField).isNotNull();
+            assertThat(newField.type()).isEqualTo(Types.IntegerType.get());
+            assertThat(newField.isOptional()).isTrue();
+            assertThat(newField.doc()).isEqualTo("new column");
+
+            // Verify column ordering
+            List<String> fieldNames =
+                    icebergTable.schema().columns().stream()
+                            .map(Types.NestedField::name)
+                            .collect(Collectors.toList());
+            assertThat(fieldNames.indexOf("f_new"))
+                    .isLessThan(fieldNames.indexOf(BUCKET_COLUMN_NAME));
+
+            // Post-ALTER upserts + snapshot trigger; verify all rows tier.
+            List<InternalRow> postAlterRows = Arrays.asList(row(4, "v4", 100), 
row(5, "v5", 200));
+            writeRows(tablePath, postAlterRows, false);
+            FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tablePath);
+            assertReplicaStatus(bucket, 5);
+
+            icebergTable.refresh();
+            List<Record> postAlterRecords = getIcebergRecords(tablePath);
+            assertThat(postAlterRecords).hasSize(5);
+            Map<Integer, Object> fNewByFInt = new HashMap<>();
+            for (Record record : postAlterRecords) {
+                fNewByFInt.put((Integer) record.getField("f_int"), 
record.getField("f_new"));
+            }
+            assertThat(fNewByFInt.get(1)).isNull();
+            assertThat(fNewByFInt.get(2)).isNull();
+            assertThat(fNewByFInt.get(3)).isNull();
+            assertThat(fNewByFInt.get(4)).isEqualTo(100);
+            assertThat(fNewByFInt.get(5)).isEqualTo(200);
+        } finally {
+            jobClient.cancel().get();
+        }
+    }
+
+    @Test
+    void testSchemaEvolutionLogTableWithComplexTypes() throws Exception {
+        TablePath tablePath = TablePath.of(DEFAULT_DB, 
"schemaEvoComplexLogTable");
+        long tableId = createLogTable(tablePath, 1, false, COMPLEX_LOG_SCHEMA);
+        TableBucket bucket = new TableBucket(tableId, 0);
+
+        // Write initial rows (nulls for complex type columns)
+        List<InternalRow> initialRows =
+                Arrays.asList(
+                        row(1, "v1", null, null),
+                        row(2, "v2", null, null),
+                        row(3, "v3", null, null));
+        writeRows(tablePath, initialRows, true);
+
+        JobClient jobClient = buildTieringJob(execEnv);
+        try {
+            // Wait until initial data is tiered
+            assertReplicaStatus(bucket, 3);
+
+            // Verify initial Iceberg schema has complex type columns
+            org.apache.iceberg.Table icebergTable = 
icebergCatalog.loadTable(toIceberg(tablePath));
+            
assertThat(icebergTable.schema().findField("f_tags").type().isListType()).isTrue();
+            
assertThat(icebergTable.schema().findField("f_meta").type().isMapType()).isTrue();
+
+            // ALTER TABLE ADD COLUMN — this exercises the compatibility check
+            // with complex types whose field IDs were reassigned by Iceberg
+            admin.alterTable(
+                            tablePath,
+                            Collections.singletonList(
+                                    TableChange.addColumn(
+                                            "f_new",
+                                            DataTypes.STRING(),
+                                            null,
+                                            
TableChange.ColumnPosition.last())),
+                            false)
+                    .get();
+
+            // Verify the Iceberg schema now has the new column
+            icebergTable.refresh();
+            Types.NestedField newField = 
icebergTable.schema().findField("f_new");
+            assertThat(newField).isNotNull();
+            assertThat(newField.type()).isEqualTo(Types.StringType.get());
+            assertThat(newField.isOptional()).isTrue();
+
+            // Verify column ordering: new column before system columns
+            List<String> fieldNames =
+                    icebergTable.schema().columns().stream()
+                            .map(Types.NestedField::name)
+                            .collect(Collectors.toList());
+            assertThat(fieldNames.indexOf("f_new"))
+                    .isLessThan(fieldNames.indexOf(BUCKET_COLUMN_NAME));
+
+            
assertThat(icebergTable.schema().findField("f_tags").type().isListType()).isTrue();
+            
assertThat(icebergTable.schema().findField("f_meta").type().isMapType()).isTrue();
+
+            // Post-ALTER rows alongside pre-existing complex columns.
+            List<InternalRow> postAlterRows =
+                    Arrays.asList(
+                            row(4, "v4", null, null, "newA"), row(5, "v5", 
null, null, "newB"));
+            writeRows(tablePath, postAlterRows, true);
+            assertReplicaStatus(bucket, 5);
+
+            icebergTable.refresh();
+            List<Record> records = getIcebergRecords(tablePath);
+            assertThat(records).hasSize(5);
+            Map<Integer, Object> fNewByFInt = new HashMap<>();
+            for (Record record : records) {
+                fNewByFInt.put((Integer) record.getField("f_int"), 
record.getField("f_new"));
+            }
+            assertThat(fNewByFInt.get(1)).isNull();
+            assertThat(fNewByFInt.get(2)).isNull();
+            assertThat(fNewByFInt.get(3)).isNull();
+            assertThat(fNewByFInt.get(4)).isEqualTo("newA");
+            assertThat(fNewByFInt.get(5)).isEqualTo("newB");
+        } finally {
+            jobClient.cancel().get();
+        }
+    }
+
+    @Test
+    void testSchemaEvolutionLogTableAddComplexColumn() throws Exception {
+        TablePath tablePath = TablePath.of(DEFAULT_DB, 
"schemaEvoLogAddComplex");
+        long tableId = createLogTable(tablePath, 1, false, INITIAL_LOG_SCHEMA);
+        TableBucket bucket = new TableBucket(tableId, 0);
+        List<InternalRow> initialRows = Arrays.asList(row(1, "v1"), row(2, 
"v2"), row(3, "v3"));
+        writeRows(tablePath, initialRows, true);
+
+        JobClient jobClient = buildTieringJob(execEnv);
+        try {
+            assertReplicaStatus(bucket, 3);
+            admin.alterTable(
+                            tablePath,
+                            Collections.singletonList(
+                                    TableChange.addColumn(
+                                            "f_tags",
+                                            
DataTypes.ARRAY(DataTypes.STRING()),
+                                            null,
+                                            
TableChange.ColumnPosition.last())),
+                            false)
+                    .get();
+
+            org.apache.iceberg.Table icebergTable = 
icebergCatalog.loadTable(toIceberg(tablePath));
+            Types.NestedField added = 
icebergTable.schema().findField("f_tags");
+            assertThat(added).isNotNull();
+            assertThat(added.type().isListType()).isTrue();
+            
assertThat(added.type().asListType().elementType()).isEqualTo(Types.StringType.get());
+            List<String> fieldNames =
+                    icebergTable.schema().columns().stream()
+                            .map(Types.NestedField::name)
+                            .collect(Collectors.toList());
+            assertThat(fieldNames.indexOf("f_tags"))
+                    .isLessThan(fieldNames.indexOf(BUCKET_COLUMN_NAME));
+
+            // Post-ALTER rows for a complex new column. Writing null 
exercises the
+            // data-plane path through the new ARRAY field ID without forcing 
the test
+            // to build an InternalArray literal.
+            List<InternalRow> postAlterRows = Arrays.asList(row(4, "v4", 
null), row(5, "v5", null));
+            writeRows(tablePath, postAlterRows, true);
+            assertReplicaStatus(bucket, 5);
+
+            icebergTable.refresh();
+            List<Record> records = getIcebergRecords(tablePath);
+            assertThat(records).hasSize(5);
+            for (Record record : records) {
+                assertThat(record.getField("f_tags")).isNull();
+            }
+        } finally {
+            jobClient.cancel().get();
+        }
+    }
+
+    @Test
+    void testSchemaEvolutionPkTableWithComplexPreExisting() throws Exception {
+        TablePath tablePath = TablePath.of(DEFAULT_DB, "schemaEvoPkComplex");
+        Schema pkComplexSchema =
+                Schema.newBuilder()
+                        .column("f_int", DataTypes.INT())
+                        .column("f_str", DataTypes.STRING())
+                        .column("f_tags", DataTypes.ARRAY(DataTypes.STRING()))
+                        .primaryKey("f_int")
+                        .build();
+        long tableId = createPkTable(tablePath, 1, false, pkComplexSchema);
+        TableBucket bucket = new TableBucket(tableId, 0);
+
+        List<InternalRow> initialRows =
+                Arrays.asList(row(1, "v1", null), row(2, "v2", null), row(3, 
"v3", null));
+        writeRows(tablePath, initialRows, false);
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tablePath);
+
+        JobClient jobClient = buildTieringJob(execEnv);
+        try {
+            assertReplicaStatus(bucket, 3);
+            admin.alterTable(
+                            tablePath,
+                            Collections.singletonList(
+                                    TableChange.addColumn(
+                                            "f_new",
+                                            DataTypes.INT(),
+                                            null,
+                                            
TableChange.ColumnPosition.last())),
+                            false)
+                    .get();
+
+            org.apache.iceberg.Table icebergTable = 
icebergCatalog.loadTable(toIceberg(tablePath));
+            assertThat(icebergTable.schema().findField("f_new").type())
+                    .isEqualTo(Types.IntegerType.get());
+            
assertThat(icebergTable.schema().findField("f_tags").type().isListType()).isTrue();
+            List<String> fieldNames =
+                    icebergTable.schema().columns().stream()
+                            .map(Types.NestedField::name)
+                            .collect(Collectors.toList());
+            assertThat(fieldNames.indexOf("f_new"))
+                    .isLessThan(fieldNames.indexOf(BUCKET_COLUMN_NAME));
+
+            // Post-ALTER upserts with f_new values + snapshot trigger.
+            List<InternalRow> postAlterRows =
+                    Arrays.asList(row(4, "v4", null, 100), row(5, "v5", null, 
200));
+            writeRows(tablePath, postAlterRows, false);
+            FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tablePath);
+            assertReplicaStatus(bucket, 5);
+
+            icebergTable.refresh();
+            List<Record> records = getIcebergRecords(tablePath);
+            assertThat(records).hasSize(5);
+            Map<Integer, Object> fNewByFInt = new HashMap<>();
+            for (Record record : records) {
+                fNewByFInt.put((Integer) record.getField("f_int"), 
record.getField("f_new"));
+            }
+            assertThat(fNewByFInt.get(1)).isNull();
+            assertThat(fNewByFInt.get(2)).isNull();
+            assertThat(fNewByFInt.get(3)).isNull();
+            assertThat(fNewByFInt.get(4)).isEqualTo(100);
+            assertThat(fNewByFInt.get(5)).isEqualTo(200);
+        } finally {
+            jobClient.cancel().get();
+        }
+    }
+}

Reply via email to