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

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


The following commit(s) were added to refs/heads/master by this push:
     new d6ff29b7e8 [iceberg] Support custom table properties passthrough to 
Iceberg REST catalog (#9268)
d6ff29b7e8 is described below

commit d6ff29b7e8cb67815656fd55bd0664252b0e9916
Author: Oleksandr Nitavskyi <[email protected]>
AuthorDate: Thu Aug 20 04:18:19 2026 +0200

    [iceberg] Support custom table properties passthrough to Iceberg REST 
catalog (#9268)
---
 .../org/apache/paimon/iceberg/IcebergOptions.java  |  20 +++
 .../iceberg/IcebergRestMetadataCommitter.java      |  36 ++++++
 .../iceberg/IcebergRestMetadataCommitterTest.java  | 134 +++++++++++++++++++++
 3 files changed, 190 insertions(+)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergOptions.java 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergOptions.java
index c28716dc25..819865066d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergOptions.java
+++ b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergOptions.java
@@ -40,6 +40,8 @@ public class IcebergOptions {
 
     public static final String REST_CONFIG_PREFIX = "metadata.iceberg.rest.";
 
+    public static final String TABLE_PROPERTIES_PREFIX = 
"metadata.iceberg.table-properties.";
+
     public static final ConfigOption<StorageType> METADATA_ICEBERG_STORAGE =
             key("metadata.iceberg.storage")
                     .enumType(StorageType.class)
@@ -188,6 +190,24 @@ public class IcebergOptions {
         return restConfig;
     }
 
+    public Map<String, String> icebergTableProperties() {
+        Map<String, String> tableProperties = new HashMap<>();
+        options.keySet()
+                .forEach(
+                        key -> {
+                            if (key.startsWith(TABLE_PROPERTIES_PREFIX)) {
+                                String propertyKey =
+                                        
key.substring(TABLE_PROPERTIES_PREFIX.length());
+                                Preconditions.checkArgument(
+                                        !propertyKey.isEmpty(),
+                                        "config key '%s' for iceberg table 
property is empty!",
+                                        key);
+                                tableProperties.put(propertyKey, 
options.get(key));
+                            }
+                        });
+        return tableProperties;
+    }
+
     public boolean deleteAfterCommitEnabled() {
         return options.get(METADATA_DELETE_AFTER_COMMIT);
     }
diff --git 
a/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
 
b/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
index 643839625c..bb86c53a6f 100644
--- 
a/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
+++ 
b/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
@@ -65,6 +65,7 @@ import java.util.stream.Collectors;
 import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE;
 import static 
org.apache.iceberg.TableProperties.METADATA_DELETE_AFTER_COMMIT_ENABLED;
 import static 
org.apache.iceberg.TableProperties.METADATA_PREVIOUS_VERSIONS_MAX;
+import static org.apache.iceberg.TableProperties.RESERVED_PROPERTIES;
 
 /**
  * commit Iceberg metadata to Iceberg's rest catalog, so the table can be 
visited by Iceberg's rest
@@ -454,6 +455,12 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
     // Update Iceberg REST table properties from current IcebergOptions, but 
only
     // if the values differ from what the REST catalog already has. This avoids
     // emitting a redundant SetProperties update on every commit.
+    //
+    // This also merges in user-supplied custom properties 
(metadata.iceberg.table-properties.*),
+    // since setProperties() merges into the existing property map rather than 
replacing it
+    // (see TableMetadata.Builder#setProperties), so these persist across the 
create, recreate,
+    // and steady-state update paths that all route through this method via
+    // updatesForCorrectBase().
     private void updateProperties(TableMetadata.Builder update) {
         String desiredMax = 
String.valueOf(icebergOptions.previousVersionsMax());
         String desiredDeleteAfter = 
String.valueOf(icebergOptions.deleteAfterCommitEnabled());
@@ -464,14 +471,43 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
                         || !desiredDeleteAfter.equals(
                                 
current.get(METADATA_DELETE_AFTER_COMMIT_ENABLED));
 
+        Map<String, String> customProperties = customTableProperties();
+        for (Map.Entry<String, String> entry : customProperties.entrySet()) {
+            if (!entry.getValue().equals(current.get(entry.getKey()))) {
+                changed = true;
+                break;
+            }
+        }
+
         if (changed) {
             Map<String, String> properties = new HashMap<>();
             properties.put(METADATA_PREVIOUS_VERSIONS_MAX, desiredMax);
             properties.put(METADATA_DELETE_AFTER_COMMIT_ENABLED, 
desiredDeleteAfter);
+            properties.putAll(customProperties);
             update.setProperties(properties);
         }
     }
 
+    // Custom table properties requested via 
metadata.iceberg.table-properties.<key>, with
+    // Iceberg-reserved keys filtered out (Iceberg's TableMetadata rejects 
them outright).
+    private Map<String, String> customTableProperties() {
+        Map<String, String> customProperties = 
icebergOptions.icebergTableProperties();
+        Map<String, String> filtered = new HashMap<>();
+        customProperties.forEach(
+                (key, value) -> {
+                    if (RESERVED_PROPERTIES.contains(key)) {
+                        LOG.warn(
+                                "Ignoring custom Iceberg table property '{}' 
for table {}: "
+                                        + "it collides with an 
Iceberg-reserved property.",
+                                key,
+                                icebergTableIdentifier);
+                    } else {
+                        filtered.put(key, value);
+                    }
+                });
+        return filtered;
+    }
+
     // 
-------------------------------------------------------------------------------------
     // Utils
     // 
-------------------------------------------------------------------------------------
diff --git 
a/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
 
b/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
index f374c8c119..15782acdc5 100644
--- 
a/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
+++ 
b/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
@@ -495,6 +495,140 @@ public class IcebergRestMetadataCommitterTest {
         assertThat(icebergTable.currentSnapshot().schemaId()).isEqualTo(1);
     }
 
+    @Test
+    public void testCustomTablePropertiesPassthrough() throws Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.INT()}, new 
String[] {"k", "v"});
+        Map<String, String> customOptions = new HashMap<>();
+        customOptions.put(IcebergOptions.TABLE_PROPERTIES_PREFIX + 
"dd.table-color", "blue");
+        FileStoreTable table =
+                createPaimonTable(
+                        rowType,
+                        Collections.emptyList(),
+                        Collections.singletonList("k"),
+                        1,
+                        randomFormat(),
+                        customOptions);
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write = table.newWrite(commitUser);
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        // Custom property should be set on initial table creation.
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(false, 1));
+        Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb", 
"t"));
+        assertThat(icebergTable.properties()).containsEntry("dd.table-color", 
"blue");
+
+        // Custom property should persist across a follow-up commit.
+        write.write(GenericRow.of(2, 20));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        commit.commit(2, write.prepareCommit(true, 2));
+        icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb", "t"));
+        assertThat(icebergTable.properties()).containsEntry("dd.table-color", 
"blue");
+
+        write.close();
+        commit.close();
+    }
+
+    @Test
+    public void testCustomTablePropertyCollidingWithReservedKeyIsIgnored() 
throws Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.INT()}, new 
String[] {"k", "v"});
+        Map<String, String> customOptions = new HashMap<>();
+        // "format-version" is an Iceberg-reserved property key; Iceberg's 
TableMetadata
+        // rejects it if present in a SetProperties update, so it must be 
filtered out
+        // rather than crashing the commit.
+        customOptions.put(IcebergOptions.TABLE_PROPERTIES_PREFIX + 
"format-version", "99");
+        customOptions.put(IcebergOptions.TABLE_PROPERTIES_PREFIX + 
"dd.table-color", "green");
+        FileStoreTable table =
+                createPaimonTable(
+                        rowType,
+                        Collections.emptyList(),
+                        Collections.singletonList("k"),
+                        1,
+                        randomFormat(),
+                        customOptions);
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write = table.newWrite(commitUser);
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(false, 1));
+
+        Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb", 
"t"));
+        assertThat(icebergTable.properties()).containsEntry("dd.table-color", 
"green");
+        
assertThat(icebergTable.properties()).doesNotContainKey("format-version");
+
+        write.close();
+        commit.close();
+    }
+
+    @Test
+    public void testCustomTablePropertiesSurviveTableRecreate() throws 
Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.INT()}, new 
String[] {"k", "v"});
+        Map<String, String> customOptions = new HashMap<>();
+        customOptions.put(IcebergOptions.TABLE_PROPERTIES_PREFIX + 
"dd.table-color", "blue");
+        FileStoreTable table =
+                createPaimonTable(
+                        rowType,
+                        Collections.emptyList(),
+                        Collections.singletonList("k"),
+                        1,
+                        randomFormat(),
+                        customOptions);
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write = table.newWrite(commitUser);
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(false, 1));
+
+        write.write(GenericRow.of(1, 11));
+        write.write(GenericRow.of(3, 30));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        commit.commit(2, write.prepareCommit(true, 2));
+
+        // Disable and re-enable Iceberg compatibility, forcing the REST 
committer down the
+        // updatesForIncorrectBase() -> recreateTable() (drop-and-recreate) 
path on the next
+        // commit, since the base metadata Paimon last wrote is now stale.
+        Map<String, String> options = new HashMap<>();
+        options.put(IcebergOptions.METADATA_ICEBERG_STORAGE.key(), "disabled");
+        table = table.copy(options);
+        write.close();
+        write = table.newWrite(commitUser);
+        commit.close();
+        commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(4, 40));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        commit.commit(3, write.prepareCommit(true, 3));
+
+        options.put(IcebergOptions.METADATA_ICEBERG_STORAGE.key(), 
"rest-catalog");
+        table = table.copy(options);
+        write.close();
+        write = table.newWrite(commitUser);
+        commit.close();
+        commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(5, 50));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        commit.commit(4, write.prepareCommit(true, 4));
+
+        Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb", 
"t"));
+        assertThat(icebergTable.properties()).containsEntry("dd.table-color", 
"blue");
+
+        write.close();
+        commit.close();
+    }
+
     @Test
     public void testOptionOnlyAlterTableDoesNotCrashIcebergSync() throws 
Exception {
         // The fix deduplicates schemas in adjustMetadataForRest() and remaps

Reply via email to