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 33bfd9ea5 [lake/hudi] Introduce Hudi LakeCommitter (#3509)
33bfd9ea5 is described below

commit 33bfd9ea5f24ced04d5f6f53ab20b2bb6768d9f1
Author: fhan <[email protected]>
AuthorDate: Tue Jun 23 22:32:11 2026 +0800

    [lake/hudi] Introduce Hudi LakeCommitter (#3509)
---
 .../fluss/client/lookup/PrimaryKeyLookuper.java    |  10 +-
 .../client/table/writer/UpsertWriterImpl.java      |  12 +-
 .../org/apache/fluss/row/decode/KeyDecoder.java    |   6 +
 .../org/apache/fluss/row/encode/KeyEncoder.java    |  30 ++
 .../fluss/row/decode/CompactedKeyDecoderTest.java  |  19 +
 .../fluss/row/encode/CompactedKeyEncoderTest.java  |  45 ++
 fluss-lake/fluss-lake-hudi/pom.xml                 | 105 ++++
 .../apache/fluss/lake/hudi/HudiLakeStorage.java    |   5 +-
 .../fluss/lake/hudi/source/HudiRecordReader.java   |   4 +-
 .../fluss/lake/hudi/tiering/HudiCommittable.java   | 122 +++++
 .../hudi/tiering/HudiCommittableSerializer.java    |  70 +++
 .../fluss/lake/hudi/tiering/HudiLakeCommitter.java | 301 ++++++++++++
 .../lake/hudi/tiering/HudiLakeTieringFactory.java  |  16 +-
 .../fluss/lake/hudi/tiering/HudiLakeWriter.java    |   4 +-
 .../fluss/lake/hudi/tiering/HudiWriteResult.java   |  50 +-
 .../hudi/tiering/HudiWriteResultSerializer.java    |  50 +-
 .../fluss/lake/hudi/tiering/HudiWriteStats.java    | 119 +++++
 .../lake/hudi/tiering/HudiWriteStatsSerde.java     | 121 +++++
 .../fluss/lake/hudi/tiering/RecordWriter.java      |   4 +-
 .../hudi/tiering/writer/RecordWriteBuffer.java     |  11 +-
 .../lake/hudi/utils/meta/CkpMetadataFactory.java   |   7 +-
 .../fluss/lake/hudi/HudiLakeStorageTest.java       |  41 ++
 .../hudi/testutils/FlinkHudiTieringTestBase.java   | 539 +++++++++++++++++++++
 .../tiering/HudiCommittableSerializerTest.java     | 117 +++++
 .../fluss/lake/hudi/tiering/HudiTieringITCase.java | 233 +++++++++
 .../fluss/lake/hudi/tiering/HudiTieringTest.java   | 274 +++++++++++
 .../tiering/HudiWriteResultSerializerTest.java     |  47 +-
 .../src/test/resources/log4j2-test.properties      |   2 +-
 28 files changed, 2263 insertions(+), 101 deletions(-)

diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java
index 26a03c47c..c6c720ea7 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java
@@ -81,10 +81,12 @@ class PrimaryKeyLookuper extends AbstractLookuper 
implements Lookuper {
                         tableInfo.getTableConfig(),
                         tableInfo.isDefaultBucketKey());
         this.bucketKeyEncoder =
-                tableInfo.isDefaultBucketKey()
-                        ? primaryKeyEncoder
-                        : KeyEncoder.ofBucketKeyEncoder(
-                                lookupRowType, tableInfo.getBucketKeys(), 
lakeFormat);
+                KeyEncoder.ofBucketKeyEncoder(
+                        lookupRowType,
+                        tableInfo.getBucketKeys(),
+                        tableInfo.getTableConfig(),
+                        tableInfo.isDefaultBucketKey(),
+                        primaryKeyEncoder);
 
         this.bucketingFunction = BucketingFunction.of(lakeFormat);
 
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/UpsertWriterImpl.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/UpsertWriterImpl.java
index 6b7f821a1..8417855cc 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/UpsertWriterImpl.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/UpsertWriterImpl.java
@@ -88,12 +88,12 @@ class UpsertWriterImpl extends AbstractTableWriter 
implements UpsertWriter {
                         tableInfo.getTableConfig(),
                         tableInfo.isDefaultBucketKey());
         this.bucketKeyEncoder =
-                tableInfo.isDefaultBucketKey()
-                        ? primaryKeyEncoder
-                        : KeyEncoder.ofBucketKeyEncoder(
-                                tableInfo.getRowType(),
-                                tableInfo.getBucketKeys(),
-                                
tableInfo.getTableConfig().getDataLakeFormat().orElse(null));
+                KeyEncoder.ofBucketKeyEncoder(
+                        tableInfo.getRowType(),
+                        tableInfo.getBucketKeys(),
+                        tableInfo.getTableConfig(),
+                        tableInfo.isDefaultBucketKey(),
+                        primaryKeyEncoder);
 
         this.kvFormat = tableInfo.getTableConfig().getKvFormat();
         this.writeFormat = WriteFormat.fromKvFormat(this.kvFormat);
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/row/decode/KeyDecoder.java 
b/fluss-common/src/main/java/org/apache/fluss/row/decode/KeyDecoder.java
index 770526879..2c4dbdba7 100644
--- a/fluss-common/src/main/java/org/apache/fluss/row/decode/KeyDecoder.java
+++ b/fluss-common/src/main/java/org/apache/fluss/row/decode/KeyDecoder.java
@@ -56,6 +56,12 @@ public interface KeyDecoder {
             short kvFormatVersion,
             @Nullable DataLakeFormat lakeFormat,
             boolean isDefaultBucketKey) {
+        // Hudi's HudiKeyEncoder is lossy (4-byte hash); primary keys are 
encoded by
+        // CompactedKeyEncoder, so decoding must use CompactedKeyDecoder as 
well.
+        if (lakeFormat == DataLakeFormat.HUDI) {
+            return CompactedKeyDecoder.createKeyDecoder(rowType, keyFields);
+        }
+
         if (kvFormatVersion == 1 || (kvFormatVersion == 2 && 
isDefaultBucketKey)) {
             if (lakeFormat == null || lakeFormat == DataLakeFormat.LANCE) {
                 return CompactedKeyDecoder.createKeyDecoder(rowType, 
keyFields);
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/row/encode/KeyEncoder.java 
b/fluss-common/src/main/java/org/apache/fluss/row/encode/KeyEncoder.java
index fe52e926c..bea1a21c9 100644
--- a/fluss-common/src/main/java/org/apache/fluss/row/encode/KeyEncoder.java
+++ b/fluss-common/src/main/java/org/apache/fluss/row/encode/KeyEncoder.java
@@ -30,6 +30,8 @@ import javax.annotation.Nullable;
 import java.util.List;
 import java.util.Optional;
 
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
 /** An interface for encoding key of row into bytes. */
 public interface KeyEncoder {
 
@@ -113,6 +115,34 @@ public interface KeyEncoder {
         return of(rowType, keyFields, lakeFormat);
     }
 
+    /**
+     * Creates a bucket key encoder for bucket calculation and reuses the 
primary key encoder when
+     * the bucket key encoding is compatible with the primary key encoding.
+     *
+     * <p>When the bucket key is the same as the primary key, most formats can 
reuse the primary key
+     * encoder and avoid encoding twice. Hudi is an exception: its primary key 
encoder must be
+     * lossless, while bucket routing must keep using Hudi's hash-based bucket 
key encoding.
+     *
+     * @param rowType the row type of the input row
+     * @param keyFields the bucket key fields to encode
+     * @param tableConfig the table configuration containing lake format
+     * @param isDefaultBucketKey true if bucket key equals primary key
+     * @param primaryKeyEncoder the primary key encoder to reuse when 
compatible
+     * @return the bucket key encoder
+     */
+    static KeyEncoder ofBucketKeyEncoder(
+            RowType rowType,
+            List<String> keyFields,
+            TableConfig tableConfig,
+            boolean isDefaultBucketKey,
+            KeyEncoder primaryKeyEncoder) {
+        DataLakeFormat dataLakeFormat = 
tableConfig.getDataLakeFormat().orElse(null);
+        if (isDefaultBucketKey && dataLakeFormat != DataLakeFormat.HUDI) {
+            return checkNotNull(primaryKeyEncoder, "Primary key encoder must 
not be null.");
+        }
+        return ofBucketKeyEncoder(rowType, keyFields, dataLakeFormat);
+    }
+
     /**
      * Creates a key encoder based on the datalake format.
      *
diff --git 
a/fluss-common/src/test/java/org/apache/fluss/row/decode/CompactedKeyDecoderTest.java
 
b/fluss-common/src/test/java/org/apache/fluss/row/decode/CompactedKeyDecoderTest.java
index 529fb4c57..c6aa4cb53 100644
--- 
a/fluss-common/src/test/java/org/apache/fluss/row/decode/CompactedKeyDecoderTest.java
+++ 
b/fluss-common/src/test/java/org/apache/fluss/row/decode/CompactedKeyDecoderTest.java
@@ -18,6 +18,7 @@
 
 package org.apache.fluss.row.decode;
 
+import org.apache.fluss.metadata.DataLakeFormat;
 import org.apache.fluss.row.BinaryString;
 import org.apache.fluss.row.Decimal;
 import org.apache.fluss.row.InternalRow;
@@ -175,6 +176,24 @@ class CompactedKeyDecoderTest {
         }
     }
 
+    @Test
+    void testHudiPrimaryKeyDecoderUsesCompactedDecoder() {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.STRING()},
+                        new String[] {"id", "name"});
+        List<String> pk = Arrays.asList("id", "name");
+
+        assertThat(
+                        KeyDecoder.ofPrimaryKeyDecoder(
+                                rowType, pk, (short) 1, DataLakeFormat.HUDI, 
true))
+                .isInstanceOf(CompactedKeyDecoder.class);
+        assertThat(
+                        KeyDecoder.ofPrimaryKeyDecoder(
+                                rowType, pk, (short) 2, DataLakeFormat.HUDI, 
false))
+                .isInstanceOf(CompactedKeyDecoder.class);
+    }
+
     @Test
     void testDecodeInvalidKey() {
         RowType rowType = RowType.of(DataTypes.INT(), DataTypes.STRING());
diff --git 
a/fluss-common/src/test/java/org/apache/fluss/row/encode/CompactedKeyEncoderTest.java
 
b/fluss-common/src/test/java/org/apache/fluss/row/encode/CompactedKeyEncoderTest.java
index d7691886b..ab8d5876c 100644
--- 
a/fluss-common/src/test/java/org/apache/fluss/row/encode/CompactedKeyEncoderTest.java
+++ 
b/fluss-common/src/test/java/org/apache/fluss/row/encode/CompactedKeyEncoderTest.java
@@ -17,12 +17,17 @@
 
 package org.apache.fluss.row.encode;
 
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.config.TableConfig;
 import org.apache.fluss.memory.MemorySegment;
+import org.apache.fluss.metadata.DataLakeFormat;
 import org.apache.fluss.row.BinaryString;
 import org.apache.fluss.row.GenericRow;
 import org.apache.fluss.row.InternalRow;
 import org.apache.fluss.row.compacted.CompactedRowDeserializer;
 import org.apache.fluss.row.compacted.CompactedRowReader;
+import org.apache.fluss.row.encode.hudi.HudiKeyEncoder;
 import org.apache.fluss.row.indexed.IndexedRow;
 import org.apache.fluss.row.indexed.IndexedRowTest;
 import org.apache.fluss.row.indexed.IndexedRowWriter;
@@ -83,6 +88,36 @@ class CompactedKeyEncoderTest {
         assertThat(encodedKey.getString(0).toString()).isEqualTo("a2");
     }
 
+    @Test
+    void testBucketKeyEncoderReusesPrimaryKeyEncoderWhenCompatible() {
+        RowType rowType = RowType.of(new DataType[] {DataTypes.INT()}, new 
String[] {"id"});
+        List<String> primaryKeys = Collections.singletonList("id");
+        KeyEncoder primaryKeyEncoder = 
CompactedKeyEncoder.createKeyEncoder(rowType, primaryKeys);
+
+        assertThat(
+                        KeyEncoder.ofBucketKeyEncoder(
+                                rowType, primaryKeys, tableConfig(), true, 
primaryKeyEncoder))
+                .isSameAs(primaryKeyEncoder);
+    }
+
+    @Test
+    void testHudiBucketKeyEncoderDoesNotReusePrimaryKeyEncoder() {
+        RowType rowType = RowType.of(new DataType[] {DataTypes.INT()}, new 
String[] {"id"});
+        List<String> primaryKeys = Collections.singletonList("id");
+        KeyEncoder primaryKeyEncoder = 
CompactedKeyEncoder.createKeyEncoder(rowType, primaryKeys);
+
+        KeyEncoder bucketKeyEncoder =
+                KeyEncoder.ofBucketKeyEncoder(
+                        rowType,
+                        primaryKeys,
+                        tableConfig(DataLakeFormat.HUDI),
+                        true,
+                        primaryKeyEncoder);
+
+        assertThat(bucketKeyEncoder).isInstanceOf(HudiKeyEncoder.class);
+        assertThat(bucketKeyEncoder).isNotSameAs(primaryKeyEncoder);
+    }
+
     @Test
     void testGetKey() {
         // test int, long as primary key
@@ -179,4 +214,14 @@ class CompactedKeyEncoderTest {
         compactedRowDeserializer.deserialize(compactedRowReader, genericRow);
         return genericRow;
     }
+
+    private static TableConfig tableConfig() {
+        return new TableConfig(new Configuration());
+    }
+
+    private static TableConfig tableConfig(DataLakeFormat dataLakeFormat) {
+        Configuration configuration = new Configuration();
+        configuration.set(ConfigOptions.TABLE_DATALAKE_FORMAT, dataLakeFormat);
+        return new TableConfig(configuration);
+    }
 }
diff --git a/fluss-lake/fluss-lake-hudi/pom.xml 
b/fluss-lake/fluss-lake-hudi/pom.xml
index 64f1ca2c1..3006c73c9 100644
--- a/fluss-lake/fluss-lake-hudi/pom.xml
+++ b/fluss-lake/fluss-lake-hudi/pom.xml
@@ -69,6 +69,43 @@
             </exclusions>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.hadoop</groupId>
+            <artifactId>hadoop-mapreduce-client-core</artifactId>
+            <version>${fluss.hadoop.version}</version>
+            <scope>provided</scope>
+            <exclusions>
+                <exclusion>
+                    <artifactId>commons-io</artifactId>
+                    <groupId>commons-io</groupId>
+                </exclusion>
+                <exclusion>
+                    <groupId>commons-cli</groupId>
+                    <artifactId>commons-cli</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>commons-lang</groupId>
+                    <artifactId>commons-lang</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.avro</groupId>
+                    <artifactId>avro</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>
+            </exclusions>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.hadoop</groupId>
             <artifactId>hadoop-common</artifactId>
@@ -137,6 +174,12 @@
             <scope>provided</scope>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-core</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.flink</groupId>
             <artifactId>flink-table-common</artifactId>
@@ -163,6 +206,29 @@
         </dependency>
 
         <!-- test dependency -->
+        <dependency>
+            <groupId>org.apache.fluss</groupId>
+            <artifactId>fluss-flink-common</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+            <type>test-jar</type>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.fluss</groupId>
+            <artifactId>fluss-server</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.fluss</groupId>
+            <artifactId>fluss-server</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+            <type>test-jar</type>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.fluss</groupId>
             <artifactId>fluss-common</artifactId>
@@ -175,6 +241,45 @@
             <groupId>org.apache.fluss</groupId>
             <artifactId>fluss-test-utils</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>org.apache.curator</groupId>
+            <artifactId>curator-test</artifactId>
+            <version>${curator.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- Flink test dependency -->
+        <dependency>
+            <groupId>org.apache.fluss</groupId>
+            <artifactId>fluss-flink-${flink.major.version}</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.fluss</groupId>
+            <artifactId>fluss-flink-common</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-connector-base</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-connector-files</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-table-test-utils</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
 </project>
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 55ce818a9..9c635d92b 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
@@ -20,6 +20,7 @@ package org.apache.fluss.lake.hudi;
 import org.apache.fluss.config.Configuration;
 import org.apache.fluss.lake.hudi.source.HudiLakeSource;
 import org.apache.fluss.lake.hudi.source.HudiSplit;
+import org.apache.fluss.lake.hudi.tiering.HudiLakeTieringFactory;
 import org.apache.fluss.lake.lakestorage.LakeCatalog;
 import org.apache.fluss.lake.lakestorage.LakeStorage;
 import org.apache.fluss.lake.source.LakeSource;
@@ -37,9 +38,7 @@ public class HudiLakeStorage implements LakeStorage {
 
     @Override
     public LakeTieringFactory<?, ?> createLakeTieringFactory() {
-        throw new UnsupportedOperationException(
-                "Hudi lake tiering writer is not implemented yet, so 
HudiLakeStorage does not "
-                        + "support creating a LakeTieringFactory.");
+        return new HudiLakeTieringFactory(hudiConfig);
     }
 
     @Override
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRecordReader.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRecordReader.java
index 9cd52a15b..ea88aac94 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRecordReader.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRecordReader.java
@@ -103,10 +103,10 @@ public class HudiRecordReader implements RecordReader {
             boolean emitDelete =
                     hudiTableInfo.getTableType() == 
HoodieTableType.MERGE_ON_READ
                             && (flinkHudiOptions
-                                            .getString(FlinkOptions.QUERY_TYPE)
+                                            .get(FlinkOptions.QUERY_TYPE)
                                             
.equals(FlinkOptions.QUERY_TYPE_SNAPSHOT)
                                     || flinkHudiOptions
-                                            .getString(FlinkOptions.QUERY_TYPE)
+                                            .get(FlinkOptions.QUERY_TYPE)
                                             
.equals(FlinkOptions.QUERY_TYPE_INCREMENTAL));
 
             UnifiedHudiTableReader unifiedHudiTableReader =
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCommittable.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCommittable.java
new file mode 100644
index 000000000..e1266d639
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCommittable.java
@@ -0,0 +1,122 @@
+/*
+ * 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.tiering;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/** The committable aggregated from Hudi write results for one tiering round. 
*/
+public class HudiCommittable implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private final Map<String, HudiWriteStats> writeStats;
+    private final Map<String, HudiWriteStats> compactionWriteStats;
+
+    public HudiCommittable(
+            Map<String, HudiWriteStats> writeStats,
+            @Nullable Map<String, HudiWriteStats> compactionWriteStats) {
+        this.writeStats = copyWriteStats(writeStats);
+        this.compactionWriteStats = copyWriteStats(compactionWriteStats);
+    }
+
+    public Map<String, HudiWriteStats> getWriteStats() {
+        return writeStats;
+    }
+
+    public Map<String, HudiWriteStats> getCompactionWriteStats() {
+        return compactionWriteStats;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    private static Map<String, HudiWriteStats> copyWriteStats(
+            @Nullable Map<String, HudiWriteStats> statsByInstant) {
+        if (statsByInstant == null || statsByInstant.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return Collections.unmodifiableMap(new HashMap<>(statsByInstant));
+    }
+
+    /** Builder for {@link HudiCommittable}. */
+    public static class Builder {
+
+        private final Map<String, HudiWriteStats> writeStats = new HashMap<>();
+        private final Map<String, HudiWriteStats> compactionWriteStats = new 
HashMap<>();
+
+        public Builder addWriteStats(Map<String, HudiWriteStats> 
statsByInstant) {
+            addAll(writeStats, statsByInstant);
+            return this;
+        }
+
+        public Builder addCompactionWriteStats(
+                @Nullable Map<String, HudiWriteStats> statsByInstant) {
+            addAll(compactionWriteStats, statsByInstant);
+            return this;
+        }
+
+        public HudiCommittable build() {
+            return new HudiCommittable(writeStats, compactionWriteStats);
+        }
+
+        private static void addAll(
+                Map<String, HudiWriteStats> target, @Nullable Map<String, 
HudiWriteStats> source) {
+            if (source == null || source.isEmpty()) {
+                return;
+            }
+            for (Map.Entry<String, HudiWriteStats> entry : source.entrySet()) {
+                target.merge(entry.getKey(), entry.getValue(), 
HudiWriteStats::merge);
+            }
+        }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof HudiCommittable)) {
+            return false;
+        }
+        HudiCommittable that = (HudiCommittable) o;
+        return Objects.equals(writeStats, that.writeStats)
+                && Objects.equals(compactionWriteStats, 
that.compactionWriteStats);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(writeStats, compactionWriteStats);
+    }
+
+    @Override
+    public String toString() {
+        return "HudiCommittable{"
+                + "writeStats="
+                + writeStats
+                + ", compactionWriteStats="
+                + compactionWriteStats
+                + '}';
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCommittableSerializer.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCommittableSerializer.java
new file mode 100644
index 000000000..39eb3a1a9
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCommittableSerializer.java
@@ -0,0 +1,70 @@
+/*
+ * 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.tiering;
+
+import org.apache.fluss.lake.serializer.SimpleVersionedSerializer;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.Map;
+
+/** Serializer for {@link HudiCommittable}. */
+public class HudiCommittableSerializer implements 
SimpleVersionedSerializer<HudiCommittable> {
+
+    private static final int CURRENT_VERSION = 1;
+
+    @Override
+    public int getVersion() {
+        return CURRENT_VERSION;
+    }
+
+    @Override
+    public byte[] serialize(HudiCommittable hudiCommittable) throws 
IOException {
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                DataOutputStream dos = new DataOutputStream(baos)) {
+            HudiWriteStatsSerde.writeStatsMap(dos, 
hudiCommittable.getWriteStats());
+            HudiWriteStatsSerde.writeStatsMap(dos, 
hudiCommittable.getCompactionWriteStats());
+            return baos.toByteArray();
+        }
+    }
+
+    @Override
+    public HudiCommittable deserialize(int version, byte[] serialized) throws 
IOException {
+        if (version != CURRENT_VERSION) {
+            throw new IOException(
+                    "Unsupported HudiCommittable version "
+                            + version
+                            + ", expected "
+                            + CURRENT_VERSION);
+        }
+
+        Map<String, HudiWriteStats> writeStats;
+        Map<String, HudiWriteStats> compactionWriteStats;
+        try (DataInputStream dis = new DataInputStream(new 
ByteArrayInputStream(serialized))) {
+            writeStats = HudiWriteStatsSerde.readStatsMap(dis, "WriteStats");
+            compactionWriteStats = HudiWriteStatsSerde.readStatsMap(dis, 
"CompactionWriteStats");
+            if (dis.available() > 0) {
+                throw new IOException("Corrupted serialization: trailing bytes 
" + dis.available());
+            }
+        }
+        return new HudiCommittable(writeStats, compactionWriteStats);
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeCommitter.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeCommitter.java
new file mode 100644
index 000000000..44d898e29
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeCommitter.java
@@ -0,0 +1,301 @@
+/*
+ * 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.tiering;
+
+import org.apache.fluss.lake.committer.CommittedLakeSnapshot;
+import org.apache.fluss.lake.committer.LakeCommitResult;
+import org.apache.fluss.lake.committer.LakeCommitter;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadata;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadataProvider;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.utils.IOUtils;
+
+import org.apache.hudi.client.HoodieFlinkWriteClient;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.exception.HoodieException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static 
org.apache.fluss.lake.writer.LakeTieringFactory.FLUSS_LAKE_TIERING_COMMIT_USER;
+
+/** Hudi implementation of {@link LakeCommitter}. */
+public class HudiLakeCommitter implements LakeCommitter<HudiWriteResult, 
HudiCommittable> {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(HudiLakeCommitter.class);
+
+    private static final String COMMITTER_USER = "commit-user";
+
+    private final HudiWriteTableInfo hudiTableInfo;
+    private final HoodieFlinkWriteClient<?> writeClient;
+    private final CkpMetadata ckpMetadata;
+
+    public HudiLakeCommitter(
+            HudiCatalogProvider hudiCatalogProvider,
+            CkpMetadataProvider ckpMetadataProvider,
+            TablePath tablePath)
+            throws IOException {
+        this.hudiTableInfo = HudiWriteTableInfo.create(hudiCatalogProvider, 
tablePath);
+        this.writeClient = hudiTableInfo.getWriteClient();
+        this.ckpMetadata = ckpMetadataProvider.get(tablePath, hudiTableInfo);
+        LOG.info(
+                "Created HudiLakeCommitter with configuration {}.", 
hudiTableInfo.getFlinkConfig());
+    }
+
+    @Override
+    public HudiCommittable toCommittable(List<HudiWriteResult> 
hudiWriteResults) {
+        HudiCommittable.Builder committableBuilder = HudiCommittable.builder();
+        for (HudiWriteResult hudiWriteResult : hudiWriteResults) {
+            committableBuilder.addWriteStats(hudiWriteResult.getWriteStats());
+            
committableBuilder.addCompactionWriteStats(hudiWriteResult.getCompactionWriteStats());
+        }
+        return committableBuilder.build();
+    }
+
+    @Override
+    public LakeCommitResult commit(
+            HudiCommittable committable, Map<String, String> 
snapshotProperties)
+            throws IOException {
+        ensureNoCompactionWriteStats(committable);
+
+        Map<String, HudiWriteStats> writeStatsByInstant = 
committable.getWriteStats();
+        if (writeStatsByInstant.size() != 1) {
+            throw new IOException(
+                    "Hudi write stats must contain exactly one instant, but 
got "
+                            + writeStatsByInstant.keySet()
+                            + ".");
+        }
+
+        Map.Entry<String, HudiWriteStats> entry = 
writeStatsByInstant.entrySet().iterator().next();
+        String instant = entry.getKey();
+        HudiWriteStats writeStats = entry.getValue();
+
+        Map<String, String> commitMetadata = new HashMap<>(snapshotProperties);
+        commitMetadata.put(COMMITTER_USER, FLUSS_LAKE_TIERING_COMMIT_USER);
+
+        try {
+            validateWriteStats(instant, writeStats);
+
+            LOG.info(
+                    "Committing Hudi instant {} with {} write stat entries and 
metadata {}.",
+                    instant,
+                    writeStats.getWriteStats().size(),
+                    commitMetadata);
+            boolean committed =
+                    writeClient.commitStats(
+                            instant,
+                            writeStats.getWriteStats(),
+                            Option.of(commitMetadata),
+                            
hudiTableInfo.getMetaClient().getCommitActionType());
+            if (!committed) {
+                IOException failure =
+                        new IOException("Failed to commit Hudi instant " + 
instant + ".");
+                try {
+                    abortInstant(instant);
+                } catch (IOException abortFailure) {
+                    failure.addSuppressed(abortFailure);
+                }
+                throw failure;
+            }
+
+            ckpMetadata.commitInstant(instant);
+            LOG.info("Committed Hudi instant {} successfully.", instant);
+            return 
LakeCommitResult.committedIsReadable(parseSnapshotId(instant));
+        } catch (Exception e) {
+            if (e instanceof IOException) {
+                throw (IOException) e;
+            }
+            throw new IOException("Failed to commit Hudi instant " + instant + 
".", e);
+        }
+    }
+
+    @Override
+    public void abort(HudiCommittable committable) throws IOException {
+        Set<String> instants = new 
LinkedHashSet<>(committable.getWriteStats().keySet());
+        instants.addAll(committable.getCompactionWriteStats().keySet());
+        IOException failure = null;
+        for (String instant : instants) {
+            try {
+                abortInstant(instant);
+                LOG.info("Aborted Hudi instant {}.", instant);
+            } catch (IOException e) {
+                failure =
+                        addSuppressed(
+                                failure,
+                                new IOException(
+                                        "Failed to abort Hudi instant " + 
instant + ".", e));
+            }
+        }
+        if (failure != null) {
+            throw failure;
+        }
+    }
+
+    private static void ensureNoCompactionWriteStats(HudiCommittable 
committable)
+            throws IOException {
+        Map<String, HudiWriteStats> compactionWriteStats = 
committable.getCompactionWriteStats();
+        if (!compactionWriteStats.isEmpty()) {
+            throw new IOException(
+                    "Hudi compaction write stats are not supported yet, but 
got instants "
+                            + compactionWriteStats.keySet()
+                            + ".");
+        }
+    }
+
+    @Nullable
+    @Override
+    public CommittedLakeSnapshot getMissingLakeSnapshot(@Nullable Long 
latestLakeSnapshotIdOfFluss)
+            throws IOException {
+        HoodieTimeline latestLakeTimeline =
+                
getCompletedTimelineCommittedBy(FLUSS_LAKE_TIERING_COMMIT_USER);
+        Optional<HoodieInstant> latestLakeInstant =
+                latestLakeTimeline.getReverseOrderedInstants().findFirst();
+        if (!latestLakeInstant.isPresent()) {
+            return null;
+        }
+
+        long latestLakeSnapshotId = 
parseSnapshotId(latestLakeInstant.get().requestedTime());
+        if (latestLakeSnapshotIdOfFluss != null
+                && latestLakeSnapshotId <= latestLakeSnapshotIdOfFluss) {
+            return null;
+        }
+
+        HoodieCommitMetadata metadata =
+                latestLakeTimeline.readCommitMetadata(latestLakeInstant.get());
+        if (metadata == null) {
+            throw new IOException("Failed to load committed Hudi instant 
metadata.");
+        }
+        Map<String, String> extraMetadata = metadata.getExtraMetadata();
+        if (extraMetadata == null) {
+            throw new IOException("Failed to load committed Hudi instant extra 
metadata.");
+        }
+        return new CommittedLakeSnapshot(latestLakeSnapshotId, extraMetadata);
+    }
+
+    @Override
+    public void close() throws Exception {
+        IOUtils.closeQuietly(ckpMetadata, "hudi checkpoint metadata");
+        IOUtils.closeQuietly(hudiTableInfo, "hudi table info");
+    }
+
+    private void validateWriteStats(String instant, HudiWriteStats writeStats) 
{
+        long totalErrorRecords = writeStats.getTotalErrorRecords();
+        if (totalErrorRecords > 0
+                && 
!hudiTableInfo.getFlinkConfig().get(FlinkOptions.IGNORE_FAILED)) {
+            throw new HoodieException(
+                    String.format(
+                            "Commit Hudi instant %s failed with %s error 
records.",
+                            instant, totalErrorRecords));
+        }
+    }
+
+    private void abortInstant(String instant) throws IOException {
+        IOException failure = null;
+        try {
+            boolean rolledBack = writeClient.rollback(instant);
+            if (!rolledBack) {
+                throw new IOException("Hudi rollback returned false for 
instant " + instant + ".");
+            }
+        } catch (Exception e) {
+            failure =
+                    addSuppressed(
+                            failure,
+                            new IOException("Failed to rollback Hudi instant " 
+ instant + ".", e));
+        }
+
+        try {
+            ckpMetadata.abortInstant(instant);
+        } catch (Exception e) {
+            failure =
+                    addSuppressed(
+                            failure,
+                            new IOException(
+                                    "Failed to abort Hudi checkpoint metadata 
for instant "
+                                            + instant
+                                            + ".",
+                                    e));
+        }
+
+        if (failure != null) {
+            throw failure;
+        }
+    }
+
+    private HoodieTimeline getCompletedTimelineCommittedBy(String commitUser) 
throws IOException {
+        hudiTableInfo.getMetaClient().reloadActiveTimeline();
+        HoodieTimeline timeline =
+                writeClient
+                        .getHoodieTable()
+                        .getMetaClient()
+                        .getActiveTimeline()
+                        .getCommitsAndCompactionTimeline()
+                        .filterCompletedInstants();
+        try {
+            return timeline.filter(instant -> isCommittedBy(timeline, instant, 
commitUser));
+        } catch (UncheckedIOException e) {
+            throw e.getCause();
+        }
+    }
+
+    private static boolean isCommittedBy(
+            HoodieTimeline timeline, HoodieInstant instant, String commitUser) 
{
+        try {
+            HoodieCommitMetadata metadata = 
timeline.readCommitMetadata(instant);
+            if (metadata == null) {
+                throw new IOException("Failed to load committed Hudi instant 
metadata.");
+            }
+            Map<String, String> extraMetadata = metadata.getExtraMetadata();
+            return extraMetadata != null && 
commitUser.equals(extraMetadata.get(COMMITTER_USER));
+        } catch (IOException e) {
+            // a read failure must not be silently treated as "not committed 
by Fluss",
+            // otherwise we may miss an already-tiered snapshot and re-commit 
duplicated data
+            throw new UncheckedIOException(
+                    "Failed to read Hudi commit metadata for instant " + 
instant + ".", e);
+        }
+    }
+
+    private static long parseSnapshotId(String instant) throws IOException {
+        try {
+            return Long.parseLong(instant);
+        } catch (NumberFormatException e) {
+            throw new IOException("Invalid Hudi instant time " + instant + 
".", e);
+        }
+    }
+
+    private static IOException addSuppressed(IOException failure, IOException 
current) {
+        if (failure == null) {
+            return current;
+        }
+        failure.addSuppressed(current);
+        return failure;
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeTieringFactory.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeTieringFactory.java
index a126cbd17..eaa792721 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeTieringFactory.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeTieringFactory.java
@@ -27,10 +27,10 @@ import org.apache.fluss.lake.writer.LakeWriter;
 import org.apache.fluss.lake.writer.WriterInitContext;
 
 import java.io.IOException;
-import java.io.Serializable;
 
 /** Hudi implementation of {@link LakeTieringFactory}. */
-public class HudiLakeTieringFactory implements 
LakeTieringFactory<HudiWriteResult, Serializable> {
+public class HudiLakeTieringFactory
+        implements LakeTieringFactory<HudiWriteResult, HudiCommittable> {
 
     private static final long serialVersionUID = 1L;
 
@@ -54,14 +54,14 @@ public class HudiLakeTieringFactory implements 
LakeTieringFactory<HudiWriteResul
     }
 
     @Override
-    public LakeCommitter<HudiWriteResult, Serializable> createLakeCommitter(
-            CommitterInitContext committerInitContext) {
-        throw new UnsupportedOperationException("Hudi lake committer is not 
implemented yet.");
+    public LakeCommitter<HudiWriteResult, HudiCommittable> createLakeCommitter(
+            CommitterInitContext committerInitContext) throws IOException {
+        return new HudiLakeCommitter(
+                hudiCatalogProvider, ckpMetadataProvider, 
committerInitContext.tablePath());
     }
 
     @Override
-    public SimpleVersionedSerializer<Serializable> getCommittableSerializer() {
-        throw new UnsupportedOperationException(
-                "Hudi lake committable serializer is not implemented yet.");
+    public SimpleVersionedSerializer<HudiCommittable> 
getCommittableSerializer() {
+        return new HudiCommittableSerializer();
     }
 }
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeWriter.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeWriter.java
index f58bd2900..1b9c869a2 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeWriter.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeWriter.java
@@ -36,7 +36,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.util.HashMap;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -91,7 +91,7 @@ public class HudiLakeWriter implements 
LakeWriter<HudiWriteResult> {
     public HudiWriteResult complete() throws IOException {
         try {
             Map<String, List<WriteStatus>> writeStatuses = 
recordWriter.complete();
-            return new HudiWriteResult(writeStatuses, new HashMap<>());
+            return HudiWriteResult.fromWriteStatuses(writeStatuses, 
Collections.emptyMap());
         } catch (Exception e) {
             throw new IOException("Failed to complete Hudi write.", e);
         }
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResult.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResult.java
index 973911554..d6436e2d7 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResult.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResult.java
@@ -19,7 +19,11 @@ package org.apache.fluss.lake.hudi.tiering;
 
 import org.apache.hudi.client.WriteStatus;
 
+import javax.annotation.Nullable;
+
 import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -28,21 +32,49 @@ public class HudiWriteResult implements Serializable {
 
     private static final long serialVersionUID = 1L;
 
-    private final Map<String, List<WriteStatus>> writeStatuses;
-    private final Map<String, List<WriteStatus>> compactionWriteStatuses;
+    private final Map<String, HudiWriteStats> writeStats;
+    private final Map<String, HudiWriteStats> compactionWriteStats;
 
     public HudiWriteResult(
+            Map<String, HudiWriteStats> writeStats,
+            @Nullable Map<String, HudiWriteStats> compactionWriteStats) {
+        this.writeStats = copyWriteStats(writeStats);
+        this.compactionWriteStats = copyWriteStats(compactionWriteStats);
+    }
+
+    public static HudiWriteResult fromWriteStatuses(
             Map<String, List<WriteStatus>> writeStatuses,
-            Map<String, List<WriteStatus>> compactionWriteStatuses) {
-        this.writeStatuses = writeStatuses;
-        this.compactionWriteStatuses = compactionWriteStatuses;
+            @Nullable Map<String, List<WriteStatus>> compactionWriteStatuses) {
+        return new HudiWriteResult(
+                toWriteStats(writeStatuses), 
toWriteStats(compactionWriteStatuses));
+    }
+
+    public Map<String, HudiWriteStats> getWriteStats() {
+        return writeStats;
+    }
+
+    public Map<String, HudiWriteStats> getCompactionWriteStats() {
+        return compactionWriteStats;
     }
 
-    public Map<String, List<WriteStatus>> getWriteStatuses() {
-        return writeStatuses;
+    private static Map<String, HudiWriteStats> copyWriteStats(
+            @Nullable Map<String, HudiWriteStats> statsByInstant) {
+        if (statsByInstant == null || statsByInstant.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return Collections.unmodifiableMap(new HashMap<>(statsByInstant));
     }
 
-    public Map<String, List<WriteStatus>> getCompactionWriteStatuses() {
-        return compactionWriteStatuses;
+    private static Map<String, HudiWriteStats> toWriteStats(
+            @Nullable Map<String, List<WriteStatus>> statusesByInstant) {
+        if (statusesByInstant == null || statusesByInstant.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, HudiWriteStats> statsByInstant = new HashMap<>();
+        for (Map.Entry<String, List<WriteStatus>> entry : 
statusesByInstant.entrySet()) {
+            statsByInstant.put(entry.getKey(), 
HudiWriteStats.fromWriteStatuses(entry.getValue()));
+        }
+        return statsByInstant;
     }
 }
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializer.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializer.java
index 57d60fa9b..e80d398ab 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializer.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializer.java
@@ -18,16 +18,12 @@
 package org.apache.fluss.lake.hudi.tiering;
 
 import org.apache.fluss.lake.serializer.SimpleVersionedSerializer;
-import org.apache.fluss.utils.InstantiationUtils;
-
-import org.apache.hudi.client.WriteStatus;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
-import java.util.List;
 import java.util.Map;
 
 /** Serializer for {@link HudiWriteResult}. */
@@ -42,17 +38,10 @@ public class HudiWriteResultSerializer implements 
SimpleVersionedSerializer<Hudi
 
     @Override
     public byte[] serialize(HudiWriteResult hudiWriteResult) throws 
IOException {
-        byte[] writeResultBytes =
-                
InstantiationUtils.serializeObject(hudiWriteResult.getWriteStatuses());
-        byte[] compactionWriteResultBytes =
-                
InstantiationUtils.serializeObject(hudiWriteResult.getCompactionWriteStatuses());
-
         try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
                 DataOutputStream dos = new DataOutputStream(baos)) {
-            dos.writeInt(writeResultBytes.length);
-            dos.write(writeResultBytes);
-            dos.writeInt(compactionWriteResultBytes.length);
-            dos.write(compactionWriteResultBytes);
+            HudiWriteStatsSerde.writeStatsMap(dos, 
hudiWriteResult.getWriteStats());
+            HudiWriteStatsSerde.writeStatsMap(dos, 
hudiWriteResult.getCompactionWriteStats());
             return baos.toByteArray();
         }
     }
@@ -67,40 +56,15 @@ public class HudiWriteResultSerializer implements 
SimpleVersionedSerializer<Hudi
                             + CURRENT_VERSION);
         }
 
-        Map<String, List<WriteStatus>> writeResult;
-        Map<String, List<WriteStatus>> compactionWriteResult;
+        Map<String, HudiWriteStats> writeStats;
+        Map<String, HudiWriteStats> compactionWriteStats;
         try (DataInputStream dis = new DataInputStream(new 
ByteArrayInputStream(serialized))) {
-            byte[] writeResultBytes = readBytes(dis, "WriteResult");
-            writeResult =
-                    InstantiationUtils.deserializeObject(
-                            writeResultBytes, getClass().getClassLoader());
-
-            byte[] compactionWriteResultBytes = readBytes(dis, 
"CompactionWriteResult");
-            compactionWriteResult =
-                    InstantiationUtils.deserializeObject(
-                            compactionWriteResultBytes, 
getClass().getClassLoader());
+            writeStats = HudiWriteStatsSerde.readStatsMap(dis, "WriteStats");
+            compactionWriteStats = HudiWriteStatsSerde.readStatsMap(dis, 
"CompactionWriteStats");
             if (dis.available() > 0) {
                 throw new IOException("Corrupted serialization: trailing bytes 
" + dis.available());
             }
-        } catch (ClassNotFoundException e) {
-            throw new IOException("Couldn't deserialize HudiWriteResult.", e);
-        }
-        return new HudiWriteResult(writeResult, compactionWriteResult);
-    }
-
-    private static byte[] readBytes(DataInputStream dis, String field) throws 
IOException {
-        int length = dis.readInt();
-        validateLength(length, dis.available(), field);
-        byte[] bytes = new byte[length];
-        dis.readFully(bytes);
-        return bytes;
-    }
-
-    private static void validateLength(int length, int remainingLength, String 
field)
-            throws IOException {
-        if (length < 0 || length > remainingLength) {
-            throw new IOException(
-                    "Corrupted serialization: invalid " + field + " length " + 
length);
         }
+        return new HudiWriteResult(writeStats, compactionWriteStats);
     }
 }
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteStats.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteStats.java
new file mode 100644
index 000000000..bcb53b204
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteStats.java
@@ -0,0 +1,119 @@
+/*
+ * 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.tiering;
+
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieWriteStat;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/** Hudi write stats that are sufficient for committing one Hudi instant. */
+public class HudiWriteStats implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private final List<HoodieWriteStat> writeStats;
+    private final long totalErrorRecords;
+
+    public HudiWriteStats(List<HoodieWriteStat> writeStats, long 
totalErrorRecords) {
+        checkArgument(
+                totalErrorRecords >= 0,
+                "Hudi total error records must not be negative, but is %s.",
+                totalErrorRecords);
+        this.writeStats = copyWriteStats(writeStats);
+        this.totalErrorRecords = totalErrorRecords;
+    }
+
+    public static HudiWriteStats fromWriteStatuses(List<WriteStatus> 
writeStatuses) {
+        checkNotNull(writeStatuses, "Hudi write statuses must not be null.");
+
+        List<HoodieWriteStat> stats = new ArrayList<>(writeStatuses.size());
+        long totalErrorRecords = 0L;
+        for (WriteStatus writeStatus : writeStatuses) {
+            checkNotNull(writeStatus, "Hudi write status must not be null.");
+            stats.add(checkNotNull(writeStatus.getStat(), "Hudi write stat 
must not be null."));
+            totalErrorRecords += writeStatus.getTotalErrorRecords();
+        }
+        return new HudiWriteStats(stats, totalErrorRecords);
+    }
+
+    public List<HoodieWriteStat> getWriteStats() {
+        return writeStats;
+    }
+
+    public long getTotalErrorRecords() {
+        return totalErrorRecords;
+    }
+
+    public HudiWriteStats merge(HudiWriteStats other) {
+        checkNotNull(other, "Hudi write stats to merge must not be null.");
+
+        List<HoodieWriteStat> mergedStats =
+                new ArrayList<>(writeStats.size() + other.writeStats.size());
+        mergedStats.addAll(writeStats);
+        mergedStats.addAll(other.writeStats);
+        return new HudiWriteStats(mergedStats, totalErrorRecords + 
other.totalErrorRecords);
+    }
+
+    private static List<HoodieWriteStat> copyWriteStats(List<HoodieWriteStat> 
writeStats) {
+        if (writeStats == null || writeStats.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<HoodieWriteStat> copiedStats = new ArrayList<>(writeStats.size());
+        for (HoodieWriteStat writeStat : writeStats) {
+            copiedStats.add(checkNotNull(writeStat, "Hudi write stat must not 
be null."));
+        }
+        return Collections.unmodifiableList(copiedStats);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof HudiWriteStats)) {
+            return false;
+        }
+        HudiWriteStats that = (HudiWriteStats) o;
+        return totalErrorRecords == that.totalErrorRecords
+                && Objects.equals(writeStats, that.writeStats);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(writeStats, totalErrorRecords);
+    }
+
+    @Override
+    public String toString() {
+        return "HudiWriteStats{"
+                + "writeStats="
+                + writeStats
+                + ", totalErrorRecords="
+                + totalErrorRecords
+                + '}';
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteStatsSerde.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteStatsSerde.java
new file mode 100644
index 000000000..33059fc00
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteStatsSerde.java
@@ -0,0 +1,121 @@
+/*
+ * 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.tiering;
+
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieWriteStat;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Utilities to serialize Hudi write stats without Java object serialization. 
*/
+final class HudiWriteStatsSerde {
+
+    private HudiWriteStatsSerde() {}
+
+    static void writeStatsMap(DataOutputStream dos, Map<String, 
HudiWriteStats> statsByInstant)
+            throws IOException {
+        dos.writeInt(statsByInstant.size());
+        for (Map.Entry<String, HudiWriteStats> entry : 
statsByInstant.entrySet()) {
+            writeString(dos, entry.getKey());
+            HudiWriteStats writeStats = entry.getValue();
+            dos.writeLong(writeStats.getTotalErrorRecords());
+            writeString(dos, toCommitMetadataJson(writeStats.getWriteStats()));
+        }
+    }
+
+    static Map<String, HudiWriteStats> readStatsMap(DataInputStream dis, 
String field)
+            throws IOException {
+        int mapSize = dis.readInt();
+        if (mapSize < 0 || mapSize > dis.available()) {
+            throw new IOException("Corrupted serialization: invalid " + field 
+ " size " + mapSize);
+        }
+
+        Map<String, HudiWriteStats> statsByInstant = new LinkedHashMap<>();
+        for (int i = 0; i < mapSize; i++) {
+            String instant = readString(dis, field + " instant");
+            long totalErrorRecords = dis.readLong();
+            if (totalErrorRecords < 0) {
+                throw new IOException(
+                        "Corrupted serialization: invalid "
+                                + field
+                                + " total error records "
+                                + totalErrorRecords);
+            }
+            String commitMetadataJson = readString(dis, field + " write 
stats");
+            statsByInstant.put(
+                    instant,
+                    new HudiWriteStats(
+                            fromCommitMetadataJson(commitMetadataJson), 
totalErrorRecords));
+        }
+        return statsByInstant;
+    }
+
+    private static String toCommitMetadataJson(List<HoodieWriteStat> 
writeStats)
+            throws IOException {
+        HoodieCommitMetadata commitMetadata = new HoodieCommitMetadata();
+        for (HoodieWriteStat writeStat : writeStats) {
+            commitMetadata.addWriteStat(writeStat.getPartitionPath(), 
writeStat);
+        }
+        return commitMetadata.toJsonString();
+    }
+
+    private static List<HoodieWriteStat> fromCommitMetadataJson(String 
commitMetadataJson)
+            throws IOException {
+        try {
+            HoodieCommitMetadata commitMetadata =
+                    HoodieCommitMetadata.fromJsonString(
+                            commitMetadataJson, HoodieCommitMetadata.class);
+            if (commitMetadata == null) {
+                throw new IOException("Failed to deserialize Hudi commit 
metadata.");
+            }
+            return commitMetadata.getWriteStats();
+        } catch (IOException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IOException("Failed to deserialize Hudi commit 
metadata.", e);
+        }
+    }
+
+    private static void writeString(DataOutputStream dos, String value) throws 
IOException {
+        byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+        dos.writeInt(bytes.length);
+        dos.write(bytes);
+    }
+
+    private static String readString(DataInputStream dis, String field) throws 
IOException {
+        int length = dis.readInt();
+        validateLength(length, dis.available(), field);
+        byte[] bytes = new byte[length];
+        dis.readFully(bytes);
+        return new String(bytes, StandardCharsets.UTF_8);
+    }
+
+    private static void validateLength(int length, int remainingLength, String 
field)
+            throws IOException {
+        if (length < 0 || length > remainingLength) {
+            throw new IOException(
+                    "Corrupted serialization: invalid " + field + " length " + 
length);
+        }
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/RecordWriter.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/RecordWriter.java
index e4a0e2d06..c730ae937 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/RecordWriter.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/RecordWriter.java
@@ -91,9 +91,7 @@ public abstract class RecordWriter implements AutoCloseable {
     public void setRecordLocation(HoodieFlinkInternalRow internalRow, 
Configuration configuration) {
         String partition = internalRow.getPartitionPath();
 
-        if (!configuration
-                .getString(FlinkOptions.OPERATION)
-                .equals(WriteOperationType.INSERT.value())) {
+        if 
(!configuration.get(FlinkOptions.OPERATION).equals(WriteOperationType.INSERT.value()))
 {
             bootstrapIndexIfNeed(partition);
         }
 
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBuffer.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBuffer.java
index 3a99ede2a..0f03dd782 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBuffer.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBuffer.java
@@ -179,8 +179,7 @@ public class RecordWriteBuffer {
 
         ValidationUtils.checkState(!bucket.isEmpty(), "Data bucket to flush 
has no records.");
         List<WriteStatus> writeStatus = 
writeStatuses.getOrDefault(instantTime, new ArrayList<>());
-        writeStatus.addAll(
-                writeRecords(instantTime, 
config.getString(FlinkOptions.OPERATION), bucket));
+        writeStatus.addAll(writeRecords(instantTime, 
config.get(FlinkOptions.OPERATION), bucket));
         writeStatuses.put(instantTime, writeStatus);
         return true;
     }
@@ -191,7 +190,7 @@ public class RecordWriteBuffer {
                         hudiTableInfo.getRowType(),
                         
MemorySegmentPoolFactory.createMemorySegmentPool(config)),
                 bucketInfo,
-                config.getDouble(FlinkOptions.WRITE_BATCH_SIZE));
+                config.get(FlinkOptions.WRITE_BATCH_SIZE));
     }
 
     protected List<WriteStatus> writeRecords(
@@ -361,11 +360,9 @@ public class RecordWriteBuffer {
 
         TotalSizeTracer(Configuration conf) {
             long mergeReaderMem = 100;
-            long mergeMapMaxMem = 
conf.getInteger(FlinkOptions.WRITE_MERGE_MAX_MEMORY);
+            long mergeMapMaxMem = 
conf.get(FlinkOptions.WRITE_MERGE_MAX_MEMORY);
             this.maxBufferSize =
-                    (conf.getDouble(FlinkOptions.WRITE_TASK_MAX_SIZE)
-                                    - mergeReaderMem
-                                    - mergeMapMaxMem)
+                    (conf.get(FlinkOptions.WRITE_TASK_MAX_SIZE) - 
mergeReaderMem - mergeMapMaxMem)
                             * 1024
                             * 1024;
             ValidationUtils.checkState(
diff --git 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataFactory.java
 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataFactory.java
index 40408b1cc..225cc7262 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataFactory.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataFactory.java
@@ -31,10 +31,9 @@ public class CkpMetadataFactory {
     public static CkpMetadata getCkpMetadata(Configuration conf) {
         FileSystem fs =
                 HadoopFSUtils.getFs(
-                        conf.getString(FlinkOptions.PATH),
-                        HadoopConfigurations.getHadoopConf(conf));
-        String basePath = conf.getString(FlinkOptions.PATH);
-        String uniqueId = conf.getString(FlinkOptions.WRITE_CLIENT_ID);
+                        conf.get(FlinkOptions.PATH), 
HadoopConfigurations.getHadoopConf(conf));
+        String basePath = conf.get(FlinkOptions.PATH);
+        String uniqueId = conf.get(FlinkOptions.WRITE_CLIENT_ID);
         return new CkpMetadata(fs, basePath, uniqueId);
     }
 }
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/HudiLakeStorageTest.java
 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/HudiLakeStorageTest.java
new file mode 100644
index 000000000..2514f2289
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/HudiLakeStorageTest.java
@@ -0,0 +1,41 @@
+/*
+ * 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.lake.hudi.tiering.HudiLakeTieringFactory;
+import org.apache.fluss.lake.writer.LakeTieringFactory;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link HudiLakeStorage}. */
+class HudiLakeStorageTest {
+
+    @Test
+    void testCreateLakeTieringFactory() {
+        HudiLakeStorage lakeStorage = new HudiLakeStorage(new Configuration());
+
+        LakeTieringFactory<?, ?> lakeTieringFactory = 
lakeStorage.createLakeTieringFactory();
+
+        
assertThat(lakeTieringFactory).isInstanceOf(HudiLakeTieringFactory.class);
+        assertThat(lakeTieringFactory.getWriteResultSerializer()).isNotNull();
+        assertThat(lakeTieringFactory.getCommittableSerializer()).isNotNull();
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/testutils/FlinkHudiTieringTestBase.java
 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/testutils/FlinkHudiTieringTestBase.java
new file mode 100644
index 000000000..14892fff0
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/testutils/FlinkHudiTieringTestBase.java
@@ -0,0 +1,539 @@
+/*
+ * 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.testutils;
+
+import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.ConnectionFactory;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.table.Table;
+import org.apache.fluss.client.table.writer.AppendWriter;
+import org.apache.fluss.client.table.writer.TableWriter;
+import org.apache.fluss.client.table.writer.UpsertWriter;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.FlussRuntimeException;
+import org.apache.fluss.flink.tiering.LakeTieringJobBuilder;
+import org.apache.fluss.fs.FsPath;
+import org.apache.fluss.lake.hudi.source.UnifiedHudiTableReader;
+import org.apache.fluss.lake.hudi.utils.HudiTableInfo;
+import org.apache.fluss.metadata.DataLakeFormat;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.server.replica.Replica;
+import org.apache.fluss.server.testutils.FlussClusterExtension;
+import org.apache.fluss.server.zk.ZooKeeperClient;
+import org.apache.fluss.server.zk.data.lake.LakeTable;
+
+import org.apache.flink.api.common.RuntimeExecutionMode;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.table.catalog.Catalog;
+import org.apache.flink.table.data.RowData;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.util.collection.ClosableIterator;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.index.bucket.BucketIdentifier;
+import org.apache.hudi.storage.StorageConfiguration;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+import org.apache.hudi.table.catalog.HoodieCatalog;
+import org.apache.hudi.table.format.InternalSchemaManager;
+import org.apache.hudi.util.StreamerUtil;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+
+import java.io.Closeable;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static 
org.apache.fluss.flink.tiering.source.TieringSourceOptions.POLL_TIERING_TABLE_INTERVAL;
+import static 
org.apache.fluss.lake.committer.LakeCommitter.FLUSS_LAKE_SNAP_BUCKET_OFFSET_PROPERTY;
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.apache.fluss.testutils.common.CommonTestUtils.retry;
+import static org.apache.fluss.testutils.common.CommonTestUtils.waitUntil;
+import static org.apache.fluss.testutils.common.CommonTestUtils.waitValue;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test base for tiering to Hudi by Flink. */
+public abstract class FlinkHudiTieringTestBase {
+
+    protected static final String DEFAULT_DB = "fluss";
+    protected static final String CATALOG_NAME = "testcatalog";
+
+    protected StreamExecutionEnvironment execEnv;
+
+    protected static Connection conn;
+    protected static Admin admin;
+    protected static Configuration clientConf;
+    protected static String warehousePath;
+    protected static Catalog hudiCatalog;
+
+    protected static Configuration initConfig() {
+        Configuration conf = new Configuration();
+        conf.set(ConfigOptions.KV_SNAPSHOT_INTERVAL, Duration.ofSeconds(1))
+                .set(ConfigOptions.KV_MAX_RETAINED_SNAPSHOTS, 
Integer.MAX_VALUE);
+        conf.set(ConfigOptions.SERVER_DATA_DISK_WRITE_LIMIT_RATIO, 1.0);
+        conf.set(ConfigOptions.DATALAKE_FORMAT, DataLakeFormat.HUDI);
+        conf.setString("datalake.hudi.mode", "dfs");
+
+        try {
+            warehousePath = 
Files.createTempDirectory("fluss-testing-hudi-tiered").toString();
+        } catch (Exception e) {
+            throw new FlussRuntimeException("Failed to create Hudi warehouse 
path", e);
+        }
+        conf.setString("datalake.hudi.catalog.path", warehousePath);
+        return conf;
+    }
+
+    protected static void beforeAll(Configuration conf) {
+        clientConf = conf;
+        conn = ConnectionFactory.createConnection(clientConf);
+        admin = conn.getAdmin();
+        hudiCatalog = getHudiCatalog();
+        hudiCatalog.open();
+    }
+
+    @AfterAll
+    static void afterAll() throws Exception {
+        if (admin != null) {
+            admin.close();
+            admin = null;
+        }
+        if (conn != null) {
+            conn.close();
+            conn = null;
+        }
+        if (hudiCatalog instanceof Closeable) {
+            ((Closeable) hudiCatalog).close();
+            hudiCatalog = null;
+        }
+    }
+
+    @BeforeEach
+    public void beforeEach() {
+        execEnv = StreamExecutionEnvironment.getExecutionEnvironment();
+        execEnv.setRuntimeMode(RuntimeExecutionMode.STREAMING);
+        execEnv.setParallelism(2);
+    }
+
+    protected abstract FlussClusterExtension getFlussClusterExtension();
+
+    protected JobClient buildTieringJob(StreamExecutionEnvironment execEnv) 
throws Exception {
+        Configuration flussConfig = new Configuration(clientConf);
+        flussConfig.set(POLL_TIERING_TABLE_INTERVAL, Duration.ofMillis(500L));
+        return LakeTieringJobBuilder.newBuilder(
+                        execEnv,
+                        flussConfig,
+                        Configuration.fromMap(getHudiCatalogConf()),
+                        new Configuration(),
+                        DataLakeFormat.HUDI.toString())
+                .build();
+    }
+
+    protected static Map<String, String> getHudiCatalogConf() {
+        Map<String, String> hudiConf = new HashMap<>();
+        hudiConf.put("mode", "dfs");
+        hudiConf.put("catalog.path", warehousePath);
+        return hudiConf;
+    }
+
+    protected static Catalog getHudiCatalog() {
+        return new HoodieCatalog(
+                CATALOG_NAME,
+                
org.apache.flink.configuration.Configuration.fromMap(getHudiCatalogConf()));
+    }
+
+    protected long createPkTable(
+            TablePath tablePath, int bucketNum, boolean enableAutoCompaction, 
Schema schema)
+            throws Exception {
+        TableDescriptor.Builder tableBuilder =
+                TableDescriptor.builder()
+                        .schema(schema)
+                        .distributedBy(bucketNum)
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), 
"true")
+                        .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, 
Duration.ofMillis(500))
+                        .customProperty("hudi.precombine.field", "f_time");
+
+        if (enableAutoCompaction) {
+            
tableBuilder.property(ConfigOptions.TABLE_DATALAKE_AUTO_COMPACTION.key(), 
"true");
+        }
+        return createTable(tablePath, tableBuilder.build());
+    }
+
+    protected long createLogTable(
+            TablePath tablePath, int bucketNum, boolean enableAutoCompaction, 
Schema schema)
+            throws Exception {
+        TableDescriptor.Builder tableBuilder =
+                TableDescriptor.builder()
+                        .schema(schema)
+                        .distributedBy(bucketNum, "f_int")
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), 
"true")
+                        .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, 
Duration.ofMillis(500))
+                        .customProperty("hudi.precombine.field", "f_str")
+                        .customProperty("hudi." + 
FlinkOptions.RECORD_KEY_FIELD.key(), "f_int");
+
+        if (enableAutoCompaction) {
+            
tableBuilder.property(ConfigOptions.TABLE_DATALAKE_AUTO_COMPACTION.key(), 
"true");
+        }
+        return createTable(tablePath, tableBuilder.build());
+    }
+
+    protected long createTable(TablePath tablePath, TableDescriptor 
tableDescriptor)
+            throws Exception {
+        admin.createTable(tablePath, tableDescriptor, true).get();
+        return admin.getTableInfo(tablePath).get().getTableId();
+    }
+
+    protected void waitUntilSnapshot(long tableId, int bucketNum) {
+        List<TableBucket> buckets = new ArrayList<>();
+        for (int i = 0; i < bucketNum; i++) {
+            buckets.add(new TableBucket(tableId, i));
+        }
+        getFlussClusterExtension().triggerAndWaitSnapshots(buckets);
+    }
+
+    protected void writeRows(TablePath tablePath, List<InternalRow> rows, 
boolean append)
+            throws Exception {
+        try (Table table = conn.getTable(tablePath)) {
+            TableWriter tableWriter;
+            if (append) {
+                tableWriter = table.newAppend().createWriter();
+            } else {
+                tableWriter = table.newUpsert().createWriter();
+            }
+            for (InternalRow row : rows) {
+                if (tableWriter instanceof AppendWriter) {
+                    ((AppendWriter) tableWriter).append(row);
+                } else {
+                    ((UpsertWriter) tableWriter).upsert(row);
+                }
+            }
+            tableWriter.flush();
+        }
+    }
+
+    protected Map<String, List<InternalRow>> writeRowsIntoPartitionedTable(
+            TablePath tablePath,
+            TableDescriptor tableDescriptor,
+            Map<Long, String> partitionNameByIds)
+            throws Exception {
+        List<InternalRow> rows = new ArrayList<>();
+        Map<String, List<InternalRow>> writtenRowsByPartition = new 
HashMap<>();
+        for (String partitionName : partitionNameByIds.values()) {
+            List<InternalRow> partitionRows =
+                    Arrays.asList(
+                            row(11, "v1", partitionName),
+                            row(12, "v2", partitionName),
+                            row(13, "v3", partitionName));
+            rows.addAll(partitionRows);
+            writtenRowsByPartition.put(partitionName, partitionRows);
+        }
+
+        writeRows(tablePath, rows, !tableDescriptor.hasPrimaryKey());
+        return writtenRowsByPartition;
+    }
+
+    /**
+     * Waits until the default number of partitions is created, and returns 
the partition id to
+     * partition name mapping.
+     */
+    public Map<Long, String> waitUntilPartitions(TablePath tablePath) {
+        return waitUntilPartitions(
+                getFlussClusterExtension().getZooKeeperClient(),
+                tablePath,
+                
ConfigOptions.TABLE_AUTO_PARTITION_NUM_PRECREATE.defaultValue());
+    }
+
+    /**
+     * Waits until the default number of partitions is created, and returns 
the partition id to
+     * partition name mapping.
+     */
+    public static Map<Long, String> waitUntilPartitions(
+            ZooKeeperClient zooKeeperClient, TablePath tablePath) {
+        return waitUntilPartitions(
+                zooKeeperClient,
+                tablePath,
+                
ConfigOptions.TABLE_AUTO_PARTITION_NUM_PRECREATE.defaultValue());
+    }
+
+    /**
+     * Waits until the expected number of partitions is created, and returns 
the partition id to
+     * partition name mapping.
+     */
+    public static Map<Long, String> waitUntilPartitions(
+            ZooKeeperClient zooKeeperClient, TablePath tablePath, int 
expectPartitions) {
+        return waitValue(
+                () -> {
+                    Map<Long, String> gotPartitions =
+                            zooKeeperClient.getPartitionIdAndNames(tablePath);
+                    return expectPartitions == gotPartitions.size()
+                            ? Optional.of(gotPartitions)
+                            : Optional.empty();
+                },
+                Duration.ofMinutes(1),
+                String.format("expect %d table partition has not been 
created", expectPartitions));
+    }
+
+    protected Replica getLeaderReplica(TableBucket tableBucket) {
+        return getFlussClusterExtension().waitAndGetLeaderReplica(tableBucket);
+    }
+
+    protected void assertReplicaStatus(TableBucket tb, long 
expectedLogEndOffset) {
+        retry(
+                Duration.ofMinutes(1),
+                () -> {
+                    Replica replica = getLeaderReplica(tb);
+                    assertThat(replica.getLogTablet().getLakeTableSnapshotId())
+                            .isGreaterThanOrEqualTo(0);
+                    
assertThat(replica.getLakeLogEndOffset()).isEqualTo(expectedLogEndOffset);
+                });
+    }
+
+    protected void waitUntilBucketSynced(TableBucket tb) {
+        waitUntil(
+                () -> 
getLeaderReplica(tb).getLogTablet().getLakeTableSnapshotId() >= 0,
+                Duration.ofMinutes(2),
+                "bucket " + tb + " not synced");
+    }
+
+    protected void checkDataInHudiMORTable(
+            TablePath tablePath, String partition, List<InternalRow> 
expectedRows, int bucket)
+            throws Exception {
+        List<String> expectedRecords = new ArrayList<>();
+        for (InternalRow row : expectedRows) {
+            expectedRecords.add(formatMORRow(row));
+        }
+
+        List<String> actualRecords =
+                collectHudiRows(
+                        tablePath,
+                        partition,
+                        bucket,
+                        record ->
+                                record.getBoolean(5)
+                                        + ","
+                                        + record.getInt(6)
+                                        + ","
+                                        + record.getLong(7)
+                                        + ","
+                                        + record.getFloat(8)
+                                        + ","
+                                        + record.getDouble(9)
+                                        + ","
+                                        + record.getString(10).toString()
+                                        + ","
+                                        + record.getDecimal(11, 5, 
2).toBigDecimal().toPlainString()
+                                        + ","
+                                        + record.getDecimal(12, 20, 0)
+                                                .toBigDecimal()
+                                                .toPlainString());
+
+        
assertThat(actualRecords).containsExactlyInAnyOrderElementsOf(expectedRecords);
+    }
+
+    protected void checkDataInHudiMORPartitionTable(
+            TablePath tablePath, String partition, List<InternalRow> 
expectedRows, int bucket)
+            throws Exception {
+        List<String> expectedRecords = new ArrayList<>();
+        for (InternalRow row : expectedRows) {
+            expectedRecords.add(
+                    row.getInt(0)
+                            + ","
+                            + row.getString(1).toString()
+                            + ","
+                            + row.getString(2).toString());
+        }
+
+        List<String> actualRecords =
+                collectHudiRows(
+                        tablePath,
+                        partition,
+                        bucket,
+                        record ->
+                                record.getInt(5)
+                                        + ","
+                                        + record.getString(6).toString()
+                                        + ","
+                                        + record.getString(7).toString());
+
+        
assertThat(actualRecords).containsExactlyInAnyOrderElementsOf(expectedRecords);
+    }
+
+    protected void checkDataInHudiCOWTable(
+            TablePath tablePath,
+            String partition,
+            List<InternalRow> expectedRows,
+            long startingOffset,
+            int bucket)
+            throws Exception {
+        List<String> expectedRecords = new ArrayList<>();
+        Iterator<InternalRow> flussRowIterator = expectedRows.iterator();
+        while (flussRowIterator.hasNext()) {
+            InternalRow flussRow = flussRowIterator.next();
+            expectedRecords.add(
+                    flussRow.getInt(0)
+                            + ","
+                            + flussRow.getString(1).toString()
+                            + ","
+                            + startingOffset++);
+        }
+
+        List<String> actualRecords =
+                collectHudiRows(
+                        tablePath,
+                        partition,
+                        bucket,
+                        record ->
+                                record.getInt(5)
+                                        + ","
+                                        + record.getString(6).toString()
+                                        + ","
+                                        + record.getLong(8));
+
+        
assertThat(actualRecords).containsExactlyInAnyOrderElementsOf(expectedRecords);
+        assertThat(flussRowIterator.hasNext()).isFalse();
+    }
+
+    protected void checkFlussOffsetsInSnapshot(
+            TablePath tablePath, Map<TableBucket, Long> expectedOffsets) 
throws Exception {
+        try (HudiTableInfo hudiTableInfo =
+                HudiTableInfo.create(tablePath, 
Configuration.fromMap(getHudiCatalogConf()))) {
+            HoodieTableMetaClient metaClient = hudiTableInfo.getMetaClient();
+            metaClient.reloadActiveTimeline();
+            HoodieTimeline timeline =
+                    metaClient
+                            .getActiveTimeline()
+                            .getCommitsAndCompactionTimeline()
+                            .filterCompletedInstants();
+            Optional<HoodieInstant> latestInstant =
+                    
timeline.getReverseOrderedInstantsByCompletionTime().findFirst();
+            assertThat(latestInstant).isPresent();
+
+            HoodieCommitMetadata metadata = 
timeline.readCommitMetadata(latestInstant.get());
+            Map<String, String> extraMetadata = metadata.getExtraMetadata();
+            assertThat(extraMetadata).isNotNull();
+            String offsetFile = 
extraMetadata.get(FLUSS_LAKE_SNAP_BUCKET_OFFSET_PROPERTY);
+            assertThat(offsetFile).isNotNull();
+
+            Map<TableBucket, Long> recordedOffsets =
+                    new LakeTable(
+                                    new LakeTable.LakeSnapshotMetadata(
+                                            -1, new FsPath(offsetFile), null))
+                            .getOrReadLatestTableSnapshot()
+                            .getBucketLogEndOffset();
+            assertThat(recordedOffsets).isEqualTo(expectedOffsets);
+        }
+    }
+
+    private static String formatMORRow(InternalRow row) {
+        return row.getBoolean(0)
+                + ","
+                + row.getInt(1)
+                + ","
+                + row.getLong(2)
+                + ","
+                + row.getFloat(3)
+                + ","
+                + row.getDouble(4)
+                + ","
+                + row.getString(5).toString()
+                + ","
+                + row.getDecimal(6, 5, 2).toBigDecimal().toPlainString()
+                + ","
+                + row.getDecimal(7, 20, 0).toBigDecimal().toPlainString();
+    }
+
+    private List<String> collectHudiRows(
+            TablePath tablePath, String partition, int bucket, 
HudiRowFormatter formatter)
+            throws Exception {
+        try (HudiTableInfo hudiTableInfo =
+                HudiTableInfo.create(tablePath, 
Configuration.fromMap(getHudiCatalogConf()))) {
+            org.apache.hudi.org.apache.avro.Schema avroSchema =
+                    
StreamerUtil.getTableAvroSchema(hudiTableInfo.getMetaClient(), true);
+            org.apache.flink.configuration.Configuration flinkHudiOptions =
+                    buildFlinkHudiOptions(tablePath, hudiTableInfo, 
avroSchema);
+
+            StorageConfiguration<org.apache.hadoop.conf.Configuration> 
hadoopConf =
+                    new HadoopStorageConfiguration(
+                            
HadoopConfigurations.getHadoopConf(flinkHudiOptions));
+            InternalSchemaManager internalSchemaManager =
+                    InternalSchemaManager.get(hadoopConf, 
hudiTableInfo.getMetaClient());
+            int columnCount = avroSchema.getFields().size();
+
+            List<FileSlice> fileSlices =
+                    hudiTableInfo
+                            .getFileSystemView()
+                            .getLatestFileSlices(partition)
+                            .collect(Collectors.toList());
+            List<String> records = new ArrayList<>();
+            for (FileSlice fileSlice : fileSlices) {
+                if 
(!fileSlice.getFileId().contains(BucketIdentifier.bucketIdStr(bucket))) {
+                    continue;
+                }
+                try (UnifiedHudiTableReader reader =
+                                UnifiedHudiTableReader.newBuilder()
+                                        
.withMetaClient(hudiTableInfo.getMetaClient())
+                                        
.withInternalSchemaManager(internalSchemaManager)
+                                        .withProps(flinkHudiOptions)
+                                        .withTableSchema(avroSchema)
+                                        .withSelectedFields(
+                                                IntStream.range(0, 
columnCount).toArray())
+                                        
.withLatestCommitTime(fileSlice.getLatestInstantTime())
+                                        .build();
+                        ClosableIterator<RowData> iterator = 
reader.readFileSlice(fileSlice)) {
+                    while (iterator.hasNext()) {
+                        records.add(formatter.format(iterator.next()));
+                    }
+                }
+            }
+            return records;
+        }
+    }
+
+    private org.apache.flink.configuration.Configuration buildFlinkHudiOptions(
+            TablePath tablePath,
+            HudiTableInfo hudiTableInfo,
+            org.apache.hudi.org.apache.avro.Schema avroSchema) {
+        Map<String, String> hudiOptions = new 
HashMap<>(hudiTableInfo.getTableOptions());
+        hudiOptions.putAll(getHudiCatalogConf());
+        hudiOptions.put(FlinkOptions.PATH.key(), hudiTableInfo.getBasePath());
+        hudiOptions.put(FlinkOptions.TABLE_NAME.key(), 
tablePath.getTableName());
+        hudiOptions.put(FlinkOptions.SOURCE_AVRO_SCHEMA.key(), 
avroSchema.toString());
+        return 
org.apache.flink.configuration.Configuration.fromMap(hudiOptions);
+    }
+
+    private interface HudiRowFormatter {
+        String format(RowData row);
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiCommittableSerializerTest.java
 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiCommittableSerializerTest.java
new file mode 100644
index 000000000..52dba1c7b
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiCommittableSerializerTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.tiering;
+
+import org.apache.hudi.common.model.HoodieWriteStat;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link HudiCommittableSerializer}. */
+class HudiCommittableSerializerTest {
+
+    @Test
+    void testSerializeAndDeserializeCommittable() throws Exception {
+        HudiCommittableSerializer serializer = new HudiCommittableSerializer();
+        HudiCommittable committable =
+                new HudiCommittable(writeStats("20260622000100000"), 
Collections.emptyMap());
+
+        HudiCommittable deserialized =
+                serializer.deserialize(serializer.getVersion(), 
serializer.serialize(committable));
+
+        
assertThat(deserialized.getWriteStats()).containsOnlyKeys("20260622000100000").hasSize(1);
+        HudiWriteStats writeStats = 
deserialized.getWriteStats().get("20260622000100000");
+        assertThat(writeStats.getWriteStats()).hasSize(2);
+        assertThat(writeStats.getWriteStats())
+                .extracting(HoodieWriteStat::getFileId)
+                .containsExactly("file-1", "file-2");
+        assertThat(writeStats.getTotalErrorRecords()).isEqualTo(3L);
+        assertThat(deserialized.getCompactionWriteStats()).isEmpty();
+    }
+
+    @Test
+    void testBuilderAggregatesStatsByInstant() {
+        HudiCommittable committable =
+                HudiCommittable.builder()
+                        .addWriteStats(writeStats("20260622000100000"))
+                        .addWriteStats(writeStats("20260622000100000"))
+                        
.addCompactionWriteStats(writeStats("20260622000200000"))
+                        .build();
+
+        HudiWriteStats writeStats = 
committable.getWriteStats().get("20260622000100000");
+        assertThat(writeStats.getWriteStats()).hasSize(4);
+        assertThat(writeStats.getTotalErrorRecords()).isEqualTo(6L);
+        
assertThat(committable.getCompactionWriteStats().get("20260622000200000").getWriteStats())
+                .hasSize(2);
+    }
+
+    @Test
+    void testRejectUnsupportedVersion() {
+        HudiCommittableSerializer serializer = new HudiCommittableSerializer();
+
+        assertThatThrownBy(() -> 
serializer.deserialize(serializer.getVersion() + 1, new byte[0]))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Unsupported HudiCommittable version");
+    }
+
+    @Test
+    void testRejectCorruptedLength() throws Exception {
+        HudiCommittableSerializer serializer = new HudiCommittableSerializer();
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (DataOutputStream dos = new DataOutputStream(baos)) {
+            dos.writeInt(0);
+            dos.writeInt(1);
+            dos.writeInt(1024);
+        }
+
+        assertThatThrownBy(
+                        () -> serializer.deserialize(serializer.getVersion(), 
baos.toByteArray()))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Corrupted serialization: invalid 
CompactionWriteStats");
+    }
+
+    private static Map<String, HudiWriteStats> writeStats(String instant) {
+        Map<String, HudiWriteStats> writeStats = new HashMap<>();
+        writeStats.put(instant, new HudiWriteStats(writeStats(), 3L));
+        return writeStats;
+    }
+
+    private static List<HoodieWriteStat> writeStats() {
+        return Arrays.asList(writeStat("file-1"), writeStat("file-2"));
+    }
+
+    private static HoodieWriteStat writeStat(String fileId) {
+        HoodieWriteStat writeStat = new HoodieWriteStat();
+        writeStat.setFileId(fileId);
+        writeStat.setPartitionPath("partition");
+        writeStat.setPath("partition/" + fileId + ".parquet");
+        writeStat.setNumWrites(1L);
+        return writeStat;
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringITCase.java
 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringITCase.java
new file mode 100644
index 000000000..0f1acc0de
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringITCase.java
@@ -0,0 +1,233 @@
+/*
+ * 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.tiering;
+
+import org.apache.fluss.config.AutoPartitionTimeUnit;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.lake.hudi.testutils.FlinkHudiTieringTestBase;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.BinaryString;
+import org.apache.fluss.row.Decimal;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.row.TimestampLtz;
+import org.apache.fluss.row.TimestampNtz;
+import org.apache.fluss.server.testutils.FlussClusterExtension;
+import org.apache.fluss.types.DataTypes;
+import org.apache.fluss.utils.TypeUtils;
+import org.apache.fluss.utils.types.Tuple2;
+
+import org.apache.flink.core.execution.JobClient;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** IT case for tiering tables to Hudi. */
+class HudiTieringITCase extends FlinkHudiTieringTestBase {
+
+    @RegisterExtension
+    public static final FlussClusterExtension FLUSS_CLUSTER_EXTENSION =
+            FlussClusterExtension.builder()
+                    .setClusterConf(initConfig())
+                    .setNumOfTabletServers(3)
+                    .build();
+
+    private static final Schema PK_SCHEMA =
+            Schema.newBuilder()
+                    .column("f_boolean", DataTypes.BOOLEAN())
+                    .column("f_int", DataTypes.INT())
+                    .column("f_long", DataTypes.BIGINT())
+                    .column("f_float", DataTypes.FLOAT())
+                    .column("f_double", DataTypes.DOUBLE())
+                    .column("f_string", DataTypes.STRING())
+                    .column("f_decimal1", DataTypes.DECIMAL(5, 2))
+                    .column("f_decimal2", DataTypes.DECIMAL(20, 0))
+                    .column("f_timestamp_ltz1", DataTypes.TIMESTAMP_LTZ(3))
+                    .column("f_timestamp_ltz2", DataTypes.TIMESTAMP_LTZ(6))
+                    .column("f_timestamp_ntz1", DataTypes.TIMESTAMP(3))
+                    .column("f_timestamp_ntz2", DataTypes.TIMESTAMP(6))
+                    .column("f_binary", DataTypes.BINARY(4))
+                    .column("f_date", DataTypes.DATE())
+                    .column("f_time", DataTypes.TIME())
+                    .column("f_char", DataTypes.CHAR(3))
+                    .column("f_bytes", DataTypes.BYTES())
+                    .primaryKey("f_int")
+                    .build();
+
+    private static final Schema LOG_SCHEMA =
+            Schema.newBuilder()
+                    .column("f_int", DataTypes.INT())
+                    .column("f_str", DataTypes.STRING())
+                    .build();
+
+    @BeforeAll
+    protected static void beforeAll() {
+        
FlinkHudiTieringTestBase.beforeAll(FLUSS_CLUSTER_EXTENSION.getClientConfig());
+    }
+
+    @Test
+    void testTiering() throws Exception {
+        TablePath pkTablePath = TablePath.of(DEFAULT_DB, "pkTable");
+        long pkTableId = createPkTable(pkTablePath, 1, false, PK_SCHEMA);
+        TableBucket pkTableBucket = new TableBucket(pkTableId, 0);
+
+        List<InternalRow> rows = Arrays.asList(pkRow(1, "v1"), pkRow(2, "v2"), 
pkRow(3, "v3"));
+        writeRows(pkTablePath, rows, false);
+        waitUntilSnapshot(pkTableId, 1);
+
+        execEnv.enableCheckpointing(1000);
+        JobClient jobClient = buildTieringJob(execEnv);
+        try {
+            assertReplicaStatus(pkTableBucket, 3);
+            checkDataInHudiMORTable(pkTablePath, "", rows, 0);
+            checkFlussOffsetsInSnapshot(pkTablePath, 
Collections.singletonMap(pkTableBucket, 3L));
+
+            testLogTableTiering();
+
+            rows = Arrays.asList(pkRow(1, "v111"), pkRow(2, "v222"), pkRow(3, 
"v333"));
+            writeRows(pkTablePath, rows, false);
+
+            // 3 initial records + 3 delete records + 3 insert records.
+            assertReplicaStatus(pkTableBucket, 9);
+            checkDataInHudiMORTable(pkTablePath, "", rows, 0);
+            checkFlussOffsetsInSnapshot(pkTablePath, 
Collections.singletonMap(pkTableBucket, 9L));
+
+            testPartitionedTableTiering();
+        } finally {
+            jobClient.cancel().get();
+        }
+    }
+
+    private void testLogTableTiering() throws Exception {
+        TablePath logTablePath = TablePath.of(DEFAULT_DB, "logTable");
+        long logTableId = createLogTable(logTablePath, 1, false, LOG_SCHEMA);
+        TableBucket logTableBucket = new TableBucket(logTableId, 0);
+
+        List<InternalRow> flussRows = new ArrayList<>();
+        for (int i = 0; i < 10; i++) {
+            List<InternalRow> rows =
+                    Arrays.asList(
+                            row(i + 1, "v" + (i + 1)),
+                            row(i + 11, "v" + (i + 11)),
+                            row(i + 21, "v" + (i + 21)));
+            flussRows.addAll(rows);
+            writeRows(logTablePath, rows, true);
+        }
+
+        assertReplicaStatus(logTableBucket, 30);
+        
assertThat(getLeaderReplica(logTableBucket).getLogTablet().getLakeMaxTimestamp())
+                .isGreaterThan(-1);
+        checkDataInHudiCOWTable(logTablePath, "", flussRows, 0, 0);
+        checkFlussOffsetsInSnapshot(logTablePath, 
Collections.singletonMap(logTableBucket, 30L));
+    }
+
+    private void testPartitionedTableTiering() throws Exception {
+        TablePath partitionedTablePath = TablePath.of(DEFAULT_DB, 
"partitionedTable");
+        Tuple2<Long, TableDescriptor> tableIdAndDescriptor =
+                createPartitionedTable(partitionedTablePath);
+        Map<Long, String> partitionNameByIds = 
waitUntilPartitions(partitionedTablePath);
+
+        TableDescriptor partitionedTableDescriptor = tableIdAndDescriptor.f1;
+        Map<String, List<InternalRow>> writtenRowsByPartition =
+                writeRowsIntoPartitionedTable(
+                        partitionedTablePath, partitionedTableDescriptor, 
partitionNameByIds);
+        long tableId = tableIdAndDescriptor.f0;
+
+        Map<TableBucket, Long> expectedOffsets = new HashMap<>();
+        for (Long partitionId : partitionNameByIds.keySet()) {
+            TableBucket tableBucket = new TableBucket(tableId, partitionId, 0);
+            assertReplicaStatus(tableBucket, 3);
+            expectedOffsets.put(tableBucket, 3L);
+        }
+
+        for (String partitionName : partitionNameByIds.values()) {
+            checkDataInHudiMORPartitionTable(
+                    partitionedTablePath,
+                    partitionName,
+                    writtenRowsByPartition.get(partitionName),
+                    0);
+        }
+        checkFlussOffsetsInSnapshot(partitionedTablePath, expectedOffsets);
+    }
+
+    private Tuple2<Long, TableDescriptor> createPartitionedTable(TablePath 
partitionedTablePath)
+            throws Exception {
+        TableDescriptor partitionedTableDescriptor =
+                TableDescriptor.builder()
+                        .schema(
+                                Schema.newBuilder()
+                                        .column("id", DataTypes.INT())
+                                        .column("name", DataTypes.STRING())
+                                        .column("date", DataTypes.STRING())
+                                        .build())
+                        .distributedBy(1, "id")
+                        .partitionedBy("date")
+                        .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, 
true)
+                        .property(
+                                ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+                                AutoPartitionTimeUnit.YEAR)
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED, true)
+                        .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, 
Duration.ofMillis(500))
+                        .customProperty("hudi.precombine.field", "date")
+                        
.customProperty("hudi.hoodie.datasource.write.recordkey.field", "id")
+                        .build();
+        return Tuple2.of(
+                createTable(partitionedTablePath, partitionedTableDescriptor),
+                partitionedTableDescriptor);
+    }
+
+    private static InternalRow pkRow(int id, String value) {
+        return row(
+                true,
+                id,
+                id + 400L,
+                500.1f,
+                600.0d,
+                value,
+                Decimal.fromUnscaledLong(900, 5, 2),
+                Decimal.fromBigDecimal(new BigDecimal("1000"), 20, 0),
+                TimestampLtz.fromEpochMillis(1698235273400L),
+                TimestampLtz.fromEpochMillis(1698235273400L, 7000),
+                TimestampNtz.fromMillis(1698235273501L),
+                TimestampNtz.fromMillis(1698235273501L, 8000),
+                new byte[] {5, 6, 7, 8},
+                TypeUtils.castFromString("2026-02-25", DataTypes.DATE()),
+                TypeUtils.castFromString("09:30:00.0", DataTypes.TIME()),
+                BinaryString.fromString("abc"),
+                new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
+    }
+
+    @Override
+    protected FlussClusterExtension getFlussClusterExtension() {
+        return FLUSS_CLUSTER_EXTENSION;
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java
 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java
new file mode 100644
index 000000000..815daf069
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java
@@ -0,0 +1,274 @@
+/*
+ * 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.tiering;
+
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.lake.committer.CommittedLakeSnapshot;
+import org.apache.fluss.lake.committer.CommitterInitContext;
+import org.apache.fluss.lake.committer.LakeCommitter;
+import org.apache.fluss.lake.hudi.HudiLakeCatalog;
+import org.apache.fluss.lake.lakestorage.TestingLakeCatalogContext;
+import org.apache.fluss.lake.writer.LakeWriter;
+import org.apache.fluss.lake.writer.WriterInitContext;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.ChangeType;
+import org.apache.fluss.record.GenericRecord;
+import org.apache.fluss.record.LogRecord;
+import org.apache.fluss.row.BinaryString;
+import org.apache.fluss.row.GenericRow;
+import org.apache.fluss.types.DataTypes;
+
+import org.apache.hudi.common.model.HoodieWriteStat;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.annotation.Nullable;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collections;
+
+import static 
org.apache.fluss.lake.committer.LakeCommitter.FLUSS_LAKE_SNAP_BUCKET_OFFSET_PROPERTY;
+import static 
org.apache.fluss.lake.writer.LakeTieringFactory.FLUSS_LAKE_TIERING_COMMIT_USER;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for tiering Fluss records to Hudi via {@link HudiLakeTieringFactory}. 
*/
+class HudiTieringTest {
+
+    @TempDir private File tempWarehouseDir;
+
+    private Configuration hudiConfig;
+    private HudiLakeCatalog hudiLakeCatalog;
+    private HudiLakeTieringFactory hudiLakeTieringFactory;
+
+    @BeforeEach
+    void beforeEach() {
+        hudiConfig = new Configuration();
+        hudiConfig.setString("catalog.path", 
tempWarehouseDir.toURI().toString());
+        hudiConfig.setString("mode", "dfs");
+        hudiLakeCatalog = new HudiLakeCatalog(hudiConfig);
+        hudiLakeTieringFactory = new HudiLakeTieringFactory(hudiConfig);
+    }
+
+    @AfterEach
+    void afterEach() {
+        if (hudiLakeCatalog != null) {
+            hudiLakeCatalog.close();
+        }
+    }
+
+    @Test
+    void testWriteAndCommitLogTable() throws Exception {
+        TablePath tablePath = TablePath.of("hudi", 
"test_write_and_commit_log_table");
+        TableDescriptor tableDescriptor = createLogTableDescriptor();
+        hudiLakeCatalog.createTable(
+                tablePath, tableDescriptor, new 
TestingLakeCatalogContext(tableDescriptor));
+        TableInfo tableInfo = TableInfo.of(tablePath, 1L, 0, tableDescriptor, 
null, 1L, 1L);
+
+        HudiWriteResult writeResult;
+        try (LakeWriter<HudiWriteResult> writer =
+                hudiLakeTieringFactory.createLakeWriter(
+                        new TestingWriterInitContext(
+                                tablePath, new TableBucket(1L, 0), tableInfo, 
0, 0L))) {
+            writer.write(logRecord(0L, 1, "v1"));
+            writer.write(logRecord(1L, 2, "v2"));
+            writeResult = writer.complete();
+        }
+
+        long committedSnapshotId;
+        try (LakeCommitter<HudiWriteResult, HudiCommittable> committer =
+                createLakeCommitter(tablePath, tableInfo)) {
+            assertThat(committer.getMissingLakeSnapshot(null)).isNull();
+
+            HudiCommittable committable =
+                    
committer.toCommittable(Collections.singletonList(writeResult));
+            committedSnapshotId =
+                    committer
+                            .commit(
+                                    committable,
+                                    Collections.singletonMap(
+                                            
FLUSS_LAKE_SNAP_BUCKET_OFFSET_PROPERTY,
+                                            "fluss-offset-file"))
+                            .getCommittedSnapshotId();
+        }
+
+        try (LakeCommitter<HudiWriteResult, HudiCommittable> committer =
+                createLakeCommitter(tablePath, tableInfo)) {
+            CommittedLakeSnapshot missingLakeSnapshot =
+                    committer.getMissingLakeSnapshot(committedSnapshotId - 1);
+
+            assertThat(missingLakeSnapshot).isNotNull();
+            
assertThat(missingLakeSnapshot.getLakeSnapshotId()).isEqualTo(committedSnapshotId);
+            assertThat(missingLakeSnapshot.getSnapshotProperties())
+                    .containsEntry("commit-user", 
FLUSS_LAKE_TIERING_COMMIT_USER)
+                    .containsEntry(FLUSS_LAKE_SNAP_BUCKET_OFFSET_PROPERTY, 
"fluss-offset-file");
+            
assertThat(committer.getMissingLakeSnapshot(committedSnapshotId)).isNull();
+        }
+    }
+
+    @Test
+    void testRejectCompactionWriteStatusesOnCommit() throws Exception {
+        TablePath tablePath =
+                TablePath.of("hudi", 
"test_reject_compaction_write_statuses_on_commit");
+        TableDescriptor tableDescriptor = createLogTableDescriptor();
+        hudiLakeCatalog.createTable(
+                tablePath, tableDescriptor, new 
TestingLakeCatalogContext(tableDescriptor));
+        TableInfo tableInfo = TableInfo.of(tablePath, 1L, 0, tableDescriptor, 
null, 1L, 1L);
+
+        try (LakeCommitter<HudiWriteResult, HudiCommittable> committer =
+                createLakeCommitter(tablePath, tableInfo)) {
+            HudiCommittable committable =
+                    new HudiCommittable(
+                            Collections.emptyMap(),
+                            Collections.singletonMap(
+                                    "20260623000100000",
+                                    new HudiWriteStats(
+                                            
Collections.singletonList(writeStat()), 0L)));
+
+            assertThatThrownBy(() -> committer.commit(committable, 
Collections.emptyMap()))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("Hudi compaction write stats are not 
supported yet");
+        }
+    }
+
+    private LakeCommitter<HudiWriteResult, HudiCommittable> 
createLakeCommitter(
+            TablePath tablePath, TableInfo tableInfo) throws IOException {
+        return hudiLakeTieringFactory.createLakeCommitter(
+                new TestingCommitterInitContext(tablePath, tableInfo));
+    }
+
+    private static TableDescriptor createLogTableDescriptor() {
+        return TableDescriptor.builder()
+                .schema(
+                        Schema.newBuilder()
+                                .column("id", DataTypes.INT())
+                                .column("name", DataTypes.STRING())
+                                .build())
+                .distributedBy(1, "id")
+                .customProperty("hudi." + FlinkOptions.RECORD_KEY_FIELD.key(), 
"id")
+                .customProperty("hudi.precombine.field", "name")
+                .build();
+    }
+
+    private static LogRecord logRecord(long offset, int id, String name) {
+        GenericRow row = new GenericRow(2);
+        row.setField(0, id);
+        row.setField(1, BinaryString.fromString(name));
+        return new GenericRecord(offset, 1_000L + offset, 
ChangeType.APPEND_ONLY, row);
+    }
+
+    private static HoodieWriteStat writeStat() {
+        HoodieWriteStat writeStat = new HoodieWriteStat();
+        writeStat.setFileId("file-1");
+        writeStat.setPartitionPath("");
+        writeStat.setPath("file-1.parquet");
+        return writeStat;
+    }
+
+    private static class TestingWriterInitContext implements WriterInitContext 
{
+
+        private final TablePath tablePath;
+        private final TableBucket tableBucket;
+        private final TableInfo tableInfo;
+        private final int splitIndex;
+        private final long tieringRoundTimestamp;
+
+        private TestingWriterInitContext(
+                TablePath tablePath,
+                TableBucket tableBucket,
+                TableInfo tableInfo,
+                int splitIndex,
+                long tieringRoundTimestamp) {
+            this.tablePath = tablePath;
+            this.tableBucket = tableBucket;
+            this.tableInfo = tableInfo;
+            this.splitIndex = splitIndex;
+            this.tieringRoundTimestamp = tieringRoundTimestamp;
+        }
+
+        @Override
+        public TablePath tablePath() {
+            return tablePath;
+        }
+
+        @Override
+        public TableBucket tableBucket() {
+            return tableBucket;
+        }
+
+        @Nullable
+        @Override
+        public String partition() {
+            return null;
+        }
+
+        @Override
+        public TableInfo tableInfo() {
+            return tableInfo;
+        }
+
+        @Override
+        public int splitIndex() {
+            return splitIndex;
+        }
+
+        @Override
+        public long tieringRoundTimestamp() {
+            return tieringRoundTimestamp;
+        }
+    }
+
+    private static class TestingCommitterInitContext implements 
CommitterInitContext {
+
+        private final TablePath tablePath;
+        private final TableInfo tableInfo;
+
+        private TestingCommitterInitContext(TablePath tablePath, TableInfo 
tableInfo) {
+            this.tablePath = tablePath;
+            this.tableInfo = tableInfo;
+        }
+
+        @Override
+        public TablePath tablePath() {
+            return tablePath;
+        }
+
+        @Override
+        public TableInfo tableInfo() {
+            return tableInfo;
+        }
+
+        @Override
+        public Configuration lakeTieringConfig() {
+            return new Configuration();
+        }
+
+        @Override
+        public Configuration flussClientConfig() {
+            return new Configuration();
+        }
+    }
+}
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializerTest.java
 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializerTest.java
index 33c93d777..428176b4c 100644
--- 
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializerTest.java
+++ 
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializerTest.java
@@ -17,15 +17,17 @@
 
 package org.apache.fluss.lake.hudi.tiering;
 
-import org.apache.fluss.utils.InstantiationUtils;
-
+import org.apache.hudi.common.model.HoodieWriteStat;
 import org.junit.jupiter.api.Test;
 
 import java.io.ByteArrayOutputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
+import static java.util.Collections.singletonList;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -41,8 +43,24 @@ class HudiWriteResultSerializerTest {
         HudiWriteResult deserialized =
                 serializer.deserialize(serializer.getVersion(), 
serializer.serialize(writeResult));
 
-        assertThat(deserialized.getWriteStatuses()).isEmpty();
-        assertThat(deserialized.getCompactionWriteStatuses()).isEmpty();
+        assertThat(deserialized.getWriteStats()).isEmpty();
+        assertThat(deserialized.getCompactionWriteStats()).isEmpty();
+    }
+
+    @Test
+    void testSerializeAndDeserializeWriteResult() throws Exception {
+        HudiWriteResultSerializer serializer = new HudiWriteResultSerializer();
+        HudiWriteResult writeResult =
+                new HudiWriteResult(
+                        writeStats("20260622000100000", "file-1", 2L), 
Collections.emptyMap());
+
+        HudiWriteResult deserialized =
+                serializer.deserialize(serializer.getVersion(), 
serializer.serialize(writeResult));
+
+        HudiWriteStats writeStats = 
deserialized.getWriteStats().get("20260622000100000");
+        assertThat(writeStats.getWriteStats()).hasSize(1);
+        
assertThat(writeStats.getWriteStats().get(0).getFileId()).isEqualTo("file-1");
+        assertThat(writeStats.getTotalErrorRecords()).isEqualTo(2L);
     }
 
     @Test
@@ -57,18 +75,29 @@ class HudiWriteResultSerializerTest {
     @Test
     void testRejectCorruptedLength() throws Exception {
         HudiWriteResultSerializer serializer = new HudiWriteResultSerializer();
-        byte[] emptyMapBytes = 
InstantiationUtils.serializeObject(Collections.emptyMap());
 
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
         try (DataOutputStream dos = new DataOutputStream(baos)) {
-            dos.writeInt(emptyMapBytes.length);
-            dos.write(emptyMapBytes);
-            dos.writeInt(emptyMapBytes.length);
+            dos.writeInt(0);
+            dos.writeInt(1);
+            dos.writeInt(1024);
         }
 
         assertThatThrownBy(
                         () -> serializer.deserialize(serializer.getVersion(), 
baos.toByteArray()))
                 .isInstanceOf(IOException.class)
-                .hasMessageContaining("Corrupted serialization: invalid 
CompactionWriteResult");
+                .hasMessageContaining("Corrupted serialization: invalid 
CompactionWriteStats");
+    }
+
+    private static Map<String, HudiWriteStats> writeStats(
+            String instant, String fileId, long totalErrorRecords) {
+        Map<String, HudiWriteStats> writeStats = new HashMap<>();
+        HoodieWriteStat writeStat = new HoodieWriteStat();
+        writeStat.setFileId(fileId);
+        writeStat.setPartitionPath("partition");
+        writeStat.setPath("partition/" + fileId + ".parquet");
+        writeStat.setNumWrites(1L);
+        writeStats.put(instant, new HudiWriteStats(singletonList(writeStat), 
totalErrorRecords));
+        return writeStats;
     }
 }
diff --git 
a/fluss-lake/fluss-lake-hudi/src/test/resources/log4j2-test.properties 
b/fluss-lake/fluss-lake-hudi/src/test/resources/log4j2-test.properties
index 2100a34e0..edfee0abf 100644
--- a/fluss-lake/fluss-lake-hudi/src/test/resources/log4j2-test.properties
+++ b/fluss-lake/fluss-lake-hudi/src/test/resources/log4j2-test.properties
@@ -23,4 +23,4 @@ appender.testlogger.name=TestLogger
 appender.testlogger.type=CONSOLE
 appender.testlogger.target=SYSTEM_ERR
 appender.testlogger.layout.type=PatternLayout
-appender.testlogger.layout.pattern=%-4r [%t] %-5p %c %x - %m%n
\ No newline at end of file
+appender.testlogger.layout.pattern=%-4r [%t] %-5p %c %x - %m%n

Reply via email to