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

loserwang1024 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new 9ea8bf028 [FLINK-41028] Support data lake table properties in the 
Fluss pipeline sink (#4584)
9ea8bf028 is described below

commit 9ea8bf0283ad774a56062ccf545168304c5c90d0
Author: Hongshun Wang <[email protected]>
AuthorDate: Fri Oct 23 17:37:28 2026 +0800

    [FLINK-41028] Support data lake table properties in the Fluss pipeline sink 
(#4584)
---
 .../docs/connectors/pipeline-connectors/fluss.md   | 32 ++++++++
 .../docs/connectors/pipeline-connectors/fluss.md   | 33 ++++++++
 .../fluss/factory/FlussDataSinkFactory.java        | 25 ++++--
 .../connectors/fluss/utils/FlussConversions.java   | 26 ++++--
 .../fluss/factory/FlussDataSinkFactoryTest.java    | 95 ++++++++++++++++++++--
 .../fluss/sink/FlussMetadataApplierTest.java       | 31 +++++++
 .../fluss/utils/FlussConversionsTest.java          | 10 ++-
 7 files changed, 234 insertions(+), 18 deletions(-)

diff --git a/docs/content.zh/docs/connectors/pipeline-connectors/fluss.md 
b/docs/content.zh/docs/connectors/pipeline-connectors/fluss.md
index 2d21ae1c7..10a6d553e 100644
--- a/docs/content.zh/docs/connectors/pipeline-connectors/fluss.md
+++ b/docs/content.zh/docs/connectors/pipeline-connectors/fluss.md
@@ -149,6 +149,13 @@ Pipeline Connector Options
       <td>String</td>
       <td>将 Fluss table 支持的参数传递给 pipeline,参考,See <a 
href="https://fluss.apache.org/docs/engine-flink/options/#storage-options";>Fluss
 table options</a>。</td>
     </tr>
+    <tr>
+      <td>properties.&lt;data-lake-format&gt;.*</td>
+      <td>optional</td>
+      <td style="word-wrap: break-word;">(none)</td>
+      <td>String</td>
+      <td>建表时向 Fluss 传递数据湖表参数。支持的前缀跟随 Fluss 客户端的数据湖格式,当前包括 
<code>properties.paimon.*</code>、<code>properties.iceberg.*</code>、<code>properties.hudi.*</code>
 和 <code>properties.lance.*</code>。仅移除最外层的 <code>properties.</code> 前缀,Flink 
CDC 不校验其中的具体参数。</td>
+    </tr>
     <tr>
       <td>properties.client.*</td>
       <td>optional</td>
@@ -186,6 +193,31 @@ sink:
 
 * 支持 Fluss 主键表和日志表。
 
+### 数据湖表参数
+
+要自动创建启用湖存储的 Fluss 表,可以使用以下 Sink 配置:
+
+```yaml
+sink:
+  type: fluss
+  bootstrap.servers: localhost:9123
+  properties.table.datalake.enabled: 'true'
+  properties.table.datalake.freshness: 30s
+  properties.paimon.file.format: orc
+  properties.paimon.deletion-vectors.enabled: 'true'
+```
+
+连接器仅移除 `properties.`,因此 `properties.paimon.file.format` 会作为
+`paimon.file.format` 传入 Fluss 表参数。使用 Iceberg 时,配置 `properties.iceberg.*`,
+例如 `properties.iceberg.write.format.default: parquet`。
+数据湖格式和分层服务需要在 Fluss 中配置,详见
+[Fluss 
湖存储文档](https://fluss.apache.org/docs/streaming-lakehouse/datalake-formats/paimon/)。
+
+这些参数对该 Sink 创建的所有表生效,不会修改已有表的属性。
+Flink CDC 允许支持的数据湖前缀下的任意参数名和值,由 Fluss 和对应的湖集成负责解释和校验;
+如果下游实现不拒绝未知参数,拼写错误也可能被忽略。
+不支持的前缀(例如 `properties.icerberg.*`)仍会被 Flink CDC 拒绝。
+
 ### 动态 Source 订阅
 
 当 Fluss 作为带表发现器的 Source 使用时,每次成功发现的结果都是当前订阅表的完整权威集合。配置正数
diff --git a/docs/content/docs/connectors/pipeline-connectors/fluss.md 
b/docs/content/docs/connectors/pipeline-connectors/fluss.md
index 6526fd00e..64f4a77f7 100644
--- a/docs/content/docs/connectors/pipeline-connectors/fluss.md
+++ b/docs/content/docs/connectors/pipeline-connectors/fluss.md
@@ -152,6 +152,13 @@ Pipeline Connector Options
       <td>String</td>
       <td>Pass options of Fluss table to pipeline. See <a 
href="https://fluss.apache.org/docs/engine-flink/options/#storage-options";>Fluss
 table options</a>. </td>
     </tr>
+    <tr>
+      <td>properties.&lt;data-lake-format&gt;.*</td>
+      <td>optional</td>
+      <td style="word-wrap: break-word;">(none)</td>
+      <td>String</td>
+      <td>Pass data lake table properties to Fluss when creating tables. 
Supported prefixes follow the Fluss client's data lake formats, currently 
<code>properties.paimon.*</code>, <code>properties.iceberg.*</code>, 
<code>properties.hudi.*</code>, and <code>properties.lance.*</code>. Only the 
outer <code>properties.</code> prefix is removed. Individual options are not 
validated by Flink CDC.</td>
+    </tr>
     <tr>
       <td>properties.client.*</td>
       <td>optional</td>
@@ -191,6 +198,32 @@ Continue to configure bucket keys and bucket counts with 
the sink options `bucke
 
 * Support Fluss primary key table and log table.
 
+### Data lake table properties
+
+To create Fluss tables with lakehouse storage enabled, configure the Fluss 
sink as follows:
+
+```yaml
+sink:
+  type: fluss
+  bootstrap.servers: localhost:9123
+  properties.table.datalake.enabled: 'true'
+  properties.table.datalake.freshness: 30s
+  properties.paimon.file.format: orc
+  properties.paimon.deletion-vectors.enabled: 'true'
+```
+
+The connector removes only `properties.`, so `properties.paimon.file.format` 
becomes
+`paimon.file.format` in the Fluss table properties. For Iceberg, use 
`properties.iceberg.*`,
+for example `properties.iceberg.write.format.default: parquet`.
+The lake format and tiering service must be configured in Fluss; see the
+[Fluss lakehouse 
documentation](https://fluss.apache.org/docs/streaming-lakehouse/datalake-formats/paimon/).
+
+These properties apply to all tables created by this sink. They do not alter 
properties of
+existing tables. Flink CDC accepts arbitrary option names and values under 
supported data lake
+prefixes and delegates their interpretation and validation to Fluss and the 
lake integration;
+an unrecognized option may be ignored if the downstream implementation does 
not reject it.
+Unsupported prefixes, such as `properties.icerberg.*`, are still rejected by 
Flink CDC.
+
 ### Dynamic source subscriptions
 
 When Fluss is used as a source with a table discoverer, each successful 
discovery result is the
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactory.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactory.java
index 455bbdc17..d3797feee 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactory.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactory.java
@@ -25,12 +25,15 @@ import 
org.apache.flink.cdc.connectors.fluss.sink.FlussDataSink;
 
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.DataLakeFormat;
 
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Stream;
 
 import static 
org.apache.flink.cdc.connectors.fluss.sink.FlussDataSinkOptions.BOOTSTRAP_SERVERS;
 import static 
org.apache.flink.cdc.connectors.fluss.sink.FlussDataSinkOptions.BUCKET_KEY;
@@ -45,10 +48,22 @@ import static 
org.apache.flink.cdc.connectors.fluss.utils.FlussConfigUtils.parse
 public class FlussDataSinkFactory implements DataSinkFactory {
     public static final String IDENTIFIER = "fluss";
 
+    private static final String PROPERTIES_PREFIX = "properties.";
+    private static final String[] TABLE_PROPERTIES_PREFIXES =
+            Stream.concat(
+                            Stream.of(TABLE_PROPERTIES_PREFIX),
+                            Arrays.stream(DataLakeFormat.values())
+                                    .map(format -> PROPERTIES_PREFIX + format 
+ "."))
+                    .toArray(String[]::new);
+
     @Override
     public DataSink createDataSink(Context context) {
         FactoryHelper.createFactoryHelper(this, context)
-                .validateExcept(CLIENT_PROPERTIES_PREFIX, 
TABLE_PROPERTIES_PREFIX);
+                .validateExcept(
+                        Stream.concat(
+                                        Stream.of(CLIENT_PROPERTIES_PREFIX),
+                                        
Arrays.stream(TABLE_PROPERTIES_PREFIXES))
+                                .toArray(String[]::new));
         org.apache.flink.cdc.common.configuration.Configuration 
factoryConfiguration =
                 context.getFactoryConfiguration();
 
@@ -96,7 +111,7 @@ public class FlussDataSinkFactory implements DataSinkFactory 
{
         tableOptions.forEach(
                 (key, value) -> {
                     if (key.startsWith(CLIENT_PROPERTIES_PREFIX)) {
-                        
flussConfig.setString(key.substring("properties.".length()), value);
+                        
flussConfig.setString(key.substring(PROPERTIES_PREFIX.length()), value);
                     }
                 });
         return flussConfig;
@@ -104,11 +119,11 @@ public class FlussDataSinkFactory implements 
DataSinkFactory {
 
     private static Map<String, String> toFlussTableOptions(Map<String, String> 
tableOptions) {
         Map<String, String> flussTableConfig = new HashMap<>();
-        // forward all client configs
+        // Forward Fluss and data lake table properties without validating 
individual options.
         tableOptions.forEach(
                 (key, value) -> {
-                    if (key.startsWith(TABLE_PROPERTIES_PREFIX)) {
-                        
flussTableConfig.put(key.substring("properties.".length()), value);
+                    if 
(Arrays.stream(TABLE_PROPERTIES_PREFIXES).anyMatch(key::startsWith)) {
+                        
flussTableConfig.put(key.substring(PROPERTIES_PREFIX.length()), value);
                     }
                 });
         return flussTableConfig;
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversions.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversions.java
index 35bef53a4..23dcc0f9f 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversions.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversions.java
@@ -42,12 +42,14 @@ import org.apache.flink.cdc.common.types.ZonedTimestampType;
 import org.apache.flink.cdc.common.utils.Preconditions;
 import org.apache.flink.util.CollectionUtil;
 
+import org.apache.fluss.metadata.DataLakeFormat;
 import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.TableDescriptor;
 
 import javax.annotation.Nullable;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -177,13 +179,23 @@ public class FlussConversions {
                             .orElse(Collections.emptyList());
         }
 
-        return TableDescriptor.builder()
-                .schema(schema)
-                .partitionedBy(cdcSchema.partitionKeys())
-                .distributedBy(bucketNum, bucketKeys)
-                .comment(cdcSchema.comment())
-                .properties(tableProperties)
-                .build();
+        TableDescriptor.Builder builder =
+                TableDescriptor.builder()
+                        .schema(schema)
+                        .partitionedBy(cdcSchema.partitionKeys())
+                        .distributedBy(bucketNum, bucketKeys)
+                        .comment(cdcSchema.comment());
+        tableProperties.forEach(
+                (key, value) -> {
+                    if (Arrays.stream(DataLakeFormat.values())
+                            .anyMatch(format -> key.startsWith(format + "."))) 
{
+                        // Lake options must use custom properties to pass 
Fluss table validation.
+                        builder.customProperty(key, value);
+                    } else {
+                        builder.property(key, value);
+                    }
+                });
+        return builder.build();
     }
 
     public static org.apache.fluss.metadata.Schema toFlussSchema(
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactoryTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactoryTest.java
index 7b287d122..3a75fbcb5 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactoryTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/factory/FlussDataSinkFactoryTest.java
@@ -33,11 +33,17 @@ import 
org.apache.flink.shaded.guava31.com.google.common.collect.ImmutableMap;
 
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Test for {@link FlussDataSinkFactory}. */
-public class FlussDataSinkFactoryTest {
+class FlussDataSinkFactoryTest {
     @Test
     void testCreateDataSink() {
         DataSinkFactory sinkFactory =
@@ -70,8 +76,20 @@ public class FlussDataSinkFactoryTest {
                 .isInstanceOf(ForwardHashFunctionProvider.class);
     }
 
-    @Test
-    void testUnsupportedOption() {
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                "unsupported_key",
+                "properties.unsupported.option",
+                "properties.paimon",
+                "properties.paimonx.option",
+                "properties.iceberg",
+                "properties.icebergx.option",
+                "properties.icerberg.option",
+                "paimon.file.format",
+                "iceberg.write.format.default"
+            })
+    void testUnsupportedOption(String option) {
         DataSinkFactory sinkFactory =
                 FactoryDiscoveryUtils.getFactoryByIdentifier("fluss", 
DataSinkFactory.class);
         
Assertions.assertThat(sinkFactory).isInstanceOf(FlussDataSinkFactory.class);
@@ -80,7 +98,7 @@ public class FlussDataSinkFactoryTest {
                 Configuration.fromMap(
                         ImmutableMap.<String, String>builder()
                                 
.put(FlussDataSinkOptions.BOOTSTRAP_SERVERS.key(), "localhost:9123")
-                                .put("unsupported_key", "unsupported_value")
+                                .put(option, "unsupported_value")
                                 .build());
 
         Assertions.assertThatThrownBy(
@@ -94,7 +112,74 @@ public class FlussDataSinkFactoryTest {
                 .hasMessageContaining(
                         "Unsupported options found for 'fluss'.\n\n"
                                 + "Unsupported options:\n\n"
-                                + "unsupported_key");
+                                + option);
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "paimon.file.format, orc",
+        "paimon.deletion-vectors.enabled, true",
+        "paimon.unknown-option, unknown-value",
+        "iceberg.write.format.default, parquet",
+        "iceberg.unknown-option, unknown-value",
+        "hudi.unknown-option, unknown-value",
+        "lance.unknown-option, unknown-value"
+    })
+    void testDataLakeProperties(String option, String value) {
+        Configuration conf =
+                Configuration.fromMap(
+                        ImmutableMap.<String, String>builder()
+                                
.put(FlussDataSinkOptions.BOOTSTRAP_SERVERS.key(), "localhost:9123")
+                                .put("properties.table.datalake.enabled", 
"true")
+                                .put("properties.table.datalake.freshness", 
"30s")
+                                .put("properties." + option, value)
+                                .put("properties.client.writer.acks", "all")
+                                .build());
+
+        DataSink dataSink =
+                new FlussDataSinkFactory()
+                        .createDataSink(
+                                new FactoryHelper.DefaultContext(
+                                        conf,
+                                        conf,
+                                        
Thread.currentThread().getContextClassLoader()));
+
+        Map<String, String> expectedTableProperties =
+                ImmutableMap.of(
+                        "table.datalake.enabled",
+                        "true",
+                        "table.datalake.freshness",
+                        "30s",
+                        option,
+                        value);
+        assertThat(dataSink.getMetadataApplier())
+                .extracting("tableProperties")
+                .isEqualTo(expectedTableProperties);
+        assertThat(dataSink)
+                .extracting("flussClientConfig")
+                .isEqualTo(
+                        org.apache.fluss.config.Configuration.fromMap(
+                                ImmutableMap.of(
+                                        "bootstrap.servers", "localhost:9123",
+                                        "client.writer.acks", "all")));
+    }
+
+    @Test
+    void testDataLakePropertiesRequireBootstrapServers() {
+        Configuration conf =
+                
Configuration.fromMap(ImmutableMap.of("properties.paimon.file.format", "orc"));
+
+        assertThatThrownBy(
+                        () ->
+                                new FlussDataSinkFactory()
+                                        .createDataSink(
+                                                new 
FactoryHelper.DefaultContext(
+                                                        conf,
+                                                        conf,
+                                                        Thread.currentThread()
+                                                                
.getContextClassLoader())))
+                .isInstanceOf(ValidationException.class)
+                .hasMessageContaining("Missing required options 
are:\n\nbootstrap.servers");
     }
 
     @Test
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
index ed2ab81fc..2fb83b0b5 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
@@ -1174,6 +1174,37 @@ class FlussMetadataApplierTest {
         }
     }
 
+    @Test
+    void testCreateTableWithDataLakeProperties() throws Exception {
+        Map<String, String> tableProperties = new HashMap<>();
+        tableProperties.put("paimon.file.format", "orc");
+        tableProperties.put("iceberg.write.format.default", "parquet");
+        Schema schema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.BIGINT().notNull())
+                        .primaryKey("id")
+                        .build();
+        TableId tableId = TableId.tableId(DATABASE_NAME, "table1");
+
+        try (FlussMetaDataApplier applier =
+                new FlussMetaDataApplier(
+                        FLUSS_CLUSTER_EXTENSION.getClientConfig(),
+                        tableProperties,
+                        Collections.emptyMap(),
+                        Collections.emptyMap())) {
+            applier.applySchemaChange(new CreateTableEvent(tableId, schema));
+
+            TableDescriptor tableDescriptor =
+                    admin.getTableInfo(new TablePath(DATABASE_NAME, "table1"))
+                            .get()
+                            .toTableDescriptor();
+            assertThat(tableDescriptor.getCustomProperties())
+                    .containsExactlyInAnyOrderEntriesOf(tableProperties);
+            assertThat(tableDescriptor.getProperties())
+                    .doesNotContainKeys("paimon.file.format", 
"iceberg.write.format.default");
+        }
+    }
+
     @Test
     void testCreateTableWithTableOptions() throws Exception {
         Schema schema =
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversionsTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversionsTest.java
index ba5535477..8a9b3798f 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversionsTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/utils/FlussConversionsTest.java
@@ -219,11 +219,19 @@ class FlussConversionsTest {
 
         Map<String, String> properties = new HashMap<>();
         properties.put("table.replication.factor", "3");
+        properties.put("paimon.file.format", "orc");
+        properties.put("iceberg.write.format.default", "parquet");
 
         TableDescriptor descriptor =
                 FlussConversions.toFlussTable(cdcSchema, null, null, 
properties);
 
-        
assertThat(descriptor.getProperties()).containsEntry("table.replication.factor",
 "3");
+        assertThat(descriptor.getProperties())
+                .containsExactlyEntriesOf(
+                        Collections.singletonMap("table.replication.factor", 
"3"));
+        assertThat(descriptor.getCustomProperties())
+                .hasSize(2)
+                .containsEntry("paimon.file.format", "orc")
+                .containsEntry("iceberg.write.format.default", "parquet");
     }
 
     @Test

Reply via email to