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

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 265a6ad2d4f branch-4.1: [feature](catalog) Support altering Iceberg 
and Paimon table properties (#66428)
265a6ad2d4f is described below

commit 265a6ad2d4f83a0b38f289de4fe1ab4ceae4c5e3
Author: Socrates <[email protected]>
AuthorDate: Tue Aug 25 10:25:10 2026 +0800

    branch-4.1: [feature](catalog) Support altering Iceberg and Paimon table 
properties (#66428)
    
    ## What changed
    
    - Support `ALTER TABLE ... SET (...)` for both Iceberg and Paimon
    external tables.
    - Keep Doris-owned `auto_analyze_policy` separate from connector-native
    properties and reject mixed updates.
    - Allow multiple connector properties in one synchronous external-table
    ALTER operation.
    - Commit Iceberg properties through one `UpdateProperties` transaction.
    - Convert Paimon properties to `SchemaChange.setOption` entries and
    apply them through one `Catalog.alterTable` call.
    - Refresh FE external-table metadata and persist the refresh log only
    after the remote commit succeeds.
    
    ## Atomicity and failure behavior
    
    - Iceberg validates and commits the complete property map as one update.
    - Paimon validates and applies the complete `List<SchemaChange>` as one
    schema change.
    - A failed remote update does not trigger FE refresh or leave a valid
    subset of a mixed valid/invalid update behind.
    
    ## Review cleanup
    
    - Reuse one `auto_analyze_policy` validator for internal and external
    table paths.
    - Remove redundant execution-layer validation already guaranteed by
    command analysis.
    - Remove overlapping mock coverage while retaining real Paimon catalog
    atomicity coverage.
    - Reduce redundant Spark refresh calls in the Iceberg regression case.
    
    ## Example
    
    ```sql
    ALTER TABLE external_table SET (
        'snapshot.num-retained.min' = '3',
        'snapshot.num-retained.max' = '8'
    );
    ```
    
    The same syntax supports native Iceberg properties such as
    `write.target-file-size-bytes` and `commit.manifest.min-count-to-merge`.
    
    ## Coverage
    
    - SQL validation: external multi-property SET, internal-table
    compatibility, `auto_analyze_policy` validation, and property-domain
    separation.
    - Iceberg metadata ops: multi-property commit, single commit, success
    refresh, and failure without refresh.
    - Paimon metadata ops: persisted options, one atomic alter call, real
    filesystem-catalog rejection atomicity, and failure without refresh.
    - Iceberg regression: multi-property SET, overwrite/preservation,
    Doris-owned property isolation, invalid-update atomicity, and Spark
    metadata verification.
    - Paimon regression: multi-property SET, overwrite/preservation,
    schema-version behavior, Doris-owned property isolation, invalid-update
    atomicity, and `$options`/`$schemas` verification.
    
    ## Validation
    
    - Targeted FE tests: 78 passed, 0 failures, 0 errors, 0 skipped.
    - Maven reactor build: success.
    - Checkstyle: 0 violations.
    - Both new Groovy regression scripts compile successfully.
    - External Iceberg/Paimon regression suites require the
    Docker/Spark/MinIO test environment and were not executed locally.
---
 .../main/java/org/apache/doris/alter/Alter.java    |   7 +-
 .../apache/doris/datasource/ExternalCatalog.java   |  18 +++
 .../datasource/iceberg/IcebergMetadataOps.java     |  16 +++
 .../datasource/operations/ExternalMetadataOps.java |  14 +++
 .../doris/datasource/paimon/PaimonMetadataOps.java |   8 ++
 .../commands/info/ModifyTablePropertiesOp.java     |  41 +++++--
 .../iceberg/IcebergMetadataOpsValidationTest.java  |  48 ++++++++
 .../datasource/paimon/PaimonMetadataOpsTest.java   |  91 ++++++++++++++
 .../commands/info/ModifyTablePropertiesOpTest.java | 100 +++++++++++++++
 .../iceberg/test_iceberg_alter_properties.groovy   | 114 ++++++++++++++++++
 .../paimon/test_paimon_alter_properties.groovy     | 134 +++++++++++++++++++++
 11 files changed, 580 insertions(+), 11 deletions(-)

diff --git a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java 
b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
index 62e5ab6ba53..3e07bb20e98 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
@@ -399,7 +399,12 @@ public class Alter {
         long updateTime = System.currentTimeMillis();
         for (AlterClause alterClause : alterClauses) {
             if (alterClause instanceof ModifyTablePropertiesClause) {
-                setExternalTableAutoAnalyzePolicy(table, alterClauses);
+                Map<String, String> properties = alterClause.getProperties();
+                if 
(properties.containsKey(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY)) {
+                    setExternalTableAutoAnalyzePolicy(table, alterClauses);
+                } else {
+                    table.getCatalog().updateTableProperties(table, 
properties);
+                }
             } else if (alterClause instanceof CreateOrReplaceBranchClause) {
                 table.getCatalog().createOrReplaceBranch(
                         table,
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
index d7314efa46f..2c31414a99f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
@@ -1546,6 +1546,24 @@ public abstract class ExternalCatalog
                                 dorisTable.getDbName(), dorisTable.getName(), 
updateTime));
     }
 
+    public void updateTableProperties(TableIf dorisTable, Map<String, String> 
properties) throws UserException {
+        makeSureInitialized();
+        Preconditions.checkState(dorisTable instanceof ExternalTable, 
dorisTable.getName());
+        ExternalTable externalTable = (ExternalTable) dorisTable;
+        if (metadataOps == null) {
+            throw new DdlException("Update table properties operation is not 
supported for catalog: " + getName());
+        }
+        try {
+            long updateTime = System.currentTimeMillis();
+            metadataOps.updateTableProperties(externalTable, properties, 
updateTime);
+            logRefreshExternalTable(externalTable, updateTime);
+        } catch (Exception e) {
+            LOG.warn("Failed to update properties for table {}.{} in catalog 
{}",
+                    externalTable.getDbName(), externalTable.getName(), 
getName(), e);
+            throw e;
+        }
+    }
+
     @Override
     public void addColumn(TableIf dorisTable, Column column, ColumnPosition 
position) throws UserException {
         addColumn(dorisTable, ColumnPath.of(column.getName()), column, 
position);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
index 0635e0a9a0c..f0546bcb9c2 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
@@ -64,6 +64,7 @@ import org.apache.iceberg.SnapshotRef;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.UpdatePartitionSpec;
+import org.apache.iceberg.UpdateProperties;
 import org.apache.iceberg.UpdateSchema;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
@@ -865,6 +866,21 @@ public class IcebergMetadataOps implements 
ExternalMetadataOps {
         refreshTable(dorisTable, updateTime);
     }
 
+    @Override
+    public void updateTableProperties(ExternalTable dorisTable, Map<String, 
String> properties, long updateTime)
+            throws UserException {
+        Table icebergTable = IcebergUtils.getIcebergTable(dorisTable);
+        UpdateProperties updateProperties = icebergTable.updateProperties();
+        properties.forEach(updateProperties::set);
+        try {
+            executionAuthenticator.execute(updateProperties::commit);
+        } catch (Exception e) {
+            throw new UserException("Failed to update properties for table: " 
+ icebergTable.name()
+                    + ", error message is: " + e.getMessage(), e);
+        }
+        refreshTable(dorisTable, updateTime);
+    }
+
     @Override
     public void renameColumn(ExternalTable dorisTable, String oldName, String 
newName, long updateTime)
             throws UserException {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
index 13b3d9fea41..9ba7e4e3f53 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
@@ -293,6 +293,20 @@ public interface ExternalMetadataOps {
         throw new UnsupportedOperationException("Nested rename column 
operation is not supported for this table type.");
     }
 
+    /**
+     * update properties for external table
+     *
+     * @param dorisTable external table
+     * @param properties properties to update
+     * @param updateTime update time used to refresh FE metadata
+     * @throws UserException if the update fails
+     */
+    default void updateTableProperties(ExternalTable dorisTable, Map<String, 
String> properties, long updateTime)
+            throws UserException {
+        throw new UnsupportedOperationException(
+                "Update table properties operation is not supported for this 
table type.");
+    }
+
     /**
      * update column for external table
      *
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
index 016831e26aa..ace93f14655 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
@@ -530,6 +530,14 @@ public class PaimonMetadataOps implements 
ExternalMetadataOps {
         }
     }
 
+    @Override
+    public void updateTableProperties(ExternalTable dorisTable, Map<String, 
String> properties, long updateTime)
+            throws UserException {
+        List<SchemaChange> changes = new ArrayList<>(properties.size());
+        properties.forEach((key, value) -> 
changes.add(SchemaChange.setOption(key, value)));
+        alterTable(dorisTable, changes, "set properties", updateTime);
+    }
+
     @Override
     public void addColumn(ExternalTable dorisTable, Column column, 
ColumnPosition position, long updateTime)
             throws UserException {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
index cbb4f68e78d..7a364042cc5 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
@@ -76,6 +76,11 @@ public class ModifyTablePropertiesOp extends AlterTableOp {
             throw new AnalysisException("Properties is not set");
         }
 
+        if (tableName != null && 
!InternalCatalog.INTERNAL_CATALOG_NAME.equals(tableName.getCtl())) {
+            validateExternalTableProperties();
+            return;
+        }
+
         if (properties.size() != 1
                 && !TableProperty.isSamePrefixProperties(
                         properties, 
DynamicPartitionProperty.DYNAMIC_PARTITION_PROPERTY_PREFIX)
@@ -359,16 +364,7 @@ public class ModifyTablePropertiesOp extends AlterTableOp {
         } else if 
(properties.containsKey(PropertyAnalyzer.PROPERTIES_ROW_STORE_COLUMNS)) {
             // do nothing, will be analyzed when creating alter job
         } else if 
(properties.containsKey(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY)) {
-            String analyzePolicy = 
properties.getOrDefault(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY, "");
-            if (analyzePolicy != null
-                    && 
!analyzePolicy.equals(PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY)
-                    && 
!analyzePolicy.equals(PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY)
-                    && 
!analyzePolicy.equals(PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY)) {
-                throw new AnalysisException(
-                        "Table auto analyze policy only support for " + 
PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY
-                                + " or " + 
PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY
-                                + " or " + 
PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY);
-            }
+            validateAutoAnalyzePolicy();
             this.needTableStable = false;
             this.opType = AlterOpType.MODIFY_TABLE_PROPERTY_SYNC;
         } else if 
(properties.containsKey(PropertyAnalyzer.ENABLE_UNIQUE_KEY_SKIP_BITMAP_COLUMN)) 
{
@@ -385,6 +381,31 @@ public class ModifyTablePropertiesOp extends AlterTableOp {
         analyzeForMTMV();
     }
 
+    private void validateExternalTableProperties() throws AnalysisException {
+        this.needTableStable = false;
+        this.opType = AlterOpType.MODIFY_TABLE_PROPERTY_SYNC;
+        if 
(!properties.containsKey(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY)) {
+            return;
+        }
+        if (properties.size() != 1) {
+            throw new AnalysisException("auto_analyze_policy cannot be set 
with external table properties");
+        }
+        validateAutoAnalyzePolicy();
+    }
+
+    private void validateAutoAnalyzePolicy() throws AnalysisException {
+        String analyzePolicy = 
properties.getOrDefault(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY, "");
+        if (analyzePolicy != null
+                && 
!analyzePolicy.equals(PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY)
+                && 
!analyzePolicy.equals(PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY)
+                && 
!analyzePolicy.equals(PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY)) {
+            throw new AnalysisException(
+                    "Table auto analyze policy only support for " + 
PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY
+                            + " or " + 
PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY
+                            + " or " + 
PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY);
+        }
+    }
+
     @Override
     public AlterTableClause translateToLegacyAlterClause() {
         return new ModifyTablePropertiesClause(properties, storagePolicy, 
isBeingSynced, needTableStable, opType);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java
index de3a20eca59..b0b6878d248 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java
@@ -36,6 +36,7 @@ import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.Schema;
 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;
@@ -180,6 +181,53 @@ public class IcebergMetadataOpsValidationTest {
         Mockito.verify(icebergTable, Mockito.never()).updateSchema();
     }
 
+    @Test
+    public void testUpdateTablePropertiesCommitsAllProperties() throws 
Exception {
+        ExternalTable dorisTable = Mockito.mock(ExternalTable.class);
+        Table icebergTable = Mockito.mock(Table.class);
+        UpdateProperties updateProperties = 
Mockito.mock(UpdateProperties.class);
+        
Mockito.when(icebergTable.updateProperties()).thenReturn(updateProperties);
+        Mockito.when(dorisTable.getRemoteDbName()).thenReturn("db");
+
+        Map<String, String> properties = new HashMap<>();
+        properties.put("write.target-file-size-bytes", "134217728");
+        properties.put("commit.manifest.min-count-to-merge", "50");
+
+        try (MockedStatic<IcebergUtils> mockedIcebergUtils =
+                Mockito.mockStatic(IcebergUtils.class, 
Mockito.CALLS_REAL_METHODS)) {
+            mockedIcebergUtils.when(() -> 
IcebergUtils.getIcebergTable(dorisTable)).thenReturn(icebergTable);
+
+            ops.updateTableProperties(dorisTable, properties, 123L);
+        }
+
+        Mockito.verify(updateProperties).set("write.target-file-size-bytes", 
"134217728");
+        
Mockito.verify(updateProperties).set("commit.manifest.min-count-to-merge", 
"50");
+        Mockito.verify(updateProperties).commit();
+        Mockito.verify(dorisCatalog).getDbForReplay("db");
+    }
+
+    @Test
+    public void testUpdateTablePropertiesDoesNotRefreshAfterCommitFailure() {
+        ExternalTable dorisTable = Mockito.mock(ExternalTable.class);
+        Table icebergTable = Mockito.mock(Table.class);
+        UpdateProperties updateProperties = 
Mockito.mock(UpdateProperties.class);
+        
Mockito.when(icebergTable.updateProperties()).thenReturn(updateProperties);
+        Mockito.when(icebergTable.name()).thenReturn("db.tbl");
+        Mockito.when(dorisTable.getRemoteDbName()).thenReturn("db");
+        Mockito.doThrow(new RuntimeException("commit 
failed")).when(updateProperties).commit();
+
+        try (MockedStatic<IcebergUtils> mockedIcebergUtils =
+                Mockito.mockStatic(IcebergUtils.class, 
Mockito.CALLS_REAL_METHODS)) {
+            mockedIcebergUtils.when(() -> 
IcebergUtils.getIcebergTable(dorisTable)).thenReturn(icebergTable);
+
+            assertUserException(() -> ops.updateTableProperties(
+                            dorisTable, 
Collections.singletonMap("write.target-file-size-bytes", "134217728"), 123L),
+                    "commit failed");
+        }
+
+        Mockito.verify(dorisCatalog, 
Mockito.never()).getDbForReplay(Mockito.anyString());
+    }
+
     @Test
     public void testValidateForModifyColumnRejectsComplexToPrimitive() {
         Column column = new Column("struct_col", Type.INT, true);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
index 3135df2541f..79b7d48e5c2 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
@@ -21,9 +21,11 @@ import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.ScalarType;
 import org.apache.doris.common.DdlException;
 import org.apache.doris.common.UserException;
+import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
 import org.apache.doris.datasource.CatalogFactory;
 import org.apache.doris.datasource.ExternalCatalog;
 import org.apache.doris.datasource.ExternalDatabase;
+import org.apache.doris.datasource.ExternalTable;
 import org.apache.doris.nereids.parser.NereidsParser;
 import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand;
 import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand;
@@ -37,6 +39,7 @@ import org.apache.paimon.catalog.FileSystemCatalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.hive.HiveCatalog;
 import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.types.BigIntType;
 import org.apache.paimon.types.DataField;
@@ -51,13 +54,16 @@ import org.junit.Assert;
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.junit.jupiter.api.Assertions;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.UUID;
 import java.util.stream.Collectors;
@@ -136,6 +142,91 @@ public class PaimonMetadataOpsTest {
         Assert.assertEquals(1, table.primaryKeys().size());
     }
 
+    @Test
+    public void testUpdateTablePropertiesPersistsAllOptions() throws Exception 
{
+        String tableName = getTableName();
+        Identifier identifier = new Identifier(dbName, tableName);
+        createTable("create table " + dbName + "." + tableName + " (id int) 
engine = paimon");
+
+        ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+        PaimonMetadataOps propertyOps = newMetadataOps(dorisCatalog, 
ops.getCatalog());
+        ExternalTable dorisTable = mockExternalTable(tableName);
+        Map<String, String> properties = new LinkedHashMap<>();
+        properties.put("snapshot.num-retained.min", "2");
+        properties.put("snapshot.num-retained.max", "5");
+
+        propertyOps.updateTableProperties(dorisTable, properties, 123L);
+
+        Map<String, String> actualOptions = 
ops.getCatalog().getTable(identifier).options();
+        Assert.assertEquals("2", 
actualOptions.get("snapshot.num-retained.min"));
+        Assert.assertEquals("5", 
actualOptions.get("snapshot.num-retained.max"));
+        Mockito.verify(dorisCatalog).getDbForReplay(dbName);
+    }
+
+    @Test
+    public void testUpdateTablePropertiesUsesOneAtomicAlter() throws Exception 
{
+        String tableName = getTableName();
+        Identifier identifier = new Identifier(dbName, tableName);
+        Catalog remoteCatalog = Mockito.mock(Catalog.class);
+        ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+        PaimonMetadataOps propertyOps = newMetadataOps(dorisCatalog, 
remoteCatalog);
+        Map<String, String> properties = new LinkedHashMap<>();
+        properties.put("snapshot.num-retained.min", "2");
+        properties.put("snapshot.num-retained.max", "5");
+
+        propertyOps.updateTableProperties(mockExternalTable(tableName), 
properties, 123L);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<List<SchemaChange>> changesCaptor = 
ArgumentCaptor.forClass(List.class);
+        Mockito.verify(remoteCatalog).alterTable(Mockito.eq(identifier), 
changesCaptor.capture(), Mockito.eq(false));
+        Assert.assertEquals(
+                java.util.Arrays.asList(
+                        SchemaChange.setOption("snapshot.num-retained.min", 
"2"),
+                        SchemaChange.setOption("snapshot.num-retained.max", 
"5")),
+                changesCaptor.getValue());
+    }
+
+    @Test
+    public void testUpdateTablePropertiesRejectsInvalidBatchAtomically() 
throws Exception {
+        String tableName = getTableName();
+        Identifier identifier = new Identifier(dbName, tableName);
+        createTable("create table " + dbName + "." + tableName
+                + " (id int not null, seq bigint) engine = paimon "
+                + "properties ('primary-key' = 'id', 
'snapshot.num-retained.min' = '2', "
+                + "'snapshot.num-retained.max' = '5')");
+
+        ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+        PaimonMetadataOps propertyOps = newMetadataOps(dorisCatalog, 
ops.getCatalog());
+        Map<String, String> properties = new LinkedHashMap<>();
+        properties.put("fields.missing.sequence-group", "seq");
+        properties.put("snapshot.num-retained.max", "10");
+
+        UserException exception = Assert.assertThrows(UserException.class,
+                () -> 
propertyOps.updateTableProperties(mockExternalTable(tableName), properties, 
123L));
+
+        
Assert.assertTrue(exception.getMessage().toLowerCase().contains("missing"));
+        ops.getCatalog().invalidateTable(identifier);
+        Map<String, String> actualOptions = 
ops.getCatalog().getTable(identifier).options();
+        Assert.assertEquals("5", 
actualOptions.get("snapshot.num-retained.max"));
+        
Assert.assertFalse(actualOptions.containsKey("fields.missing.sequence-group"));
+        Mockito.verify(dorisCatalog, 
Mockito.never()).getDbForReplay(Mockito.anyString());
+    }
+
+    private PaimonMetadataOps newMetadataOps(ExternalCatalog dorisCatalog, 
Catalog remoteCatalog) {
+        Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new 
ExecutionAuthenticator() {
+        });
+        return new PaimonMetadataOps(dorisCatalog, remoteCatalog);
+    }
+
+    private ExternalTable mockExternalTable(String tableName) {
+        ExternalTable dorisTable = Mockito.mock(ExternalTable.class);
+        Mockito.when(dorisTable.getDbName()).thenReturn(dbName);
+        Mockito.when(dorisTable.getRemoteDbName()).thenReturn(dbName);
+        Mockito.when(dorisTable.getName()).thenReturn(tableName);
+        Mockito.when(dorisTable.getRemoteName()).thenReturn(tableName);
+        return dorisTable;
+    }
+
     @Test
     public void testType() throws Exception {
         String tableName = getTableName();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOpTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOpTest.java
new file mode 100644
index 00000000000..a13e530e6d6
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOpTest.java
@@ -0,0 +1,100 @@
+// 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.doris.nereids.trees.plans.commands.info;
+
+import org.apache.doris.alter.AlterOpType;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.util.PropertyAnalyzer;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.info.TableNameInfo;
+import org.apache.doris.qe.ConnectContext;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class ModifyTablePropertiesOpTest {
+
+    @Test
+    public void testExternalTablePropertiesAllowMultipleConnectorOptions() 
throws Exception {
+        Map<String, String> properties = new HashMap<>();
+        properties.put("snapshot.num-retained.min", "2");
+        properties.put("snapshot.num-retained.max", "5");
+        ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(properties);
+        op.setTableName(new TableNameInfo("paimon", "db", "tbl"));
+
+        op.validate(new ConnectContext());
+
+        Assertions.assertEquals(AlterOpType.MODIFY_TABLE_PROPERTY_SYNC,
+                op.translateToLegacyAlterClause().getOpType());
+    }
+
+    @Test
+    public void testInternalTableStillRejectsMultipleUnrelatedProperties() {
+        Map<String, String> properties = new HashMap<>();
+        properties.put("snapshot.num-retained.min", "2");
+        properties.put("snapshot.num-retained.max", "5");
+        ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(properties);
+        op.setTableName(new 
TableNameInfo(InternalCatalog.INTERNAL_CATALOG_NAME, "db", "tbl"));
+
+        AnalysisException exception = Assertions.assertThrows(
+                AnalysisException.class, () -> op.validate(new 
ConnectContext()));
+        Assertions.assertTrue(exception.getMessage().contains("Can only set 
one table property"));
+    }
+
+    @Test
+    public void 
testExternalAutoAnalyzePolicyCannotBeMixedWithConnectorOptions() {
+        Map<String, String> properties = new HashMap<>();
+        properties.put(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY,
+                PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY);
+        properties.put("write.target-file-size-bytes", "134217728");
+        ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(properties);
+        op.setTableName(new TableNameInfo("iceberg", "db", "tbl"));
+
+        AnalysisException exception = Assertions.assertThrows(
+                AnalysisException.class, () -> op.validate(new 
ConnectContext()));
+        Assertions.assertTrue(exception.getMessage().contains("cannot be set 
with external table properties"));
+    }
+
+    @Test
+    public void testExternalAutoAnalyzePolicyIsValidated() {
+        ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(
+                
java.util.Collections.singletonMap(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY,
 "invalid"));
+        op.setTableName(new TableNameInfo("iceberg", "db", "tbl"));
+
+        AnalysisException exception = Assertions.assertThrows(
+                AnalysisException.class, () -> op.validate(new 
ConnectContext()));
+        Assertions.assertTrue(exception.getMessage().contains("Table auto 
analyze policy only support"));
+    }
+
+    @Test
+    public void testExternalAutoAnalyzePolicyKeepsDorisPropertyPath() throws 
Exception {
+        ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(
+                java.util.Collections.singletonMap(
+                        PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY,
+                        PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY));
+        op.setTableName(new TableNameInfo("paimon", "db", "tbl"));
+
+        op.validate(new ConnectContext());
+
+        Assertions.assertEquals(AlterOpType.MODIFY_TABLE_PROPERTY_SYNC,
+                op.translateToLegacyAlterClause().getOpType());
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_alter_properties.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_alter_properties.groovy
new file mode 100644
index 00000000000..51778aababd
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_alter_properties.groovy
@@ -0,0 +1,114 @@
+// 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.
+
+suite("test_iceberg_alter_properties", "p0,external") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("Iceberg test is not enabled, skip this test")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_iceberg_alter_properties"
+    String dbName = "test_iceberg_alter_properties_db"
+    String tableName = "iceberg_alter_properties"
+
+    def refreshSparkTable = {
+        spark_iceberg """REFRESH TABLE demo.${dbName}.${tableName}"""
+    }
+    def sparkProperty = { String key ->
+        List<List<Object>> rows = spark_iceberg """
+            SHOW TBLPROPERTIES demo.${dbName}.${tableName} ('${key}')
+        """
+        assertEquals(1, rows.size())
+        return rows[0][1].toString()
+    }
+
+    sql """DROP CATALOG IF EXISTS ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'iceberg',
+            'iceberg.catalog.type' = 'rest',
+            'uri' = 'http://${externalEnvIp}:${restPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.region' = 'us-east-1'
+        )
+    """
+    sql """SWITCH ${catalogName}"""
+    sql """DROP DATABASE IF EXISTS ${dbName} FORCE"""
+    sql """CREATE DATABASE ${dbName}"""
+    sql """USE ${dbName}"""
+
+    sql """DROP TABLE IF EXISTS ${tableName}"""
+    sql """CREATE TABLE ${tableName} (id INT) PROPERTIES ('format-version' = 
'2')"""
+
+    // Multiple connector properties are committed together.
+    sql """
+        ALTER TABLE ${tableName} SET (
+            'write.target-file-size-bytes' = '134217728',
+            'commit.manifest.min-count-to-merge' = '50'
+        )
+    """
+    refreshSparkTable()
+    assertEquals("134217728", sparkProperty("write.target-file-size-bytes"))
+    assertEquals("50", sparkProperty("commit.manifest.min-count-to-merge"))
+
+    // A later SET replaces the selected property and preserves unrelated ones.
+    sql """
+        ALTER TABLE ${tableName} SET (
+            'write.target-file-size-bytes' = '268435456'
+        )
+    """
+    refreshSparkTable()
+    assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+    assertEquals("50", sparkProperty("commit.manifest.min-count-to-merge"))
+
+    // Doris-owned properties stay on the Doris path and never reach Iceberg.
+    sql """ALTER TABLE ${tableName} SET ('auto_analyze_policy' = 'disable')"""
+    refreshSparkTable()
+    assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+    test {
+        sql """
+            ALTER TABLE ${tableName} SET (
+                'auto_analyze_policy' = 'enable',
+                'write.target-file-size-bytes' = '536870912'
+            )
+        """
+        exception "auto_analyze_policy cannot be set with external table 
properties"
+    }
+    refreshSparkTable()
+    assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+
+    // Iceberg validates the whole UpdateProperties commit. A rejected format
+    // downgrade must not commit the otherwise valid size change.
+    test {
+        sql """
+            ALTER TABLE ${tableName} SET (
+                'format-version' = '1',
+                'write.target-file-size-bytes' = '536870912'
+            )
+        """
+        exception "Cannot downgrade"
+    }
+    refreshSparkTable()
+    assertEquals("2", sparkProperty("format-version"))
+    assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+}
diff --git 
a/regression-test/suites/external_table_p0/paimon/test_paimon_alter_properties.groovy
 
b/regression-test/suites/external_table_p0/paimon/test_paimon_alter_properties.groovy
new file mode 100644
index 00000000000..69694b6f16d
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/test_paimon_alter_properties.groovy
@@ -0,0 +1,134 @@
+// 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.
+
+suite("test_paimon_alter_properties", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_paimon_alter_properties"
+    String dbName = "test_paimon_alter_properties_db"
+    String tableName = "paimon_alter_properties"
+
+    def schemaId = {
+        return (sql """
+            SELECT MAX(schema_id) FROM `${tableName}\$schemas`
+        """)[0][0] as long
+    }
+    def optionRows = { String key ->
+        return sql("""
+            SELECT value
+            FROM `${tableName}\$options`
+            WHERE `key` = '${key}'
+        """)
+    }
+    def optionValue = { String key ->
+        List<List<Object>> rows = optionRows(key)
+        assertEquals(1, rows.size())
+        return rows[0][0].toString()
+    }
+
+    sql """DROP CATALOG IF EXISTS `${catalogName}`"""
+    sql """
+        CREATE CATALOG `${catalogName}` PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """SWITCH `${catalogName}`"""
+    sql """DROP DATABASE IF EXISTS `${dbName}` FORCE"""
+    sql """CREATE DATABASE `${dbName}`"""
+    sql """USE `${dbName}`"""
+    sql """DROP TABLE IF EXISTS `${tableName}`"""
+    sql """
+        CREATE TABLE `${tableName}` (
+            id INT NOT NULL,
+            seq BIGINT NULL,
+            payload STRING NULL
+        ) ENGINE=paimon
+        PROPERTIES (
+            'primary-key' = 'id',
+            'snapshot.num-retained.min' = '2',
+            'snapshot.num-retained.max' = '5'
+        )
+    """
+
+    // One Doris statement becomes one atomic Paimon schema change containing
+    // every SetOption. The refreshed system tables are visible immediately.
+    long beforeSchemaId = schemaId()
+    sql """
+        ALTER TABLE `${tableName}` SET (
+            'snapshot.num-retained.min' = '3',
+            'snapshot.num-retained.max' = '6'
+        )
+    """
+    assertEquals(beforeSchemaId + 1, schemaId())
+    assertEquals("3", optionValue("snapshot.num-retained.min"))
+    assertEquals("6", optionValue("snapshot.num-retained.max"))
+
+    // Updating one option replaces it without removing the other option.
+    beforeSchemaId = schemaId()
+    sql """
+        ALTER TABLE `${tableName}` SET (
+            'snapshot.num-retained.max' = '8'
+        )
+    """
+    assertEquals(beforeSchemaId + 1, schemaId())
+    assertEquals("3", optionValue("snapshot.num-retained.min"))
+    assertEquals("8", optionValue("snapshot.num-retained.max"))
+
+    // auto_analyze_policy is Doris metadata, so it does not create a Paimon
+    // schema version. Mixing the two property domains is rejected up front.
+    beforeSchemaId = schemaId()
+    sql """ALTER TABLE `${tableName}` SET ('auto_analyze_policy' = 
'disable')"""
+    assertEquals(beforeSchemaId, schemaId())
+    test {
+        sql """
+            ALTER TABLE `${tableName}` SET (
+                'auto_analyze_policy' = 'enable',
+                'snapshot.num-retained.max' = '9'
+            )
+        """
+        exception "auto_analyze_policy cannot be set with external table 
properties"
+    }
+    assertEquals(beforeSchemaId, schemaId())
+    assertEquals("8", optionValue("snapshot.num-retained.max"))
+
+    // Paimon validates all SetOption changes before committing the next 
schema.
+    // A bad field-scoped option must not leak the valid max-retention update.
+    test {
+        sql """
+            ALTER TABLE `${tableName}` SET (
+                'fields.missing.sequence-group' = 'seq',
+                'snapshot.num-retained.max' = '10'
+            )
+        """
+        exception "missing"
+    }
+    assertEquals(beforeSchemaId, schemaId())
+    assertEquals("8", optionValue("snapshot.num-retained.max"))
+    assertTrue(optionRows("fields.missing.sequence-group").isEmpty())
+}


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

Reply via email to