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

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 87156eb714c [fix](fe) Reject Iceberg v3 row lineage names in ALTER ADD 
and RENAME COLUMN (#69471)
87156eb714c is described below

commit 87156eb714c19761f41e0d5ef2aff2c55cd5f915
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Oct 23 09:04:21 2026 +0100

    [fix](fe) Reject Iceberg v3 row lineage names in ALTER ADD and RENAME 
COLUMN (#69471)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #65329, #64304
    
    Problem Summary:
    
    **In short.** This restores a rule that #65329 introduced and that
    branch-4.1 and branch-4.2 still follow: on an Iceberg format-version 3
    table, `ALTER TABLE ... ADD COLUMN` and `RENAME COLUMN` may not name a
    top-level column `_row_id` or `_last_updated_sequence_number`. Master
    lost the ALTER half of the rule when #64304 moved Iceberg DDL into the
    connector plugin.
    
    **Background.** Doris exposes the row lineage of a format-version 3
    table as two hidden columns, `_row_id` and
    `_last_updated_sequence_number`, next to the table's own columns. #65329
    (merged 07-23) rejected those names for the columns of a v3 table, in
    CREATE TABLE and in ALTER TABLE. #64304 (merged 07-30) was developed in
    parallel and rewrote Iceberg DDL in `fe-connector-iceberg`. It kept the
    CREATE TABLE check
    (`IcebergConnectorMetadata.rejectReservedRowLineageColumns`), but ALTER
    only checks `_file` and `_pos`. Iceberg itself does not check the names
    in a schema update.
    
    **The problem, and what it cost.** On master these statements commit on
    a v3 table. Doris then appends the hidden column to a schema that
    already has a column with the same name. With the default metadata
    cache, `DESC` and every query on the table fail with `Duplicate column
    name found: _row_id`. With the table cache disabled, the user column
    hides the row lineage column instead. Spark cannot read such a table
    either: it fails with `Table column names conflict with names reserved
    for Iceberg metadata columns`.
    
    | On a v3 table | Master today | After this PR (same as branch-4.2) |
    |---|---|---|
    | `ADD COLUMN _row_id BIGINT` | commits; the table can no longer be read
    | `Cannot add Iceberg v3 reserved row lineage column: _row_id` |
    | `ADD COLUMN (extra INT, _LAST_UPDATED_SEQUENCE_NUMBER BIGINT)` |
    commits both columns | rejected; nothing is committed |
    | `RENAME COLUMN c _last_updated_sequence_number` | commits | `Cannot
    rename to Iceberg v3 reserved row lineage column:
    _last_updated_sequence_number` |
    | the same names on a v2 table, or on a nested field such as `s._row_id`
    | allowed | allowed |
    
    **How this PR fixes it.** `CatalogBackedIcebergCatalogOps.addColumn`,
    `addColumns` and `renameColumn` call a new
    `IcebergWriterHelper.validateNotRowLineageColumn` after they load the
    table, next to the existing VARIANT format check, so the rule reads the
    table's real format version. The check ignores case and takes the names
    from Iceberg's `MetadataColumns`. The messages are the ones branch-4.2
    uses.
    
    One difference from branch-4.2 is deliberate. branch-4.2 also rejects
    dropping such a column or renaming it away. But Iceberg lets such a
    column exist on a v3 table: Spark adds one with `ADD COLUMN`, and a v2
    table that has one can be upgraded to v3. After that neither Spark nor
    Doris can read the table, and dropping the column or renaming it away is
    how it becomes readable again. This PR keeps both allowed. `MODIFY
    COLUMN`, a column comment or a reorder that names `_row_id` already
    fails on master, because the row lineage columns are not in the Iceberg
    schema.
    
    **Results.**
    
    - Reproduced on a local cluster with a `file://` v3 table. Before: `ADD
    COLUMN _row_id BIGINT` and `RENAME COLUMN c
    _last_updated_sequence_number` commit, and `DESC` then fails with
    `Duplicate column name found: _row_id`. After: both fail with the
    messages above, and the table stays readable. A table that Spark
    upgraded from v2 with a `_row_id` column becomes readable in Doris and
    in Spark after `DROP COLUMN _row_id` in Doris, and so does one where
    Spark added the column, after a rename.
    - New tests in `CatalogBackedIcebergCatalogOpsColumnEvolutionTest` run
    against a real in-memory Iceberg catalog. On a v3 table the three
    statements fail and leave the schema unchanged. v2 tables and nested
    fields may use the names. An existing column with such a name can be
    dropped or renamed away. The first test fails without this change. All
    1640 tests of fe-connector-iceberg pass.
    - `test_iceberg_v3_row_lineage_query_insert` now runs the three
    statements on its v3 table, for Parquet and ORC, and checks the errors.
    The rest of the suite then writes to and reads the same table. With the
    connector built without this change, the suite fails at the first of the
    three.
---
 .../doris/connector/iceberg/IcebergCatalogOps.java |  3 +
 .../connector/iceberg/IcebergWriterHelper.java     | 17 ++++++
 ...BackedIcebergCatalogOpsColumnEvolutionTest.java | 68 ++++++++++++++++++++++
 ...test_iceberg_v3_row_lineage_query_insert.groovy | 21 +++++++
 4 files changed, 109 insertions(+)

diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
index 6fdd8d24206..8d63b2b0158 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
@@ -523,6 +523,7 @@ public interface IcebergCatalogOps {
                 if (column.getType().isVariantType()) {
                     IcebergWriterHelper.validateVariantColumn(table, 
column.getName());
                 }
+                IcebergWriterHelper.validateNotRowLineageColumn(table, 
column.getName(), "add");
                 
IcebergNestedColumnEvolution.validateNoCaseInsensitiveSiblingCollision(
                         table.schema().asStruct(), "", column.getName(), null, 
"add");
                 UpdateSchema updateSchema = table.updateSchema();
@@ -541,6 +542,7 @@ public interface IcebergCatalogOps {
                     if (column.getType().isVariantType()) {
                         IcebergWriterHelper.validateVariantColumn(table, 
column.getName());
                     }
+                    IcebergWriterHelper.validateNotRowLineageColumn(table, 
column.getName(), "add");
                 }
                 
IcebergNestedColumnEvolution.validateNoCaseInsensitiveTopLevelCollisions(table.schema(),
 columns);
                 UpdateSchema updateSchema = table.updateSchema();
@@ -564,6 +566,7 @@ public interface IcebergCatalogOps {
         @Override
         public void renameColumn(String dbName, String tableName, String 
oldName, String newName) {
             withTable(dbName, tableName, table -> {
+                IcebergWriterHelper.validateNotRowLineageColumn(table, 
newName, "rename to");
                 IcebergNestedColumnEvolution.renameTopLevelColumn(table, 
oldName, newName);
                 return null;
             });
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java
index 29c9ba68fae..abf2edc38f2 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java
@@ -122,6 +122,23 @@ final class IcebergWriterHelper {
         }
     }
 
+    /**
+     * Rejects {@code columnName} as the new name of a top-level column of 
{@code table} when it is a format-version
+     * 3 row-lineage column name ({@code _row_id}, {@code 
_last_updated_sequence_number}, in any case). Such a table
+     * reads both as hidden columns, so a column that takes either name makes 
every later read of the table fail with
+     * a duplicate column. Nested fields and older format versions may use the 
names, and dropping such a column or
+     * renaming it away stays allowed: that is how a table that already holds 
one becomes readable again.
+     */
+    static void validateNotRowLineageColumn(Table table, String columnName, 
String operation) {
+        int formatVersion = getFormatVersion(table);
+        if (formatVersion >= ICEBERG_ROW_LINEAGE_MIN_VERSION
+                && (MetadataColumns.ROW_ID.name().equalsIgnoreCase(columnName)
+                || 
MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name().equalsIgnoreCase(columnName)))
 {
+            throw new DorisConnectorException("Cannot " + operation + " 
Iceberg v" + formatVersion
+                    + " reserved row lineage column: " + columnName);
+        }
+    }
+
     /**
      * Converts the BE data-file commit fragments into an iceberg {@link 
WriteResult} of {@link DataFile}s
      * (the INSERT / OVERWRITE / MERGE-data path). A partitioned table 
requires non-empty partition values per
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
index c581b78b35c..e292b2308b7 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
@@ -413,6 +413,74 @@ public class 
CatalogBackedIcebergCatalogOpsColumnEvolutionTest {
         Assertions.assertEquals(Types.LongType.get(), 
schema.findField("id").type());
     }
 
+    // ---------- row lineage names ----------
+
+    /** Creates {@code db1.<name>} at {@code formatVersion} with {@code 
fields} as its top-level columns. */
+    private void createTableWithFields(String name, String formatVersion, 
Types.NestedField... fields) {
+        Map<String, String> props = new 
HashMap<>(IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap()));
+        props.put("format-version", formatVersion);
+        ops.createTable("db1", name, new Schema(fields), 
PartitionSpec.unpartitioned(), null, props);
+    }
+
+    private List<String> columnNames(String tableName) {
+        return ops.loadTable("db1", tableName).schema().columns().stream()
+                .map(Types.NestedField::name).collect(Collectors.toList());
+    }
+
+    @Test
+    public void testRejectsRowLineageNamesOnFormatThreeTable() {
+        // A format-version 3 table reads _row_id and 
_last_updated_sequence_number as hidden columns, so a column
+        // that takes either name makes every later read of the table fail 
with a duplicate column.
+        createTableWithFields("v3", "3", Types.NestedField.optional(1, "id", 
Types.LongType.get()),
+                Types.NestedField.optional(2, "age", Types.IntegerType.get()));
+        DorisConnectorException add = 
Assertions.assertThrows(DorisConnectorException.class,
+                () -> ops.addColumn("db1", "v3", change("_row_id", 
Types.LongType.get(), null, true), null));
+        Assertions.assertEquals("Cannot add Iceberg v3 reserved row lineage 
column: _row_id", add.getMessage());
+        DorisConnectorException addColumns = 
Assertions.assertThrows(DorisConnectorException.class,
+                () -> ops.addColumns("db1", "v3", Arrays.asList(
+                        change("extra", Types.IntegerType.get(), null, true),
+                        change("_LAST_UPDATED_SEQUENCE_NUMBER", 
Types.LongType.get(), null, true))));
+        Assertions.assertEquals("Cannot add Iceberg v3 reserved row lineage 
column: _LAST_UPDATED_SEQUENCE_NUMBER",
+                addColumns.getMessage());
+        DorisConnectorException rename = 
Assertions.assertThrows(DorisConnectorException.class,
+                () -> ops.renameColumn("db1", "v3", "age", 
"_last_updated_sequence_number"));
+        Assertions.assertEquals(
+                "Cannot rename to Iceberg v3 reserved row lineage column: 
_last_updated_sequence_number",
+                rename.getMessage());
+        Assertions.assertEquals(Arrays.asList("id", "age"), columnNames("v3"));
+    }
+
+    @Test
+    public void testAllowsRowLineageNamesOnFormatTwoTableAndNestedFields() {
+        // The names are reserved only for the top-level columns of a 
format-version 3 table.
+        createTableWithFields("v2", "2", Types.NestedField.optional(1, "id", 
Types.LongType.get()));
+        ops.addColumn("db1", "v2", change("_row_id", Types.LongType.get(), 
null, true), null);
+        ops.renameColumn("db1", "v2", "id", "_last_updated_sequence_number");
+        Assertions.assertEquals(Arrays.asList("_last_updated_sequence_number", 
"_row_id"), columnNames("v2"));
+
+        createTableWithFields("v3_nested", "3", Types.NestedField.optional(1, 
"s", Types.StructType.of(
+                Types.NestedField.optional(2, "a", Types.IntegerType.get()))));
+        ops.addNestedColumn("db1", "v3_nested", 
ConnectorColumnPath.of(Arrays.asList("s", "_row_id")),
+                change("_row_id", Types.LongType.get(), null, true), null);
+        ops.renameNestedColumn("db1", "v3_nested", 
ConnectorColumnPath.of(Arrays.asList("s", "a")),
+                "_last_updated_sequence_number");
+        Assertions.assertEquals(Arrays.asList("_last_updated_sequence_number", 
"_row_id"),
+                ops.loadTable("db1", 
"v3_nested").schema().findField("s").type().asStructType().fields().stream()
+                        
.map(Types.NestedField::name).collect(Collectors.toList()));
+    }
+
+    @Test
+    public void 
testDropsOrRenamesAwayExistingRowLineageNamesOnFormatThreeTable() {
+        // A format-version 3 table can already hold such columns, for example 
one upgraded from format-version 2.
+        // Dropping them or renaming them away is how such a table becomes 
readable again, so both stay allowed.
+        createTableWithFields("v3_taken", "3", Types.NestedField.optional(1, 
"id", Types.LongType.get()),
+                Types.NestedField.optional(2, "_row_id", Types.LongType.get()),
+                Types.NestedField.optional(3, "_last_updated_sequence_number", 
Types.LongType.get()));
+        ops.dropColumn("db1", "v3_taken", "_row_id");
+        ops.renameColumn("db1", "v3_taken", "_last_updated_sequence_number", 
"seq");
+        Assertions.assertEquals(Arrays.asList("id", "seq"), 
columnNames("v3_taken"));
+    }
+
     // ---------- reorderColumns ----------
 
     @Test
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
index 4fc8d61a258..e95b779bc49 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
@@ -278,6 +278,24 @@ suite("test_iceberg_v3_row_lineage_query_insert", 
"p0,external,iceberg,external_
         }
     }
 
+    // A column that takes a row lineage name would clash with the hidden 
column and break every read of the table.
+    def assertAlterTableWithRowLineageColumnsFails = { tableName ->
+        test {
+            sql """alter table ${tableName} add column _row_id bigint"""
+            exception "Cannot add Iceberg v3 reserved row lineage column: 
_row_id"
+        }
+
+        test {
+            sql """alter table ${tableName} add column (extra int, 
_LAST_UPDATED_SEQUENCE_NUMBER bigint)"""
+            exception "Cannot add Iceberg v3 reserved row lineage column: 
_LAST_UPDATED_SEQUENCE_NUMBER"
+        }
+
+        test {
+            sql """alter table ${tableName} rename column age 
_last_updated_sequence_number"""
+            exception "Cannot rename to Iceberg v3 reserved row lineage 
column: _last_updated_sequence_number"
+        }
+    }
+
     def assertRowLineageOnlyAggregatesReadable = { tableName, expectedRowCount 
->
         def rowIdsWithPhysicalColumn = sql("""
             select _row_id, id
@@ -434,6 +452,9 @@ suite("test_iceberg_v3_row_lineage_query_insert", 
"p0,external,iceberg,external_
                     exception "Cannot specify invisible column 
'_last_updated_sequence_number' in INSERT statement"
                 }
 
+                // The rejected ALTERs leave the table readable for the writes 
and reads below.
+                assertAlterTableWithRowLineageColumnsFails(unpartitionedTable)
+
                 sql """insert into ${unpartitionedTable}(id, name, age) values 
(4, 'Doris', 40)"""
                 def unpartitionedCount = sql """select count(*) from 
${unpartitionedTable}"""
                 log.info("Checking row count after regular INSERT for 
${unpartitionedTable}: result=${unpartitionedCount}")


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to