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

luoyuxia 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 89b7c0cad [lake/paimon] Support custom Paimon path (#3523)
89b7c0cad is described below

commit 89b7c0cad6b6c3e7e931fb1848a0cf597c89481d
Author: Liurnly <[email protected]>
AuthorDate: Mon Jul 13 21:56:43 2026 +0800

    [lake/paimon] Support custom Paimon path (#3523)
---
 .../fluss/lake/paimon/PaimonLakeCatalog.java       |  50 +++++++-
 .../fluss/lake/paimon/utils/PaimonConversions.java |   1 -
 .../lake/paimon/LakeEnabledTableCreateITCase.java  | 136 +++++++++++++++++++++
 3 files changed, 184 insertions(+), 3 deletions(-)

diff --git 
a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java
 
b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java
index 738f659dd..bd846cec2 100644
--- 
a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java
+++ 
b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java
@@ -32,6 +32,7 @@ import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.CatalogContext;
 import org.apache.paimon.catalog.CatalogFactory;
 import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
@@ -42,6 +43,7 @@ import org.apache.paimon.types.DataTypes;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -59,6 +61,7 @@ import static 
org.apache.fluss.metadata.TableDescriptor.TIMESTAMP_COLUMN_NAME;
 public class PaimonLakeCatalog implements LakeCatalog {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(PaimonLakeCatalog.class);
+    private static final String PAIMON_PATH_KEY = "paimon.path";
     public static final LinkedHashMap<String, DataType> SYSTEM_COLUMNS = new 
LinkedHashMap<>();
 
     static {
@@ -115,6 +118,14 @@ public class PaimonLakeCatalog implements LakeCatalog {
         try {
             Table table = paimonCatalog.getTable(toPaimon(tablePath));
             FileStoreTable fileStoreTable = (FileStoreTable) table;
+            List<TableChange> changesToApply =
+                    
validateAndFilterPaimonPathChanges(fileStoreTable.location(), tableChanges);
+
+            // Avoid creating a new Paimon schema version for a path-only 
no-op.
+            if (changesToApply.isEmpty()) {
+                return;
+            }
+
             Schema currentPaimonSchema = fileStoreTable.schema().toSchema();
 
             List<SchemaChange> paimonSchemaChanges;
@@ -122,13 +133,13 @@ public class PaimonLakeCatalog implements LakeCatalog {
                     currentPaimonSchema, 
toPaimonSchema(context.getCurrentTable()))) {
                 // if the paimon schema is same as current fluss schema, 
directly apply all the
                 // changes.
-                paimonSchemaChanges = toPaimonSchemaChanges(tableChanges);
+                paimonSchemaChanges = toPaimonSchemaChanges(changesToApply);
             } else if (isPaimonSchemaCompatible(
                     currentPaimonSchema, 
toPaimonSchema(context.getExpectedTable()))) {
                 // if the schema is same as applied fluss schema , skip adding 
columns.
                 paimonSchemaChanges =
                         toPaimonSchemaChanges(
-                                tableChanges.stream()
+                                changesToApply.stream()
                                         .filter(
                                                 tableChange ->
                                                         !(tableChange
@@ -157,6 +168,41 @@ public class PaimonLakeCatalog implements LakeCatalog {
         }
     }
 
+    private static List<TableChange> validateAndFilterPaimonPathChanges(
+            Path currentLocation, List<TableChange> tableChanges) {
+        List<TableChange> changesToApply = new 
ArrayList<>(tableChanges.size());
+
+        // `paimon.path` is create-time-only because changing it does not 
migrate existing data.
+        // It may be repeated after table creation when enabling lake in the 
same ALTER. Treat an
+        // equivalent path as a no-op, but reject any actual path change.
+        for (TableChange tableChange : tableChanges) {
+            if (tableChange instanceof TableChange.SetOption) {
+                TableChange.SetOption setOption = (TableChange.SetOption) 
tableChange;
+                if (PAIMON_PATH_KEY.equals(setOption.getKey())) {
+                    if (currentLocation.equals(new 
Path(setOption.getValue()))) {
+                        continue;
+                    }
+                    throw invalidPaimonPathChangeException();
+                }
+            } else if (tableChange instanceof TableChange.ResetOption) {
+                TableChange.ResetOption resetOption = 
(TableChange.ResetOption) tableChange;
+                if (PAIMON_PATH_KEY.equals(resetOption.getKey())) {
+                    throw invalidPaimonPathChangeException();
+                }
+            }
+            changesToApply.add(tableChange);
+        }
+
+        return changesToApply;
+    }
+
+    private static InvalidAlterTableException 
invalidPaimonPathChangeException() {
+        return new InvalidAlterTableException(
+                String.format(
+                        "'%s' can only be altered before the Paimon table is 
created.",
+                        PAIMON_PATH_KEY));
+    }
+
     private void createTable(TablePath tablePath, Schema schema, boolean 
isCreatingFlussTable)
             throws Catalog.DatabaseNotExistException {
         Identifier paimonPath = toPaimon(tablePath);
diff --git 
a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/utils/PaimonConversions.java
 
b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/utils/PaimonConversions.java
index 4bb12a0f4..2aa1f0529 100644
--- 
a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/utils/PaimonConversions.java
+++ 
b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/utils/PaimonConversions.java
@@ -72,7 +72,6 @@ public class PaimonConversions {
     static {
         PAIMON_UNSETTABLE_OPTIONS.add(CoreOptions.BUCKET.key());
         PAIMON_UNSETTABLE_OPTIONS.add(CoreOptions.BUCKET_KEY.key());
-        PAIMON_UNSETTABLE_OPTIONS.add(CoreOptions.PATH.key());
         
PAIMON_UNSETTABLE_OPTIONS.add(PARTITION_GENERATE_LEGACY_NAME_OPTION_KEY);
     }
 
diff --git 
a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/LakeEnabledTableCreateITCase.java
 
b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/LakeEnabledTableCreateITCase.java
index 0efa057b8..52087992a 100644
--- 
a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/LakeEnabledTableCreateITCase.java
+++ 
b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/LakeEnabledTableCreateITCase.java
@@ -23,6 +23,7 @@ import org.apache.fluss.client.admin.Admin;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
 import org.apache.fluss.exception.FlussRuntimeException;
+import org.apache.fluss.exception.InvalidAlterTableException;
 import org.apache.fluss.exception.InvalidConfigException;
 import org.apache.fluss.exception.InvalidTableException;
 import org.apache.fluss.exception.LakeTableAlreadyExistException;
@@ -46,6 +47,7 @@ import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
@@ -390,6 +392,8 @@ class LakeEnabledTableCreateITCase {
 
     @Test
     void testCreateLakeEnableTableWithUnsettablePaimonOptions() {
+        
assertThat(PAIMON_UNSETTABLE_OPTIONS).doesNotContain(CoreOptions.PATH.key());
+
         Map<String, String> customProperties = new HashMap<>();
 
         for (String key : PAIMON_UNSETTABLE_OPTIONS) {
@@ -418,6 +422,138 @@ class LakeEnabledTableCreateITCase {
         }
     }
 
+    @Test
+    void testAlterPaimonPathOnlyWhenPaimonTableNotCreated() throws Exception {
+        TableDescriptor lakeDisabledTable =
+                TableDescriptor.builder()
+                        .schema(
+                                Schema.newBuilder()
+                                        .column("c1", DataTypes.INT())
+                                        .column("c2", DataTypes.STRING())
+                                        .build())
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED, false)
+                        .distributedBy(BUCKET_NUM, "c1", "c2")
+                        .build();
+        TablePath lakeDisabledTablePath = TablePath.of(DATABASE, 
"alter_paimon_path_disabled");
+        admin.createTable(lakeDisabledTablePath, lakeDisabledTable, 
false).get();
+
+        String customPaimonPath =
+                
Files.createTempDirectory("alter-paimon-path-disabled").toUri().toString();
+        admin.alterTable(
+                        lakeDisabledTablePath,
+                        
Collections.singletonList(TableChange.set("paimon.path", customPaimonPath)),
+                        false)
+                .get();
+        assertThat(
+                        admin.getTableInfo(lakeDisabledTablePath)
+                                .get()
+                                .toTableDescriptor()
+                                .getCustomProperties())
+                .containsEntry("paimon.path", customPaimonPath);
+        assertThatThrownBy(
+                        () ->
+                                paimonCatalog.getTable(
+                                        Identifier.create(
+                                                DATABASE, 
lakeDisabledTablePath.getTableName())))
+                .isInstanceOf(Catalog.TableNotExistException.class)
+                .hasMessageContaining(
+                        String.format(
+                                "Table %s.%s does not exist",
+                                DATABASE, 
lakeDisabledTablePath.getTableName()));
+
+        TableDescriptor lakeEnabledTable =
+                TableDescriptor.builder()
+                        .schema(
+                                Schema.newBuilder()
+                                        .column("c1", DataTypes.INT())
+                                        .column("c2", DataTypes.STRING())
+                                        .build())
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED, true)
+                        .distributedBy(BUCKET_NUM, "c1", "c2")
+                        .build();
+        TablePath lakeEnabledTablePath = TablePath.of(DATABASE, 
"alter_paimon_path_enabled");
+        admin.createTable(lakeEnabledTablePath, lakeEnabledTable, false).get();
+
+        Identifier lakeEnabledPaimonPath =
+                Identifier.create(DATABASE, 
lakeEnabledTablePath.getTableName());
+        FileStoreTable paimonTable = (FileStoreTable) 
paimonCatalog.getTable(lakeEnabledPaimonPath);
+        String paimonPath = paimonTable.location().toString();
+
+        admin.alterTable(
+                        lakeEnabledTablePath,
+                        Arrays.asList(
+                                TableChange.set("paimon.path", paimonPath + 
"/"),
+                                TableChange.set("key", "value")),
+                        false)
+                .get();
+
+        paimonTable = (FileStoreTable) 
paimonCatalog.getTable(lakeEnabledPaimonPath);
+        assertThat(paimonTable.location().toString()).isEqualTo(paimonPath);
+        assertThat(paimonTable.options()).containsEntry("fluss.key", "value");
+        long schemaId = paimonTable.schema().id();
+
+        admin.alterTable(
+                        lakeEnabledTablePath,
+                        
Collections.singletonList(TableChange.set("paimon.path", paimonPath)),
+                        false)
+                .get();
+
+        paimonTable = (FileStoreTable) 
paimonCatalog.getTable(lakeEnabledPaimonPath);
+        assertThat(paimonTable.location().toString()).isEqualTo(paimonPath);
+        assertThat(paimonTable.schema().id()).isEqualTo(schemaId);
+
+        assertThatThrownBy(
+                        () ->
+                                admin.alterTable(
+                                                lakeEnabledTablePath,
+                                                Collections.singletonList(
+                                                        TableChange.set(
+                                                                "paimon.path",
+                                                                
Files.createTempDirectory(
+                                                                               
 "alter-paimon-path-enabled")
+                                                                        
.toUri()
+                                                                        
.toString())),
+                                                false)
+                                        .get())
+                .cause()
+                .isInstanceOf(InvalidAlterTableException.class)
+                .hasMessageContaining(
+                        "'paimon.path' can only be altered before the Paimon 
table is created");
+
+        TablePath lakeReDisabledTablePath =
+                TablePath.of(DATABASE, 
"alter_paimon_path_created_then_disabled");
+        admin.createTable(lakeReDisabledTablePath, lakeEnabledTable, 
false).get();
+        Identifier lakeReDisabledPaimonPath =
+                Identifier.create(DATABASE, 
lakeReDisabledTablePath.getTableName());
+        paimonCatalog.getTable(lakeReDisabledPaimonPath);
+
+        admin.alterTable(
+                        lakeReDisabledTablePath,
+                        Collections.singletonList(
+                                TableChange.set(
+                                        
ConfigOptions.TABLE_DATALAKE_ENABLED.key(), "false")),
+                        false)
+                .get();
+
+        assertThatThrownBy(
+                        () ->
+                                admin.alterTable(
+                                                lakeReDisabledTablePath,
+                                                Collections.singletonList(
+                                                        TableChange.set(
+                                                                "paimon.path",
+                                                                
Files.createTempDirectory(
+                                                                               
 "alter-paimon-path-created")
+                                                                        
.toUri()
+                                                                        
.toString())),
+                                                false)
+                                        .get())
+                .cause()
+                .isInstanceOf(InvalidAlterTableException.class)
+                .hasMessageContaining(
+                        "'paimon.path' can only be altered before the Paimon 
table is created");
+    }
+
     @Test
     void testCreateLakeEnableTableWithExistLakeTable() throws Exception {
         Map<String, String> customProperties = new HashMap<>();

Reply via email to