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 730407d4e [lake/hudi] Introduce Hudi LakeCatalog to create table 
(#3395)
730407d4e is described below

commit 730407d4e02cacb47ff2df3c9568ffd048258cae
Author: fhan <[email protected]>
AuthorDate: Wed Jun 3 19:24:00 2026 +0800

    [lake/hudi] Introduce Hudi LakeCatalog to create table (#3395)
---
 fluss-lake/fluss-lake-hudi/pom.xml                 |  98 +++-
 .../lake/hudi/FlussDataTypeToHudiDataType.java     | 189 +++++++
 .../apache/fluss/lake/hudi/HudiLakeCatalog.java    | 278 +++++++++
 .../apache/fluss/lake/hudi/HudiLakeStorage.java    |   5 +-
 .../fluss/lake/hudi/utils/HudiConversions.java     | 310 ++++++++++
 .../lake/hudi/utils/catalog/HudiCatalogUtils.java  | 101 ++++
 .../fluss/lake/hudi/HudiLakeCatalogTest.java       | 625 +++++++++++++++++++++
 fluss-lake/pom.xml                                 |   5 +
 8 files changed, 1605 insertions(+), 6 deletions(-)

diff --git a/fluss-lake/fluss-lake-hudi/pom.xml 
b/fluss-lake/fluss-lake-hudi/pom.xml
index d4c5070e1..0f2bc76d3 100644
--- a/fluss-lake/fluss-lake-hudi/pom.xml
+++ b/fluss-lake/fluss-lake-hudi/pom.xml
@@ -36,7 +36,7 @@
             <groupId>org.apache.hudi</groupId>
             <artifactId>hudi-flink${flink.major.version}-bundle</artifactId>
             <version>${hudi.version}</version>
-            <scope>test</scope>
+            <scope>provided</scope>
             <exclusions>
                 <exclusion>
                     <groupId>log4j</groupId>
@@ -69,12 +69,106 @@
             </exclusions>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.hadoop</groupId>
+            <artifactId>hadoop-common</artifactId>
+            <version>${fluss.hadoop.version}</version>
+            <scope>provided</scope>
+            <exclusions>
+                <exclusion>
+                    <artifactId>avro</artifactId>
+                    <groupId>org.apache.avro</groupId>
+                </exclusion>
+                <exclusion>
+                    <artifactId>log4j</artifactId>
+                    <groupId>log4j</groupId>
+                </exclusion>
+                <exclusion>
+                    <artifactId>slf4j-log4j12</artifactId>
+                    <groupId>org.slf4j</groupId>
+                </exclusion>
+                <exclusion>
+                    <groupId>ch.qos.reload4j</groupId>
+                    <artifactId>reload4j</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-reload4j</artifactId>
+                </exclusion>
+                <exclusion>
+                    <artifactId>jdk.tools</artifactId>
+                    <groupId>jdk.tools</groupId>
+                </exclusion>
+                <exclusion>
+                    <artifactId>protobuf-java</artifactId>
+                    <groupId>com.google.protobuf</groupId>
+                </exclusion>
+                <exclusion>
+                    <artifactId>commons-io</artifactId>
+                    <groupId>commons-io</groupId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.commons</groupId>
+                    <artifactId>commons-lang3</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.curator</groupId>
+                    <artifactId>curator-client</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.curator</groupId>
+                    <artifactId>curator-framework</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.curator</groupId>
+                    <artifactId>curator-recipes</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.zookeeper</groupId>
+                    <artifactId>zookeeper</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.fluss</groupId>
             <artifactId>fluss-common</artifactId>
             <version>${project.version}</version>
             <scope>provided</scope>
         </dependency>
+
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-table-common</artifactId>
+            <scope>provided</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.apache.commons</groupId>
+                    <artifactId>commons-lang3</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-table-api-java</artifactId>
+            <version>${flink.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- test dependency -->
+        <dependency>
+            <groupId>org.apache.fluss</groupId>
+            <artifactId>fluss-common</artifactId>
+            <version>${project.version}</version>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.fluss</groupId>
+            <artifactId>fluss-test-utils</artifactId>
+        </dependency>
     </dependencies>
 
-</project>
\ No newline at end of file
+</project>
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/FlussDataTypeToHudiDataType.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/FlussDataTypeToHudiDataType.java
new file mode 100644
index 000000000..820e0ea20
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/FlussDataTypeToHudiDataType.java
@@ -0,0 +1,189 @@
+/*
+ * 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.fluss.lake.hudi;
+
+import org.apache.fluss.types.ArrayType;
+import org.apache.fluss.types.BigIntType;
+import org.apache.fluss.types.BinaryType;
+import org.apache.fluss.types.BooleanType;
+import org.apache.fluss.types.BytesType;
+import org.apache.fluss.types.CharType;
+import org.apache.fluss.types.DataTypeVisitor;
+import org.apache.fluss.types.DateType;
+import org.apache.fluss.types.DecimalType;
+import org.apache.fluss.types.DoubleType;
+import org.apache.fluss.types.FloatType;
+import org.apache.fluss.types.IntType;
+import org.apache.fluss.types.LocalZonedTimestampType;
+import org.apache.fluss.types.MapType;
+import org.apache.fluss.types.RowType;
+import org.apache.fluss.types.SmallIntType;
+import org.apache.fluss.types.StringType;
+import org.apache.fluss.types.TimeType;
+import org.apache.fluss.types.TimestampType;
+import org.apache.fluss.types.TinyIntType;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.types.DataType;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+import static 
org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils.FILE_SYSTEM_TYPE;
+import static 
org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils.HIVE_META_STORE_TYPE;
+
+/**
+ * Converter from Fluss's data type to Flink's internal data type used by Hudi.
+ *
+ * <p>Hudi's Flink catalog accepts Flink {@link DataType} through Flink 
catalog APIs and then
+ * handles the conversion to Hudi's internal schema itself. Therefore, Fluss 
only needs to bridge
+ * its type system to Flink's type system here.
+ */
+@ThreadSafe
+public class FlussDataTypeToHudiDataType implements DataTypeVisitor<DataType> {
+
+    private final String catalogMode;
+
+    private FlussDataTypeToHudiDataType(String catalogMode) {
+        this.catalogMode = catalogMode;
+    }
+
+    public static final FlussDataTypeToHudiDataType DFS_INSTANCE =
+            new FlussDataTypeToHudiDataType(FILE_SYSTEM_TYPE);
+    public static final FlussDataTypeToHudiDataType HMS_INSTANCE =
+            new FlussDataTypeToHudiDataType(HIVE_META_STORE_TYPE);
+
+    @Override
+    public DataType visit(CharType charType) {
+        return withNullability(DataTypes.STRING(), charType.isNullable());
+    }
+
+    @Override
+    public DataType visit(StringType stringType) {
+        return withNullability(DataTypes.STRING(), stringType.isNullable());
+    }
+
+    @Override
+    public DataType visit(BooleanType booleanType) {
+        return withNullability(DataTypes.BOOLEAN(), booleanType.isNullable());
+    }
+
+    @Override
+    public DataType visit(BinaryType binaryType) {
+        return withNullability(DataTypes.BINARY(binaryType.getLength()), 
binaryType.isNullable());
+    }
+
+    @Override
+    public DataType visit(BytesType bytesType) {
+        return withNullability(DataTypes.BYTES(), bytesType.isNullable());
+    }
+
+    @Override
+    public DataType visit(DecimalType decimalType) {
+        return withNullability(
+                DataTypes.DECIMAL(decimalType.getPrecision(), 
decimalType.getScale()),
+                decimalType.isNullable());
+    }
+
+    @Override
+    public DataType visit(TinyIntType tinyIntType) {
+        // Hudi IntType covers 8/16/32-bit integers (consistent with internal 
conversion logic)
+        return withNullability(DataTypes.TINYINT(), tinyIntType.isNullable());
+    }
+
+    @Override
+    public DataType visit(SmallIntType smallIntType) {
+        return withNullability(DataTypes.SMALLINT(), 
smallIntType.isNullable());
+    }
+
+    @Override
+    public DataType visit(IntType intType) {
+        return withNullability(DataTypes.INT(), intType.isNullable());
+    }
+
+    @Override
+    public DataType visit(BigIntType bigIntType) {
+        return withNullability(DataTypes.BIGINT(), bigIntType.isNullable());
+    }
+
+    @Override
+    public DataType visit(FloatType floatType) {
+        return withNullability(DataTypes.FLOAT(), floatType.isNullable());
+    }
+
+    @Override
+    public DataType visit(DoubleType doubleType) {
+        return withNullability(DataTypes.DOUBLE(), doubleType.isNullable());
+    }
+
+    @Override
+    public DataType visit(DateType dateType) {
+        return withNullability(DataTypes.DATE(), dateType.isNullable());
+    }
+
+    @Override
+    public DataType visit(TimeType timeType) {
+        return withNullability(DataTypes.TIME(timeType.getPrecision()), 
timeType.isNullable());
+    }
+
+    @Override
+    public DataType visit(TimestampType timestampType) {
+        return withNullability(
+                DataTypes.TIMESTAMP(timestampType.getPrecision()), 
timestampType.isNullable());
+    }
+
+    @Override
+    public DataType visit(LocalZonedTimestampType localZonedTimestampType) {
+        return HIVE_META_STORE_TYPE.equals(catalogMode)
+                ? withNullability(DataTypes.BIGINT(), 
localZonedTimestampType.isNullable())
+                : withNullability(
+                        DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(),
+                        localZonedTimestampType.isNullable());
+    }
+
+    @Override
+    public DataType visit(ArrayType arrayType) {
+        DataType elementType = arrayType.getElementType().accept(this);
+        return withNullability(DataTypes.ARRAY(elementType), 
arrayType.isNullable());
+    }
+
+    @Override
+    public DataType visit(MapType mapType) {
+        DataType keyType = mapType.getKeyType().accept(this);
+        DataType valueType = mapType.getValueType().accept(this);
+        return withNullability(DataTypes.MAP(keyType, valueType), 
mapType.isNullable());
+    }
+
+    @Override
+    public DataType visit(RowType rowType) {
+        DataTypes.Field[] fields = new 
DataTypes.Field[rowType.getFieldCount()];
+
+        for (int i = 0; i < rowType.getFieldCount(); i++) {
+            String fieldName = rowType.getFieldNames().get(i);
+            DataType fieldType = rowType.getTypeAt(i).accept(this);
+            fields[i] = DataTypes.FIELD(fieldName, fieldType);
+        }
+        return withNullability(DataTypes.ROW(fields), rowType.isNullable());
+    }
+
+    private DataType withNullability(DataType flinkDataType, boolean nullable) 
{
+        if (flinkDataType.getLogicalType().isNullable() != nullable) {
+            return nullable ? flinkDataType.nullable() : 
flinkDataType.notNull();
+        }
+        return flinkDataType;
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeCatalog.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeCatalog.java
new file mode 100644
index 000000000..796b7fdfc
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeCatalog.java
@@ -0,0 +1,278 @@
+/*
+ * 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.fluss.lake.hudi;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.lake.hudi.utils.HudiConversions;
+import org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils;
+import org.apache.fluss.lake.lakestorage.LakeCatalog;
+import org.apache.fluss.metadata.TableChange;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.utils.IOUtils;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.catalog.Catalog;
+import org.apache.flink.table.catalog.CatalogBaseTable;
+import org.apache.flink.table.catalog.CatalogDatabase;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ObjectPath;
+import org.apache.flink.table.catalog.ResolvedCatalogBaseTable;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.catalog.exceptions.DatabaseAlreadyExistException;
+import org.apache.flink.table.catalog.exceptions.DatabaseNotExistException;
+import org.apache.flink.table.catalog.exceptions.TableNotExistException;
+import org.apache.flink.table.types.AbstractDataType;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.hudi.table.catalog.CatalogOptions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Objects;
+
+import static 
org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils.HIVE_META_STORE_TYPE;
+import static 
org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils.HUDI_CATALOG_DEFAULT_NAME;
+import static org.apache.fluss.metadata.TableDescriptor.BUCKET_COLUMN_NAME;
+import static org.apache.fluss.metadata.TableDescriptor.OFFSET_COLUMN_NAME;
+import static org.apache.fluss.metadata.TableDescriptor.TIMESTAMP_COLUMN_NAME;
+
+/** Implementation of {@link LakeCatalog} for Hudi. */
+public class HudiLakeCatalog implements LakeCatalog {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(HudiLakeCatalog.class);
+
+    public static final LinkedHashMap<String, DataType> SYSTEM_COLUMNS = new 
LinkedHashMap<>();
+
+    static {
+        SYSTEM_COLUMNS.put(BUCKET_COLUMN_NAME, DataTypes.INT());
+        SYSTEM_COLUMNS.put(OFFSET_COLUMN_NAME, DataTypes.BIGINT());
+        SYSTEM_COLUMNS.put(TIMESTAMP_COLUMN_NAME, DataTypes.TIMESTAMP(6));
+    }
+
+    private final Catalog hudiCatalog;
+    private final String catalogMode;
+
+    public HudiLakeCatalog(Configuration configuration) {
+        this.catalogMode =
+                configuration.toMap().getOrDefault(CatalogOptions.MODE.key(), 
HIVE_META_STORE_TYPE);
+        this.hudiCatalog = HudiCatalogUtils.createHudiCatalog(configuration);
+        this.hudiCatalog.open();
+    }
+
+    @VisibleForTesting
+    protected Catalog getHudiCatalog() {
+        return hudiCatalog;
+    }
+
+    @Override
+    public void createTable(TablePath tablePath, TableDescriptor 
tableDescriptor, Context context)
+            throws org.apache.fluss.exception.TableAlreadyExistException {
+        LOG.info("create the lake table for : {} with props: {}", tablePath, 
tableDescriptor);
+
+        ObjectPath objectPath = HudiConversions.toHudiObjectPath(tablePath);
+
+        boolean isPkTable = 
tableDescriptor.getSchema().getPrimaryKeyIndexes().length > 0;
+
+        // Hudi's Flink catalog creates tables from Flink CatalogTable 
objects, so bridge the
+        // Fluss descriptor into Flink/Hudi schema and options before 
delegating to Hudi.
+        CatalogTable catalogTable =
+                HudiConversions.createHudiCatalogTable(
+                        tablePath, tableDescriptor, isPkTable, catalogMode);
+
+        // Create table in Hudi catalog
+        try {
+            createTable(objectPath, catalogTable, 
context.isCreatingFlussTable());
+        } catch (DatabaseNotExistException e) {
+            createDatabase(tablePath.getDatabaseName());
+            try {
+                createTable(objectPath, catalogTable, 
context.isCreatingFlussTable());
+            } catch (DatabaseNotExistException t) {
+                // shouldn't happen in normal cases
+                throw new RuntimeException(
+                        String.format(
+                                "Fail to create table %s in Hudi, because "
+                                        + "Database %s still doesn't exist 
although create database "
+                                        + "successfully, please try again.",
+                                tablePath, tablePath.getDatabaseName()),
+                        t);
+            }
+        }
+    }
+
+    @Override
+    public void alterTable(TablePath tablePath, List<TableChange> 
tableChanges, Context context)
+            throws org.apache.fluss.exception.TableNotExistException {
+        throw new UnsupportedOperationException(
+                "Alter table is not supported for Hudi at the moment");
+    }
+
+    private void createTable(
+            ObjectPath tablePath, CatalogBaseTable catalogTable, boolean 
isCreatingFlussTable)
+            throws DatabaseNotExistException {
+        try {
+            hudiCatalog.createTable(tablePath, catalogTable, false);
+            LOG.info("Table {} created successfully.", tablePath);
+        } catch 
(org.apache.flink.table.catalog.exceptions.TableAlreadyExistException e) {
+            // table already exists, check schema compatibility for idempotency
+            try {
+                CatalogBaseTable existingTable = 
hudiCatalog.getTable(tablePath);
+                if (!isHudiSchemaCompatible(existingTable, catalogTable)) {
+                    throw new 
org.apache.fluss.exception.TableAlreadyExistException(
+                            String.format(
+                                    "The table %s already exists in Hudi 
catalog, but the table schema is not compatible. "
+                                            + "Please first drop the table in 
Hudi catalog or use a new table name.",
+                                    tablePath),
+                            e);
+                }
+                // if creating a new fluss table, we should ensure the lake 
table is empty
+                if (isCreatingFlussTable) {
+                    throw new 
org.apache.fluss.exception.TableAlreadyExistException(
+                            String.format(
+                                    "The table %s already exists in Hudi 
catalog with compatible schema, "
+                                            + "but Fluss cannot verify whether 
the existing Hudi table is empty yet. "
+                                            + "Please first drop the table in 
Hudi catalog or use a new table name.",
+                                    tablePath),
+                            e);
+                }
+            } catch (TableNotExistException tableNotExistException) {
+                // shouldn't happen in normal cases
+                throw new RuntimeException(
+                        String.format(
+                                "Failed to create table %s in Hudi. The table 
already existed "
+                                        + "during the initial creation 
attempt, but subsequently "
+                                        + "could not be found when trying to 
get it. "
+                                        + "Please check whether the Hudi table 
was manually deleted, and try again.",
+                                tablePath),
+                        tableNotExistException);
+            }
+        }
+    }
+
+    /**
+     * Checks whether the existing Hudi table schema is compatible with the 
expected schema.
+     *
+     * <p>Compatibility means the column names, types, and nullability match. 
This is used for
+     * crash-recovery idempotency: if the table already exists with a 
compatible schema, the
+     * creation is considered successful.
+     */
+    @VisibleForTesting
+    boolean isHudiSchemaCompatible(CatalogBaseTable existingTable, 
CatalogBaseTable expectedTable) {
+        return 
extractColumns(existingTable).equals(extractColumns(expectedTable));
+    }
+
+    private static List<ColumnSignature> extractColumns(CatalogBaseTable 
table) {
+        if (table instanceof ResolvedCatalogBaseTable) {
+            ResolvedSchema resolvedSchema =
+                    ((ResolvedCatalogBaseTable<?>) table).getResolvedSchema();
+            List<ColumnSignature> columns = new ArrayList<>();
+            for (Column column : resolvedSchema.getColumns()) {
+                columns.add(
+                        new ColumnSignature(
+                                column.getName(), 
column.getDataType().getLogicalType()));
+            }
+            return columns;
+        }
+
+        Schema schema = table.getUnresolvedSchema();
+        List<ColumnSignature> columns = new ArrayList<>();
+        for (Schema.UnresolvedColumn column : schema.getColumns()) {
+            AbstractDataType<?> dataType = getDataType(column);
+            columns.add(new ColumnSignature(column.getName(), 
getLogicalType(dataType)));
+        }
+        return columns;
+    }
+
+    private static AbstractDataType<?> getDataType(Schema.UnresolvedColumn 
column) {
+        if (column instanceof Schema.UnresolvedPhysicalColumn) {
+            return ((Schema.UnresolvedPhysicalColumn) column).getDataType();
+        } else if (column instanceof Schema.UnresolvedMetadataColumn) {
+            return ((Schema.UnresolvedMetadataColumn) column).getDataType();
+        }
+        throw new IllegalStateException("Unexpected column kind: " + 
column.getClass());
+    }
+
+    private static LogicalType getLogicalType(AbstractDataType<?> dataType) {
+        if (dataType instanceof DataType) {
+            return ((DataType) dataType).getLogicalType();
+        }
+        throw new IllegalArgumentException("Unsupported Hudi column data type: 
" + dataType);
+    }
+
+    private static class ColumnSignature {
+        private final String name;
+        private final LogicalType dataType;
+
+        private ColumnSignature(String name, LogicalType dataType) {
+            this.name = name;
+            this.dataType = dataType;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (!(o instanceof ColumnSignature)) {
+                return false;
+            }
+            ColumnSignature that = (ColumnSignature) o;
+            return Objects.equals(name, that.name) && Objects.equals(dataType, 
that.dataType);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name, dataType);
+        }
+    }
+
+    @Override
+    public void close() {
+        if (hudiCatalog != null && hudiCatalog instanceof AutoCloseable) {
+            IOUtils.closeQuietly((AutoCloseable) hudiCatalog, 
HUDI_CATALOG_DEFAULT_NAME);
+        }
+    }
+
+    public void createDatabase(String databaseName) {
+        try {
+            if (!hudiCatalog.databaseExists(databaseName)) {
+                CatalogDatabase database =
+                        new org.apache.flink.table.catalog.CatalogDatabaseImpl(
+                                new HashMap<>(), "Hudi database");
+                hudiCatalog.createDatabase(databaseName, database, true);
+            }
+        } catch (DatabaseAlreadyExistException e) {
+            LOG.debug("Database {} already exists in Hudi catalog.", 
databaseName, e);
+        } catch (UnsupportedOperationException e) {
+            throw new UnsupportedOperationException(
+                    String.format(
+                            "The underlying Hudi catalog does not support 
database operations for database '%s'. "
+                                    + "This typically occurs with a 
filesystem-based catalog (dfs mode). "
+                                    + "Consider using Hive Metastore (hms 
mode) instead.",
+                            databaseName),
+                    e);
+        }
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
index f0865de0e..66db56057 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
@@ -43,10 +43,7 @@ public class HudiLakeStorage implements LakeStorage {
 
     @Override
     public LakeCatalog createLakeCatalog() {
-        throw new UnsupportedOperationException(
-                "HudiLakeStorage is currently a scaffold and does not support 
creating a "
-                        + "LakeCatalog yet. Verify that Hudi lake storage was 
selected "
-                        + "intentionally and that the required Hudi 
support/module is available.");
+        return new HudiLakeCatalog(hudiConfig);
     }
 
     @Override
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiConversions.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiConversions.java
new file mode 100644
index 000000000..ae98201d0
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiConversions.java
@@ -0,0 +1,310 @@
+/*
+ * 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.fluss.lake.hudi.utils;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.exception.InvalidConfigException;
+import org.apache.fluss.exception.InvalidTableException;
+import org.apache.fluss.lake.hudi.FlussDataTypeToHudiDataType;
+import org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ObjectPath;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.catalog.UniqueConstraint;
+import org.apache.flink.table.factories.FactoryUtil;
+import org.apache.flink.table.types.DataType;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.index.HoodieIndex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.fluss.lake.hudi.HudiLakeCatalog.SYSTEM_COLUMNS;
+import static 
org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils.HIVE_META_STORE_TYPE;
+
+/** Utils for conversion between Hudi and Fluss. */
+public class HudiConversions {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(HudiConversions.class);
+
+    // for fluss config
+    private static final String FLUSS_CONF_PREFIX = "fluss.";
+    // for hudi config
+    private static final String HUDI_CONF_PREFIX = "hudi.";
+
+    private static final String DELIMITER = ",";
+    private static final String HUDI_METADATA_COLUMN_PREFIX = "_hoodie_";
+    private static final String HUDI_TABLE_TYPE_KEY = 
"hoodie.datasource.write.table.type";
+    private static final String HUDI_RECORD_KEY_FIELD_OPTION =
+            HUDI_CONF_PREFIX + FlinkOptions.RECORD_KEY_FIELD.key();
+
+    /** Hudi config options set by Fluss should not be set by users. */
+    @VisibleForTesting public static final Set<String> HUDI_UNSETTABLE_OPTIONS 
= new HashSet<>();
+
+    static {
+        HUDI_UNSETTABLE_OPTIONS.add(FlinkOptions.TABLE_TYPE.key());
+        HUDI_UNSETTABLE_OPTIONS.add(HUDI_TABLE_TYPE_KEY);
+        HUDI_UNSETTABLE_OPTIONS.add(FlinkOptions.RECORD_KEY_FIELD.key());
+        HUDI_UNSETTABLE_OPTIONS.add(FlinkOptions.INDEX_TYPE.key());
+        HUDI_UNSETTABLE_OPTIONS.add(FlinkOptions.INDEX_KEY_FIELD.key());
+        
HUDI_UNSETTABLE_OPTIONS.add(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS.key());
+        HUDI_UNSETTABLE_OPTIONS.add(FlinkOptions.PARTITION_PATH_FIELD.key());
+    }
+
+    /**
+     * Converts a Fluss TablePath to a Hudi ObjectPath.
+     *
+     * @param tablePath the Fluss table path
+     * @return the corresponding Hudi ObjectPath
+     */
+    public static ObjectPath toHudiObjectPath(TablePath tablePath) {
+        return new ObjectPath(tablePath.getDatabaseName(), 
tablePath.getTableName());
+    }
+
+    public static ResolvedSchema convertToFlinkResolvedSchema(
+            TablePath tablePath,
+            TableDescriptor tableDescriptor,
+            boolean isPkTable,
+            String catalogMode) {
+        // validate hudi options first
+        validateHudiOptions(tableDescriptor.getProperties(), isPkTable);
+        validateHudiOptions(tableDescriptor.getCustomProperties(), isPkTable);
+
+        // choose the correct converter based on catalog mode
+        FlussDataTypeToHudiDataType converter =
+                HIVE_META_STORE_TYPE.equals(catalogMode)
+                        ? FlussDataTypeToHudiDataType.HMS_INSTANCE
+                        : FlussDataTypeToHudiDataType.DFS_INSTANCE;
+
+        List<Column> columns = new ArrayList<>();
+
+        // Add regular columns
+        for (org.apache.fluss.metadata.Schema.Column column :
+                tableDescriptor.getSchema().getColumns()) {
+            String columnName = column.getName();
+            if (SYSTEM_COLUMNS.containsKey(columnName)) {
+                throw new InvalidTableException(
+                        String.format(
+                                "Column %s in table %s conflicts with a system 
column name of Hudi table, "
+                                        + "please rename the column.",
+                                columnName, tablePath));
+            }
+            if (columnName.startsWith(HUDI_METADATA_COLUMN_PREFIX)) {
+                throw new InvalidTableException(
+                        String.format(
+                                "Column %s in table %s conflicts with the 
reserved Hudi metadata column "
+                                        + "prefix '%s', please rename the 
column.",
+                                columnName, tablePath, 
HUDI_METADATA_COLUMN_PREFIX));
+            }
+            columns.add(Column.physical(columnName, 
column.getDataType().accept(converter)));
+        }
+
+        // add system metadata columns to schema
+        for (Map.Entry<String, DataType> systemColumn : 
SYSTEM_COLUMNS.entrySet()) {
+            columns.add(Column.physical(systemColumn.getKey(), 
systemColumn.getValue()));
+        }
+
+        UniqueConstraint constraint = null;
+        // Set primary key if this is a PK table
+        if (isPkTable && tableDescriptor.hasPrimaryKey()) {
+            constraint =
+                    UniqueConstraint.primaryKey(
+                            "primaryKey", 
extractPrimaryKeyColumns(tableDescriptor));
+        }
+
+        return new ResolvedSchema(columns, Collections.emptyList(), 
constraint);
+    }
+
+    /**
+     * Builds Hudi table properties from Fluss TableDescriptor.
+     *
+     * @param tablePath the path of the Fluss table
+     * @param tableDescriptor the Fluss table descriptor
+     * @param isPkTable whether this is a primary key table
+     * @return map of Hudi table properties
+     */
+    public static Map<String, String> buildHudiTableProperties(
+            TablePath tablePath, TableDescriptor tableDescriptor, boolean 
isPkTable) {
+        Map<String, String> hudiProperties = new HashMap<>();
+        // Set connector type
+        hudiProperties.put(FactoryUtil.CONNECTOR.key(), "hudi");
+        hudiProperties.put("storageType", "hudi");
+
+        // Set table type based on whether it's a PK table
+        if (isPkTable) {
+            hudiProperties.put(FlinkOptions.TABLE_TYPE.key(), 
HoodieTableType.MERGE_ON_READ.name());
+            hudiProperties.put(
+                    FlinkOptions.RECORD_KEY_FIELD.key(),
+                    String.join(DELIMITER, 
extractPrimaryKeyColumns(tableDescriptor)));
+        } else {
+            hudiProperties.put(FlinkOptions.TABLE_TYPE.key(), 
HoodieTableType.COPY_ON_WRITE.name());
+            // set primary key for Fluss Log Table.
+            String recordKeyField = getRecordKeyField(tableDescriptor);
+            if (recordKeyField == null || recordKeyField.trim().isEmpty()) {
+                throw new InvalidConfigException(
+                        String.format(
+                                "The Hudi record key field option %s should be 
set for log table %s. "
+                                        + "Please set it to the column used as 
the Hudi record key.",
+                                HUDI_RECORD_KEY_FIELD_OPTION, tablePath));
+            }
+            hudiProperties.put(FlinkOptions.RECORD_KEY_FIELD.key(), 
recordKeyField);
+            hudiProperties.put(
+                    FlinkOptions.INDEX_KEY_FIELD.key(),
+                    recordKeyField); // use primary key as index key
+        }
+
+        // buket keys column
+        hudiProperties.put(FlinkOptions.INDEX_TYPE.key(), 
HoodieIndex.IndexType.BUCKET.name());
+        List<String> bucketKeys = tableDescriptor.getBucketKeys();
+        int numBuckets =
+                tableDescriptor
+                        .getTableDistribution()
+                        
.flatMap(TableDescriptor.TableDistribution::getBucketCount)
+                        .orElseThrow(
+                                () -> new IllegalArgumentException("Bucket 
count should be set."));
+
+        if (!bucketKeys.isEmpty()) {
+            hudiProperties.put(
+                    FlinkOptions.INDEX_KEY_FIELD.key(), String.join(DELIMITER, 
bucketKeys));
+        }
+        hudiProperties.put(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS.key(), 
String.valueOf(numBuckets));
+
+        // partition keys column
+        List<String> partitionKeys = tableDescriptor.getPartitionKeys();
+        hudiProperties.put(
+                FlinkOptions.PARTITION_PATH_FIELD.key(), 
String.join(DELIMITER, partitionKeys));
+
+        // Convert Fluss properties to Hudi properties
+        tableDescriptor
+                .getProperties()
+                .forEach((k, v) -> setFlussPropertyToHudi(k, v, 
hudiProperties));
+        tableDescriptor
+                .getCustomProperties()
+                .forEach((k, v) -> setFlussPropertyToHudi(k, v, 
hudiProperties));
+
+        return hudiProperties;
+    }
+
+    /**
+     * Creates a CatalogTable for Hudi from Fluss TableDescriptor.
+     *
+     * @param tablePath the path of the Fluss table
+     * @param tableDescriptor the Fluss table descriptor
+     * @param isPkTable whether this is a primary key table
+     * @return the created CatalogTable
+     */
+    public static CatalogTable createHudiCatalogTable(
+            TablePath tablePath,
+            TableDescriptor tableDescriptor,
+            boolean isPkTable,
+            String catalogMode) {
+        ResolvedSchema resolvedSchema =
+                convertToFlinkResolvedSchema(tablePath, tableDescriptor, 
isPkTable, catalogMode);
+        Schema schema = 
Schema.newBuilder().fromResolvedSchema(resolvedSchema).build();
+        List<String> partitionKeys = tableDescriptor.getPartitionKeys();
+        Map<String, String> options =
+                buildHudiTableProperties(tablePath, tableDescriptor, 
isPkTable);
+        LOG.debug("Hudi table properties: {}", options);
+
+        String comment = tableDescriptor.getComment().orElse("Hudi table 
created from Fluss");
+        return HIVE_META_STORE_TYPE.equals(catalogMode)
+                ? HudiCatalogUtils.createCatalogTable(schema, partitionKeys, 
options, comment)
+                : HudiCatalogUtils.createResolvedCatalogTable(
+                        schema, partitionKeys, options, comment, 
resolvedSchema);
+    }
+
+    private static void setFlussPropertyToHudi(
+            String key, String value, Map<String, String> hudiProperties) {
+        if (key.startsWith(HUDI_CONF_PREFIX)) {
+            hudiProperties.put(key.substring(HUDI_CONF_PREFIX.length()), 
value);
+        } else {
+            hudiProperties.put(FLUSS_CONF_PREFIX + key, value);
+        }
+    }
+
+    private static String getRecordKeyField(TableDescriptor tableDescriptor) {
+        String recordKeyField =
+                
tableDescriptor.getCustomProperties().get(HUDI_RECORD_KEY_FIELD_OPTION);
+        if (recordKeyField == null) {
+            recordKeyField = 
tableDescriptor.getProperties().get(HUDI_RECORD_KEY_FIELD_OPTION);
+        }
+        if (recordKeyField == null) {
+            // also accept the un-prefixed (native Hudi) option key
+            String unprefixedKey = FlinkOptions.RECORD_KEY_FIELD.key();
+            recordKeyField = 
tableDescriptor.getCustomProperties().get(unprefixedKey);
+            if (recordKeyField == null) {
+                recordKeyField = 
tableDescriptor.getProperties().get(unprefixedKey);
+            }
+        }
+        return recordKeyField;
+    }
+
+    /**
+     * Validates Hudi options that Fluss manages automatically.
+     *
+     * <p>{@link FlinkOptions#RECORD_KEY_FIELD} is allowed for non-primary-key 
Fluss log tables,
+     * where users must provide the Hudi record key field used by the Hudi 
bucket index. Primary-key
+     * tables derive this option from the Fluss primary key, so user-provided 
values are rejected.
+     */
+    private static void validateHudiOptions(Map<String, String> properties, 
boolean isPkTable) {
+        properties.forEach(
+                (k, v) -> {
+                    String hudiKey = k;
+                    if (k.startsWith(HUDI_CONF_PREFIX)) {
+                        hudiKey = k.substring(HUDI_CONF_PREFIX.length());
+                    }
+                    if (!isPkTable && 
FlinkOptions.RECORD_KEY_FIELD.key().equals(hudiKey)) {
+                        return;
+                    }
+                    if (HUDI_UNSETTABLE_OPTIONS.contains(hudiKey)) {
+                        throw new InvalidConfigException(
+                                String.format(
+                                        "The Hudi option %s will be set 
automatically by Fluss "
+                                                + "and should not be set 
manually.",
+                                        k));
+                    }
+                });
+    }
+
+    /**
+     * Extracts the primary key column names from a Fluss TableDescriptor.
+     *
+     * @param tableDescriptor the Fluss table descriptor
+     * @return list of primary key column names
+     */
+    private static List<String> extractPrimaryKeyColumns(TableDescriptor 
tableDescriptor) {
+        List<String> primaryKeys = new ArrayList<>();
+        for (int pkIndex : tableDescriptor.getSchema().getPrimaryKeyIndexes()) 
{
+            
primaryKeys.add(tableDescriptor.getSchema().getColumns().get(pkIndex).getName());
+        }
+        return primaryKeys;
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/catalog/HudiCatalogUtils.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/catalog/HudiCatalogUtils.java
new file mode 100644
index 000000000..90655b56d
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/catalog/HudiCatalogUtils.java
@@ -0,0 +1,101 @@
+/*
+ * 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.fluss.lake.hudi.utils.catalog;
+
+import org.apache.fluss.config.Configuration;
+
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.catalog.Catalog;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.ResolvedCatalogTable;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.hudi.exception.HoodieCatalogException;
+import org.apache.hudi.table.catalog.CatalogOptions;
+import org.apache.hudi.table.catalog.HoodieCatalog;
+import org.apache.hudi.table.catalog.HoodieHiveCatalog;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/** Implementation of {@link HudiCatalogUtils} for Hudi. */
+public class HudiCatalogUtils {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(HudiCatalogUtils.class);
+
+    private static final String CATALOG_NAME_KEY = "name";
+
+    public static final String HUDI_CATALOG_DEFAULT_NAME = 
"fluss-hudi-catalog";
+    public static final String HIVE_META_STORE_TYPE = "hms";
+    public static final String FILE_SYSTEM_TYPE = "dfs";
+
+    public static Catalog createHudiCatalog(Configuration configuration) {
+        Map<String, String> hudiProps = configuration.toMap();
+        // copy the configuration to avoid modifying the original
+        Configuration copiedConfig = new Configuration(configuration);
+        copiedConfig.setString(CatalogOptions.DEFAULT_DATABASE.key(), "tmp");
+        copiedConfig.setString(CatalogOptions.TABLE_EXTERNAL.key(), "true");
+        String catalogName = hudiProps.getOrDefault(CATALOG_NAME_KEY, 
HUDI_CATALOG_DEFAULT_NAME);
+        return buildHudiCatalog(
+                catalogName,
+                hudiProps,
+                
org.apache.flink.configuration.Configuration.fromMap(copiedConfig.toMap()));
+    }
+
+    public static Catalog buildHudiCatalog(
+            String catalogName,
+            Map<String, String> hudiProps,
+            org.apache.flink.configuration.Configuration configuration) {
+        String catalogMode =
+                hudiProps.getOrDefault(CatalogOptions.MODE.key(), 
HIVE_META_STORE_TYPE);
+        LOG.info("create hudi catalog: {}, mode: {}", catalogName, 
catalogMode);
+        switch (catalogMode.toLowerCase(Locale.ENGLISH)) {
+            case HIVE_META_STORE_TYPE:
+                return new HoodieHiveCatalog(catalogName, configuration);
+            case FILE_SYSTEM_TYPE:
+                return new HoodieCatalog(catalogName, configuration);
+            default:
+                throw new HoodieCatalogException(
+                        String.format(
+                                "Invalid hudi-catalog mode: %s, supported 
modes: [hms, dfs].",
+                                catalogMode));
+        }
+    }
+
+    public static CatalogTable createCatalogTable(
+            Schema schema,
+            List<String> partitionKeys,
+            Map<String, String> options,
+            @Nullable String comment) {
+        return CatalogTable.of(schema, comment, partitionKeys, options);
+    }
+
+    public static ResolvedCatalogTable createResolvedCatalogTable(
+            Schema schema,
+            List<String> partitionKeys,
+            Map<String, String> options,
+            @Nullable String comment,
+            ResolvedSchema resolvedSchema) {
+        return new ResolvedCatalogTable(
+                CatalogTable.of(schema, comment, partitionKeys, options), 
resolvedSchema);
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/HudiLakeCatalogTest.java
 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/HudiLakeCatalogTest.java
new file mode 100644
index 000000000..8acb742b0
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/HudiLakeCatalogTest.java
@@ -0,0 +1,625 @@
+/*
+ * 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.fluss.lake.hudi;
+
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.InvalidConfigException;
+import org.apache.fluss.exception.InvalidTableException;
+import org.apache.fluss.exception.TableAlreadyExistException;
+import org.apache.fluss.lake.hudi.utils.HudiConversions;
+import org.apache.fluss.lake.lakestorage.TestingLakeCatalogContext;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.types.DataTypes;
+
+import org.apache.flink.table.catalog.CatalogBaseTable;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ObjectPath;
+import org.apache.flink.table.catalog.ResolvedCatalogTable;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.catalog.exceptions.TableNotExistException;
+import org.apache.flink.table.types.DataType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Unit test for {@link HudiLakeCatalog}. */
+class HudiLakeCatalogTest {
+
+    @TempDir private File tempWarehouseDir;
+
+    private HudiLakeCatalog flussHudiLakeCatalog;
+
+    @BeforeEach
+    public void setUp() {
+        Configuration configuration = new Configuration();
+        configuration.setString("catalog.path", 
tempWarehouseDir.toURI().toString());
+        configuration.setString("mode", "dfs");
+        this.flussHudiLakeCatalog = new HudiLakeCatalog(configuration);
+    }
+
+    /** Verify property prefix rewriting. */
+    @Test
+    void testPropertyPrefixRewriting() throws TableNotExistException {
+        String database = "test_db";
+        String tableName = "test_table";
+
+        Schema flussSchema =
+                Schema.newBuilder().column("id", 
DataTypes.BIGINT()).primaryKey("id").build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(flussSchema)
+                        .distributedBy(3)
+                        .property("hudi.precombine.field", "id")
+                        .property("table.datalake.freshness", "30s")
+                        .build();
+
+        TablePath tablePath = TablePath.of(database, tableName);
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+        flussHudiLakeCatalog.createTable(tablePath, tableDescriptor, context);
+
+        CatalogBaseTable table =
+                flussHudiLakeCatalog
+                        .getHudiCatalog()
+                        .getTable(HudiConversions.toHudiObjectPath(tablePath));
+
+        // Verify property prefix rewriting
+        assertThat(table.getOptions()).containsEntry("precombine.field", "id");
+        
assertThat(table.getOptions()).containsEntry("fluss.table.datalake.freshness", 
"30s");
+        assertThat(table.getOptions())
+                .doesNotContainKeys("hudi.precombine.field", 
"table.datalake.freshness");
+    }
+
+    @Test
+    void testCreatePrimaryKeyTable() throws TableNotExistException {
+        String database = "test_db";
+        String tableName = "pk_table";
+
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .withComment("pk_table")
+                        .primaryKey("id")
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder().schema(flussSchema).distributedBy(4, 
"id").build();
+
+        TablePath tablePath = TablePath.of(database, tableName);
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+        flussHudiLakeCatalog.createTable(tablePath, tableDescriptor, context);
+
+        ObjectPath objectPath = HudiConversions.toHudiObjectPath(tablePath);
+        CatalogBaseTable table = 
flussHudiLakeCatalog.getHudiCatalog().getTable(objectPath);
+
+        assertThat(table).isNotNull();
+
+        org.apache.flink.table.api.Schema expectedHudiSchema =
+                buildExpectedHudiSchema(
+                        org.apache.flink.table.api.DataTypes.INT().notNull(), 
"primaryKey");
+
+        assertThat(table.getUnresolvedSchema()).isEqualTo(expectedHudiSchema);
+    }
+
+    @Test
+    void testCreateLogTable() throws TableNotExistException {
+        String database = "test_db";
+        String tableName = "log_table";
+
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("name", DataTypes.STRING())
+                        .build();
+
+        Map<String, String> customProperties = new HashMap<>();
+        customProperties.put("hudi.hoodie.datasource.write.recordkey.field", 
"id");
+
+        TableDescriptor td =
+                TableDescriptor.builder()
+                        .schema(flussSchema)
+                        .distributedBy(3) // no bucket key
+                        .customProperties(customProperties)
+                        .build();
+
+        TablePath tablePath = TablePath.of(database, tableName);
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+        flussHudiLakeCatalog.createTable(tablePath, td, context);
+
+        ObjectPath objectPath = HudiConversions.toHudiObjectPath(tablePath);
+        CatalogBaseTable table = 
flussHudiLakeCatalog.getHudiCatalog().getTable(objectPath);
+
+        org.apache.flink.table.api.Schema expectedHudiSchema =
+                buildExpectedHudiSchema(
+                        
org.apache.flink.table.api.DataTypes.BIGINT().notNull(), "PK_id");
+
+        assertThat(table.getUnresolvedSchema()).isEqualTo(expectedHudiSchema);
+    }
+
+    @Test
+    void testCreateLogTableWithoutRecordKeyThrowsException() {
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.BIGINT())
+                        .column("name", DataTypes.STRING())
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                
TableDescriptor.builder().schema(flussSchema).distributedBy(3).build();
+
+        TablePath tablePath = TablePath.of("test_db", 
"log_table_without_record_key");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        assertThatThrownBy(
+                        () -> flussHudiLakeCatalog.createTable(tablePath, 
tableDescriptor, context))
+                .isInstanceOf(InvalidConfigException.class)
+                
.hasMessageContaining("hudi.hoodie.datasource.write.recordkey.field")
+                .hasMessageContaining(tablePath.toString());
+    }
+
+    // ------------------------------------------------------------------
+    // isHudiSchemaCompatible() tests
+    // ------------------------------------------------------------------
+
+    @Test
+    void testIsHudiSchemaCompatibleWithSameSchema() {
+        // Build two catalog tables with identical schema
+        CatalogTable table1 =
+                buildTestCatalogTable(
+                        new String[] {"id", "name"},
+                        new DataType[] {
+                            
org.apache.flink.table.api.DataTypes.INT().notNull(),
+                            org.apache.flink.table.api.DataTypes.STRING()
+                        });
+        CatalogTable table2 =
+                buildTestCatalogTable(
+                        new String[] {"id", "name"},
+                        new DataType[] {
+                            
org.apache.flink.table.api.DataTypes.INT().notNull(),
+                            org.apache.flink.table.api.DataTypes.STRING()
+                        });
+
+        assertThat(flussHudiLakeCatalog.isHudiSchemaCompatible(table1, 
table2)).isTrue();
+    }
+
+    @Test
+    void testIsHudiSchemaCompatibleWithResolvedAndUnresolvedSchema() {
+        CatalogTable unresolvedTable =
+                buildTestCatalogTable(
+                        new String[] {"id", "name"},
+                        new DataType[] {
+                            org.apache.flink.table.api.DataTypes.INT()
+                                    .notNull()
+                                    .bridgedTo(Integer.class),
+                            org.apache.flink.table.api.DataTypes.STRING()
+                        });
+        CatalogTable resolvedTable =
+                buildResolvedTestCatalogTable(
+                        new String[] {"id", "name"},
+                        new DataType[] {
+                            
org.apache.flink.table.api.DataTypes.INT().notNull(),
+                            
org.apache.flink.table.api.DataTypes.STRING().bridgedTo(String.class)
+                        });
+
+        
assertThat(flussHudiLakeCatalog.isHudiSchemaCompatible(unresolvedTable, 
resolvedTable))
+                .isTrue();
+        assertThat(flussHudiLakeCatalog.isHudiSchemaCompatible(resolvedTable, 
unresolvedTable))
+                .isTrue();
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("incompatibleSchemaArgs")
+    void testIsHudiSchemaCompatibleWithIncompatibleSchemas(
+            String ignoredCaseName, String[] columnNames, DataType[] 
columnTypes) {
+        CatalogTable table1 =
+                buildTestCatalogTable(
+                        new String[] {"id", "name"},
+                        new DataType[] {
+                            
org.apache.flink.table.api.DataTypes.INT().notNull(),
+                            org.apache.flink.table.api.DataTypes.STRING()
+                        });
+        CatalogTable table2 = buildTestCatalogTable(columnNames, columnTypes);
+
+        assertThat(flussHudiLakeCatalog.isHudiSchemaCompatible(table1, 
table2)).isFalse();
+    }
+
+    @Test
+    void testIsHudiSchemaCompatibleFailsOnComputedColumn() {
+        org.apache.flink.table.api.Schema schema =
+                org.apache.flink.table.api.Schema.newBuilder()
+                        .column("id", 
org.apache.flink.table.api.DataTypes.INT())
+                        .columnByExpression("computed_id", "`id` + 1")
+                        .build();
+        CatalogTable table =
+                CatalogTable.of(schema, null, Collections.emptyList(), new 
HashMap<>());
+
+        assertThatThrownBy(() -> 
flussHudiLakeCatalog.isHudiSchemaCompatible(table, table))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("Unexpected column kind");
+    }
+
+    // ------------------------------------------------------------------
+    // Duplicate table creation idempotency tests
+    // ------------------------------------------------------------------
+
+    @Test
+    void testCreateDuplicateTableWithCompatibleSchema() throws 
TableNotExistException {
+        String database = "idempotent_db";
+        String tableName = "idempotent_table";
+
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .primaryKey("id")
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder().schema(flussSchema).distributedBy(4, 
"id").build();
+
+        TablePath tablePath = TablePath.of(database, tableName);
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        // First creation should succeed
+        flussHudiLakeCatalog.createTable(tablePath, tableDescriptor, context);
+
+        // Second creation with same schema should not throw
+        flussHudiLakeCatalog.createTable(tablePath, tableDescriptor, context);
+
+        // Verify the table still exists and has correct schema
+        CatalogBaseTable table =
+                flussHudiLakeCatalog
+                        .getHudiCatalog()
+                        .getTable(HudiConversions.toHudiObjectPath(tablePath));
+        assertThat(table).isNotNull();
+    }
+
+    @Test
+    void testCreateFlussTableFailsWhenHudiTableAlreadyExists() {
+        String database = "existing_hudi_db";
+        String tableName = "existing_hudi_table";
+
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .primaryKey("id")
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder().schema(flussSchema).distributedBy(4, 
"id").build();
+
+        TablePath tablePath = TablePath.of(database, tableName);
+        flussHudiLakeCatalog.createTable(
+                tablePath, tableDescriptor, new TestingLakeCatalogContext());
+
+        TestingLakeCatalogContext creatingFlussTableContext =
+                new TestingLakeCatalogContext() {
+                    @Override
+                    public boolean isCreatingFlussTable() {
+                        return true;
+                    }
+                };
+
+        assertThatThrownBy(
+                        () ->
+                                flussHudiLakeCatalog.createTable(
+                                        tablePath, tableDescriptor, 
creatingFlussTableContext))
+                .isInstanceOf(TableAlreadyExistException.class)
+                .hasMessageContaining("cannot verify whether the existing Hudi 
table is empty");
+    }
+
+    @Test
+    void testCreateDuplicateTableWithIncompatibleSchema() {
+        String database = "incompat_db";
+        String tableName = "incompat_table";
+
+        // First: create a table with id(INT) + name(STRING)
+        Schema flussSchema1 =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .primaryKey("id")
+                        .build();
+
+        TableDescriptor tableDescriptor1 =
+                
TableDescriptor.builder().schema(flussSchema1).distributedBy(4, "id").build();
+
+        TablePath tablePath = TablePath.of(database, tableName);
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+        flussHudiLakeCatalog.createTable(tablePath, tableDescriptor1, context);
+
+        // Second: try creating a table with id(INT) + name(BIGINT) - 
different type
+        Schema flussSchema2 =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.BIGINT())
+                        .primaryKey("id")
+                        .build();
+
+        TableDescriptor tableDescriptor2 =
+                
TableDescriptor.builder().schema(flussSchema2).distributedBy(4, "id").build();
+
+        assertThatThrownBy(
+                        () ->
+                                flussHudiLakeCatalog.createTable(
+                                        tablePath, tableDescriptor2, context))
+                .isInstanceOf(TableAlreadyExistException.class)
+                .hasMessageContaining("not compatible");
+    }
+
+    // ------------------------------------------------------------------
+    // HUDI_UNSETTABLE_OPTIONS validation tests
+    // ------------------------------------------------------------------
+
+    @Test
+    void testUnsettableOptionInPropertiesThrowsException() {
+        Schema flussSchema =
+                Schema.newBuilder().column("id", 
DataTypes.INT()).primaryKey("id").build();
+
+        // Set a protected Hudi option via properties (without hudi. prefix)
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(flussSchema)
+                        .distributedBy(4, "id")
+                        .property("hudi.hoodie.datasource.write.table.type", 
"COPY_ON_WRITE")
+                        .build();
+
+        TablePath tablePath = TablePath.of("test_db", 
"protected_option_table");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        assertThatThrownBy(
+                        () -> flussHudiLakeCatalog.createTable(tablePath, 
tableDescriptor, context))
+                .isInstanceOf(InvalidConfigException.class)
+                .hasMessageContaining("hoodie.datasource.write.table.type")
+                .hasMessageContaining("should not be set manually");
+    }
+
+    @Test
+    void testUnsettableOptionInCustomPropertiesThrowsException() {
+        Schema flussSchema =
+                Schema.newBuilder().column("id", 
DataTypes.INT()).primaryKey("id").build();
+
+        Map<String, String> customProperties = new HashMap<>();
+        // Set a protected Hudi option via customProperties
+        customProperties.put("hudi.hoodie.datasource.write.recordkey.field", 
"id");
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(flussSchema)
+                        .distributedBy(4, "id")
+                        .customProperties(customProperties)
+                        .build();
+
+        TablePath tablePath = TablePath.of("test_db", 
"protected_custom_table");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        assertThatThrownBy(
+                        () -> flussHudiLakeCatalog.createTable(tablePath, 
tableDescriptor, context))
+                .isInstanceOf(InvalidConfigException.class)
+                
.hasMessageContaining("hoodie.datasource.write.recordkey.field")
+                .hasMessageContaining("should not be set manually");
+    }
+
+    @Test
+    void testNonProtectedHudiOptionPassesValidation() throws 
TableNotExistException {
+        Schema flussSchema =
+                Schema.newBuilder().column("id", 
DataTypes.INT()).primaryKey("id").build();
+
+        // Set a non-protected Hudi option (e.g., precombine.field) — should 
work fine
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(flussSchema)
+                        .distributedBy(4, "id")
+                        .property("hudi.precombine.field", "id")
+                        .build();
+
+        TablePath tablePath = TablePath.of("test_db", "non_protected_table");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        // Should not throw
+        flussHudiLakeCatalog.createTable(tablePath, tableDescriptor, context);
+
+        CatalogBaseTable table =
+                flussHudiLakeCatalog
+                        .getHudiCatalog()
+                        .getTable(HudiConversions.toHudiObjectPath(tablePath));
+        assertThat(table).isNotNull();
+        assertThat(table.getOptions()).containsEntry("precombine.field", "id");
+    }
+
+    // ------------------------------------------------------------------
+    // System column name conflict tests
+    // ------------------------------------------------------------------
+
+    @Test
+    void testSystemColumnBucketConflictThrowsException() {
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("__bucket", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .primaryKey("__bucket")
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder().schema(flussSchema).distributedBy(4, 
"__bucket").build();
+
+        TablePath tablePath = TablePath.of("test_db", "bucket_conflict_table");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        assertThatThrownBy(
+                        () -> flussHudiLakeCatalog.createTable(tablePath, 
tableDescriptor, context))
+                .isInstanceOf(InvalidTableException.class)
+                .hasMessageContaining("__bucket")
+                .hasMessageContaining("system column");
+    }
+
+    @Test
+    void testSystemColumnOffsetConflictThrowsException() {
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("__offset", DataTypes.BIGINT())
+                        .primaryKey("id")
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder().schema(flussSchema).distributedBy(4, 
"id").build();
+
+        TablePath tablePath = TablePath.of("test_db", "offset_conflict_table");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        assertThatThrownBy(
+                        () -> flussHudiLakeCatalog.createTable(tablePath, 
tableDescriptor, context))
+                .isInstanceOf(InvalidTableException.class)
+                .hasMessageContaining("__offset")
+                .hasMessageContaining("system column");
+    }
+
+    @Test
+    void testSystemColumnTimestampConflictThrowsException() {
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("__timestamp", DataTypes.TIMESTAMP(6))
+                        .primaryKey("id")
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder().schema(flussSchema).distributedBy(4, 
"id").build();
+
+        TablePath tablePath = TablePath.of("test_db", 
"timestamp_conflict_table");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        assertThatThrownBy(
+                        () -> flussHudiLakeCatalog.createTable(tablePath, 
tableDescriptor, context))
+                .isInstanceOf(InvalidTableException.class)
+                .hasMessageContaining("__timestamp")
+                .hasMessageContaining("system column");
+    }
+
+    @Test
+    void testHudiMetadataColumnPrefixConflictThrowsException() {
+        Schema flussSchema =
+                Schema.newBuilder()
+                        .column("_hoodie_record_key", DataTypes.STRING())
+                        .column("name", DataTypes.STRING())
+                        .primaryKey("_hoodie_record_key")
+                        .build();
+
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(flussSchema)
+                        .distributedBy(4, "_hoodie_record_key")
+                        .build();
+
+        TablePath tablePath = TablePath.of("test_db", 
"hudi_metadata_conflict_table");
+        TestingLakeCatalogContext context = new TestingLakeCatalogContext();
+
+        assertThatThrownBy(
+                        () -> flussHudiLakeCatalog.createTable(tablePath, 
tableDescriptor, context))
+                .isInstanceOf(InvalidTableException.class)
+                .hasMessageContaining("_hoodie_record_key")
+                .hasMessageContaining("_hoodie_");
+    }
+
+    // ------------------------------------------------------------------
+    // Helper methods
+    // ------------------------------------------------------------------
+
+    private org.apache.flink.table.api.Schema buildExpectedHudiSchema(
+            DataType idType, String primaryKeyName) {
+        return org.apache.flink.table.api.Schema.newBuilder()
+                .column("id", idType)
+                .column("name", org.apache.flink.table.api.DataTypes.STRING())
+                .column("__bucket", org.apache.flink.table.api.DataTypes.INT())
+                .column("__offset", 
org.apache.flink.table.api.DataTypes.BIGINT())
+                .column("__timestamp", 
org.apache.flink.table.api.DataTypes.TIMESTAMP(6))
+                .primaryKeyNamed(primaryKeyName, "id")
+                .build();
+    }
+
+    private static Stream<Arguments> incompatibleSchemaArgs() {
+        return Stream.of(
+                Arguments.of(
+                        "different column count",
+                        new String[] {"id"},
+                        new DataType[] 
{org.apache.flink.table.api.DataTypes.INT().notNull()}),
+                Arguments.of(
+                        "different column name",
+                        new String[] {"id", "value"},
+                        new DataType[] {
+                            
org.apache.flink.table.api.DataTypes.INT().notNull(),
+                            org.apache.flink.table.api.DataTypes.STRING()
+                        }),
+                Arguments.of(
+                        "different column type",
+                        new String[] {"id", "name"},
+                        new DataType[] {
+                            
org.apache.flink.table.api.DataTypes.INT().notNull(),
+                            org.apache.flink.table.api.DataTypes.BIGINT()
+                        }));
+    }
+
+    private CatalogTable buildTestCatalogTable(String[] columnNames, 
DataType[] columnTypes) {
+        ResolvedSchema resolvedSchema = buildResolvedSchema(columnNames, 
columnTypes);
+        org.apache.flink.table.api.Schema schema =
+                org.apache.flink.table.api.Schema.newBuilder()
+                        .fromResolvedSchema(resolvedSchema)
+                        .build();
+        return CatalogTable.of(schema, null, Collections.emptyList(), new 
HashMap<>());
+    }
+
+    private CatalogTable buildResolvedTestCatalogTable(
+            String[] columnNames, DataType[] columnTypes) {
+        ResolvedSchema resolvedSchema = buildResolvedSchema(columnNames, 
columnTypes);
+        org.apache.flink.table.api.Schema schema =
+                org.apache.flink.table.api.Schema.newBuilder()
+                        .fromResolvedSchema(resolvedSchema)
+                        .build();
+        return new ResolvedCatalogTable(
+                CatalogTable.of(schema, null, Collections.emptyList(), new 
HashMap<>()),
+                resolvedSchema);
+    }
+
+    private ResolvedSchema buildResolvedSchema(String[] columnNames, 
DataType[] columnTypes) {
+        List<Column> columns = new ArrayList<>();
+        for (int i = 0; i < columnNames.length; i++) {
+            columns.add(Column.physical(columnNames[i], columnTypes[i]));
+        }
+        return new ResolvedSchema(columns, Collections.emptyList(), null);
+    }
+}
diff --git a/fluss-lake/pom.xml b/fluss-lake/pom.xml
index 508206bfe..eab338f59 100644
--- a/fluss-lake/pom.xml
+++ b/fluss-lake/pom.xml
@@ -44,6 +44,11 @@
                 <artifactId>flink-table-common</artifactId>
                 <version>${flink.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.apache.flink</groupId>
+                <artifactId>flink-table-api-java</artifactId>
+                <version>${flink.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.apache.flink</groupId>
                 <artifactId>flink-table-runtime</artifactId>

Reply via email to