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 dfbf1a595 [lake/hudi] Introduce Hudi lake writer support for tiering
tables (#3507)
dfbf1a595 is described below
commit dfbf1a5952b94610b6955192cf995a18b7a8bf7a
Author: fhan <[email protected]>
AuthorDate: Mon Jun 22 21:57:16 2026 +0800
[lake/hudi] Introduce Hudi lake writer support for tiering tables (#3507)
---
fluss-lake/fluss-lake-hudi/pom.xml | 6 +
.../lake/hudi/tiering/HudiCatalogProvider.java | 47 +++
.../lake/hudi/tiering/HudiLakeTieringFactory.java | 67 ++++
.../fluss/lake/hudi/tiering/HudiLakeWriter.java | 139 ++++++++
.../fluss/lake/hudi/tiering/HudiWriteResult.java | 48 +++
.../hudi/tiering/HudiWriteResultSerializer.java | 106 ++++++
.../lake/hudi/tiering/HudiWriteTableInfo.java | 285 +++++++++++++++
.../fluss/lake/hudi/tiering/RecordWriter.java | 160 +++++++++
.../hudi/tiering/writer/FlussArrayAsHudiArray.java | 193 ++++++++++
.../hudi/tiering/writer/FlussMapAsHudiMap.java | 58 +++
.../hudi/tiering/writer/FlussRecordAsHudiRow.java | 90 +++++
.../hudi/tiering/writer/FlussRowAsHudiRow.java | 183 ++++++++++
.../hudi/tiering/writer/HudiRecordConverter.java | 44 +++
.../lake/hudi/tiering/writer/HudiRecordWriter.java | 61 ++++
.../hudi/tiering/writer/RecordWriteBuffer.java | 392 +++++++++++++++++++++
.../fluss/lake/hudi/utils/HudiConversions.java | 17 +
.../fluss/lake/hudi/utils/meta/CkpMessage.java | 97 +++++
.../fluss/lake/hudi/utils/meta/CkpMetadata.java | 209 +++++++++++
.../lake/hudi/utils/meta/CkpMetadataFactory.java | 40 +++
.../lake/hudi/utils/meta/CkpMetadataProvider.java | 65 ++++
.../tiering/HudiWriteResultSerializerTest.java | 74 ++++
.../tiering/writer/FlussRecordAsHudiRowTest.java | 119 +++++++
.../hudi/tiering/writer/RecordWriteBufferTest.java | 198 +++++++++++
.../lake/hudi/utils/meta/CkpMetadataTest.java | 60 ++++
24 files changed, 2758 insertions(+)
diff --git a/fluss-lake/fluss-lake-hudi/pom.xml
b/fluss-lake/fluss-lake-hudi/pom.xml
index 0f2bc76d3..64f1ca2c1 100644
--- a/fluss-lake/fluss-lake-hudi/pom.xml
+++ b/fluss-lake/fluss-lake-hudi/pom.xml
@@ -156,6 +156,12 @@
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-table-runtime</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
<!-- test dependency -->
<dependency>
<groupId>org.apache.fluss</groupId>
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCatalogProvider.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCatalogProvider.java
new file mode 100644
index 000000000..377e9475c
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiCatalogProvider.java
@@ -0,0 +1,47 @@
+/*
+ * 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.hudi.utils.catalog.HudiCatalogUtils;
+
+import org.apache.flink.table.catalog.Catalog;
+
+import java.io.Serializable;
+
+/** Serializable provider for creating Hudi catalogs in tiering subtasks. */
+public class HudiCatalogProvider implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final Configuration hudiConfig;
+
+ public HudiCatalogProvider(Configuration hudiConfig) {
+ this.hudiConfig = hudiConfig;
+ }
+
+ public Configuration getHudiConfig() {
+ return hudiConfig;
+ }
+
+ public Catalog get() {
+ Catalog hudiCatalog = HudiCatalogUtils.createHudiCatalog(hudiConfig);
+ hudiCatalog.open();
+ return hudiCatalog;
+ }
+}
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
new file mode 100644
index 000000000..a126cbd17
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeTieringFactory.java
@@ -0,0 +1,67 @@
+/*
+ * 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.CommitterInitContext;
+import org.apache.fluss.lake.committer.LakeCommitter;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadataProvider;
+import org.apache.fluss.lake.serializer.SimpleVersionedSerializer;
+import org.apache.fluss.lake.writer.LakeTieringFactory;
+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> {
+
+ private static final long serialVersionUID = 1L;
+
+ private final HudiCatalogProvider hudiCatalogProvider;
+ private final CkpMetadataProvider ckpMetadataProvider;
+
+ public HudiLakeTieringFactory(Configuration hudiConfig) {
+ this.hudiCatalogProvider = new HudiCatalogProvider(hudiConfig);
+ this.ckpMetadataProvider = new CkpMetadataProvider();
+ }
+
+ @Override
+ public LakeWriter<HudiWriteResult> createLakeWriter(WriterInitContext
writerInitContext)
+ throws IOException {
+ return new HudiLakeWriter(hudiCatalogProvider, ckpMetadataProvider,
writerInitContext);
+ }
+
+ @Override
+ public SimpleVersionedSerializer<HudiWriteResult>
getWriteResultSerializer() {
+ return new HudiWriteResultSerializer();
+ }
+
+ @Override
+ public LakeCommitter<HudiWriteResult, Serializable> createLakeCommitter(
+ CommitterInitContext committerInitContext) {
+ throw new UnsupportedOperationException("Hudi lake committer is not
implemented yet.");
+ }
+
+ @Override
+ public SimpleVersionedSerializer<Serializable> getCommittableSerializer() {
+ throw new UnsupportedOperationException(
+ "Hudi lake committable serializer is not implemented yet.");
+ }
+}
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
new file mode 100644
index 000000000..f58bd2900
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiLakeWriter.java
@@ -0,0 +1,139 @@
+/*
+ * 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.hudi.tiering.writer.HudiRecordWriter;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadata;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadataProvider;
+import org.apache.fluss.lake.writer.LakeWriter;
+import org.apache.fluss.lake.writer.WriterInitContext;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.record.LogRecord;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.util.CommitUtils;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static
org.apache.fluss.lake.writer.WriterInitContext.UNKNOWN_SPLIT_INDEX;
+import static
org.apache.fluss.lake.writer.WriterInitContext.UNKNOWN_TIERING_ROUND_TIMESTAMP;
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+
+/** Hudi implementation of {@link LakeWriter}. */
+public class HudiLakeWriter implements LakeWriter<HudiWriteResult> {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(HudiLakeWriter.class);
+
+ private final RecordWriter recordWriter;
+ private final TableInfo tableInfo;
+ private final HudiWriteTableInfo hudiTableInfo;
+ private final CkpMetadata ckpMetadata;
+
+ public HudiLakeWriter(
+ HudiCatalogProvider hudiCatalogProvider,
+ CkpMetadataProvider ckpMetadataProvider,
+ WriterInitContext writerInitContext)
+ throws IOException {
+ validateWriterInitContext(writerInitContext);
+ this.tableInfo = writerInitContext.tableInfo();
+ this.hudiTableInfo =
+ HudiWriteTableInfo.create(hudiCatalogProvider,
tableInfo.getTablePath());
+ this.ckpMetadata = ckpMetadataProvider.get(tableInfo.getTablePath(),
hudiTableInfo);
+
+ if (writerInitContext.splitIndex() == 0) {
+ ckpMetadata.bootstrap();
+ initInstant(hudiTableInfo.getFlinkConfig(),
hudiTableInfo.getMetaClient());
+ LOG.info(
+ "Initialized Hudi instant for first split of table {},
bucket {}.",
+ tableInfo.getTablePath(),
+ writerInitContext.tableBucket());
+ }
+
+ this.recordWriter = new HudiRecordWriter(writerInitContext,
hudiTableInfo, ckpMetadata);
+ LOG.info("Created HudiLakeWriter with configuration {}.",
hudiTableInfo.getFlinkConfig());
+ }
+
+ @Override
+ public void write(LogRecord record) throws IOException {
+ try {
+ recordWriter.write(record);
+ } catch (Exception e) {
+ throw new IOException("Failed to write Fluss record to Hudi.", e);
+ }
+ }
+
+ @Override
+ public HudiWriteResult complete() throws IOException {
+ try {
+ Map<String, List<WriteStatus>> writeStatuses =
recordWriter.complete();
+ return new HudiWriteResult(writeStatuses, new HashMap<>());
+ } catch (Exception e) {
+ throw new IOException("Failed to complete Hudi write.", e);
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ recordWriter.close();
+ ckpMetadata.close();
+ } catch (Exception e) {
+ throw new IOException("Failed to close HudiLakeWriter.", e);
+ }
+ }
+
+ private void initInstant(Configuration configuration,
HoodieTableMetaClient metaClient) {
+ metaClient.reloadActiveTimeline();
+ WriteOperationType writeOperationType =
+
WriteOperationType.fromValue(configuration.get(FlinkOptions.OPERATION));
+ hudiTableInfo.getWriteClient().preTxn(writeOperationType, metaClient);
+
+ String commitAction =
+ CommitUtils.getCommitActionType(
+ writeOperationType,
+
HoodieTableType.valueOf(configuration.get(FlinkOptions.TABLE_TYPE)));
+ String instant =
hudiTableInfo.getWriteClient().startCommit(commitAction, metaClient);
+
metaClient.getActiveTimeline().transitionRequestedToInflight(commitAction,
instant);
+ hudiTableInfo.getWriteClient().setWriteTimer(commitAction);
+ ckpMetadata.startInstant(instant);
+ LOG.info(
+ "Created Hudi instant {} for table {} with type {}.",
+ instant,
+ tableInfo.getTablePath(),
+ configuration.get(FlinkOptions.TABLE_TYPE));
+ }
+
+ private static void validateWriterInitContext(WriterInitContext
writerInitContext) {
+ checkArgument(
+ writerInitContext.splitIndex() != UNKNOWN_SPLIT_INDEX,
+ "Hudi lake writer requires split index in WriterInitContext.");
+ checkArgument(
+ writerInitContext.tieringRoundTimestamp() !=
UNKNOWN_TIERING_ROUND_TIMESTAMP,
+ "Hudi lake writer requires tiering round timestamp in
WriterInitContext.");
+ }
+}
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
new file mode 100644
index 000000000..973911554
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResult.java
@@ -0,0 +1,48 @@
+/*
+ * 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 java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+
+/** Write result produced by the Hudi lake writer and consumed by a future
Hudi committer. */
+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;
+
+ public HudiWriteResult(
+ Map<String, List<WriteStatus>> writeStatuses,
+ Map<String, List<WriteStatus>> compactionWriteStatuses) {
+ this.writeStatuses = writeStatuses;
+ this.compactionWriteStatuses = compactionWriteStatuses;
+ }
+
+ public Map<String, List<WriteStatus>> getWriteStatuses() {
+ return writeStatuses;
+ }
+
+ public Map<String, List<WriteStatus>> getCompactionWriteStatuses() {
+ return compactionWriteStatuses;
+ }
+}
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
new file mode 100644
index 000000000..57d60fa9b
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializer.java
@@ -0,0 +1,106 @@
+/*
+ * 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 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}. */
+public class HudiWriteResultSerializer implements
SimpleVersionedSerializer<HudiWriteResult> {
+
+ private static final int CURRENT_VERSION = 1;
+
+ @Override
+ public int getVersion() {
+ return CURRENT_VERSION;
+ }
+
+ @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);
+ return baos.toByteArray();
+ }
+ }
+
+ @Override
+ public HudiWriteResult deserialize(int version, byte[] serialized) throws
IOException {
+ if (version != CURRENT_VERSION) {
+ throw new IOException(
+ "Unsupported HudiWriteResult version "
+ + version
+ + ", expected "
+ + CURRENT_VERSION);
+ }
+
+ Map<String, List<WriteStatus>> writeResult;
+ Map<String, List<WriteStatus>> compactionWriteResult;
+ 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());
+ 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);
+ }
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteTableInfo.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteTableInfo.java
new file mode 100644
index 000000000..d4711f261
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/HudiWriteTableInfo.java
@@ -0,0 +1,285 @@
+/*
+ * 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.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.utils.IOUtils;
+
+import org.apache.flink.table.catalog.Catalog;
+import org.apache.flink.table.catalog.CatalogBaseTable;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.hudi.client.HoodieFlinkWriteClient;
+import org.apache.hudi.client.common.HoodieFlinkEngineContext;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.engine.HoodieLocalEngineContext;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.exception.TableNotFoundException;
+import org.apache.hudi.org.apache.avro.Schema;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+import org.apache.hudi.table.catalog.CatalogOptions;
+import org.apache.hudi.util.AvroSchemaConverter;
+import org.apache.hudi.util.CompactionUtil;
+import org.apache.hudi.util.FlinkWriteClients;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.toHudiObjectPath;
+
+/** Resolved Hudi table metadata and write client used by the Hudi lake
writer. */
+public class HudiWriteTableInfo implements AutoCloseable {
+
+ private static final String DEFAULT_HUDI_WAREHOUSE = "/tmp/hudi_warehouse";
+ private static final String FLUSS_CONF_PREFIX = "fluss.";
+
+ private final TablePath tablePath;
+ private final Configuration hudiConfig;
+ private final HoodieFlinkWriteClient writeClient;
+ private final HoodieTableMetaClient metaClient;
+ private final HoodieEngineContext engineContext;
+ private final HoodieWriteConfig writeConfig;
+ private final org.apache.flink.configuration.Configuration flinkConfig;
+ private final Schema avroSchema;
+ private final RowType rowType;
+ private final List<String> logicalTypes;
+ private final String basePath;
+ private boolean closed;
+
+ private HudiWriteTableInfo(
+ TablePath tablePath,
+ Configuration hudiConfig,
+ HoodieFlinkWriteClient writeClient,
+ HoodieTableMetaClient metaClient,
+ HoodieEngineContext engineContext,
+ HoodieWriteConfig writeConfig,
+ org.apache.flink.configuration.Configuration flinkConfig,
+ Schema avroSchema,
+ RowType rowType,
+ List<String> logicalTypes,
+ String basePath) {
+ this.tablePath = tablePath;
+ this.hudiConfig = hudiConfig;
+ this.writeClient = writeClient;
+ this.metaClient = metaClient;
+ this.engineContext = engineContext;
+ this.writeConfig = writeConfig;
+ this.flinkConfig = flinkConfig;
+ this.avroSchema = avroSchema;
+ this.rowType = rowType;
+ this.logicalTypes = logicalTypes;
+ this.basePath = basePath;
+ }
+
+ public static HudiWriteTableInfo create(
+ HudiCatalogProvider hudiCatalogProvider, TablePath tablePath)
throws IOException {
+ Catalog hudiCatalog = hudiCatalogProvider.get();
+ try {
+ CatalogBaseTable hudiTable =
hudiCatalog.getTable(toHudiObjectPath(tablePath));
+ Map<String, String> hudiOptions = new
HashMap<>(hudiTable.getOptions());
+ hudiOptions.putAll(hudiCatalogProvider.getHudiConfig().toMap());
+
+ String basePath = resolveBasePath(tablePath, hudiOptions);
+ hudiOptions.put(FlinkOptions.TABLE_NAME.key(),
tablePath.getTableName());
+ hudiOptions.put(FlinkOptions.PATH.key(), basePath);
+
+ boolean isCowTable =
+ Objects.equals(
+ hudiOptions.get(FlinkOptions.TABLE_TYPE.key()),
+ HoodieTableType.COPY_ON_WRITE.name());
+ hudiOptions.putIfAbsent(
+ FlinkOptions.OPERATION.key(),
+ isCowTable
+ ? WriteOperationType.INSERT.value()
+ : WriteOperationType.UPSERT.value());
+ hudiOptions.putIfAbsent(
+ FlinkOptions.COMPACTION_SCHEDULE_ENABLED.key(),
+ hudiOptions.getOrDefault(
+ FLUSS_CONF_PREFIX +
ConfigOptions.TABLE_DATALAKE_AUTO_COMPACTION.key(),
+ "false"));
+
+ HoodieTableMetaClient metaClient =
+ createMetaClient(basePath,
hudiCatalogProvider.getHudiConfig());
+ org.apache.flink.configuration.Configuration flinkConfig =
+
org.apache.flink.configuration.Configuration.fromMap(hudiOptions);
+ try {
+ CompactionUtil.setAvroSchema(flinkConfig, metaClient);
+ } catch (Exception e) {
+ throw new IOException("Failed to resolve Hudi Avro schema for
" + tablePath, e);
+ }
+
+ HoodieWriteConfig writeConfig =
+ FlinkWriteClients.getHoodieClientConfig(flinkConfig,
false, false);
+ HoodieEngineContext engineContext =
+ new HoodieLocalEngineContext(metaClient.getStorageConf());
+ HoodieFlinkWriteClient writeClient =
+ new
HoodieFlinkWriteClient<>(HoodieFlinkEngineContext.DEFAULT, writeConfig);
+ Schema avroSchema = Schema.parse(writeConfig.getSchema());
+ RowType rowType =
+ (RowType)
AvroSchemaConverter.convertToDataType(avroSchema).getLogicalType();
+
+ List<String> logicalTypes = new ArrayList<>();
+ for (RowType.RowField rowField : rowType.getFields()) {
+ logicalTypes.add(rowField.getType().getTypeRoot().name());
+ }
+
+ return new HudiWriteTableInfo(
+ tablePath,
+ hudiCatalogProvider.getHudiConfig(),
+ writeClient,
+ metaClient,
+ engineContext,
+ writeConfig,
+ flinkConfig,
+ avroSchema,
+ rowType,
+ logicalTypes,
+ basePath);
+ } catch (Exception e) {
+ if (e instanceof IOException) {
+ throw (IOException) e;
+ }
+ throw new IOException("Failed to resolve Hudi write table info for
" + tablePath, e);
+ } finally {
+ closeCatalog(hudiCatalog);
+ }
+ }
+
+ private static HoodieTableMetaClient createMetaClient(String basePath,
Configuration hudiConfig)
+ throws IOException {
+ try {
+ return HoodieTableMetaClient.builder()
+ .setBasePath(basePath)
+ .setConf(new
HadoopStorageConfiguration(getHadoopConfiguration(hudiConfig)))
+ .build();
+ } catch (TableNotFoundException e) {
+ throw new IOException("Hudi table not found at " + basePath + ".",
e);
+ }
+ }
+
+ public static org.apache.hadoop.conf.Configuration getHadoopConfiguration(
+ Configuration hudiConfig) {
+ org.apache.flink.configuration.Configuration flinkConfig =
+
org.apache.flink.configuration.Configuration.fromMap(hudiConfig.toMap());
+ return HadoopConfigurations.getHadoopConf(flinkConfig);
+ }
+
+ private static String resolveBasePath(TablePath tablePath, Map<String,
String> tableOptions) {
+ String path = tableOptions.get(FlinkOptions.PATH.key());
+ if (path != null && !path.trim().isEmpty()) {
+ return path;
+ }
+ String catalogPath =
+ tableOptions.getOrDefault(
+ CatalogOptions.CATALOG_PATH.key(),
DEFAULT_HUDI_WAREHOUSE);
+ return ensureEndsWithSlash(catalogPath)
+ + tablePath.getDatabaseName()
+ + "/"
+ + tablePath.getTableName();
+ }
+
+ private static String ensureEndsWithSlash(String path) {
+ return path.endsWith("/") ? path : path + "/";
+ }
+
+ private static void closeCatalog(Catalog hudiCatalog) {
+ IOUtils.closeQuietly(hudiCatalog::close, "hudi catalog");
+ }
+
+ public HoodieTableMetaClient getMetaClient() {
+ return metaClient;
+ }
+
+ public HoodieEngineContext getEngineContext() {
+ return engineContext;
+ }
+
+ public HoodieTableFileSystemView getFileSystemView() {
+ return HoodieTableFileSystemView.fileListingBasedFileSystemView(
+ engineContext, metaClient, getCompletedCommitsTimeline());
+ }
+
+ public HoodieTimeline getCompletedCommitsTimeline() {
+ return
metaClient.getCommitsAndCompactionTimeline().filterCompletedAndCompactionInstants();
+ }
+
+ public HoodieTableType getTableType() {
+ return metaClient.getTableType();
+ }
+
+ public String getBasePath() {
+ return metaClient.getBasePath().toString();
+ }
+
+ public String getTableBasePath() {
+ return basePath;
+ }
+
+ public TablePath getTablePath() {
+ return tablePath;
+ }
+
+ public Configuration getHudiConfig() {
+ return hudiConfig;
+ }
+
+ public HoodieWriteConfig getWriteConfig() {
+ return writeConfig;
+ }
+
+ public org.apache.flink.configuration.Configuration getFlinkConfig() {
+ return flinkConfig;
+ }
+
+ public Schema getAvroSchema() {
+ return avroSchema;
+ }
+
+ public HoodieFlinkWriteClient getWriteClient() {
+ return writeClient;
+ }
+
+ public RowType getRowType() {
+ return rowType;
+ }
+
+ public List<String> getLogicalTypes() {
+ return logicalTypes;
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ IOUtils.closeQuietly(writeClient::close, "hudi write client");
+ }
+}
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
new file mode 100644
index 000000000..e4a0e2d06
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/RecordWriter.java
@@ -0,0 +1,160 @@
+/*
+ * 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.hudi.tiering.writer.FlussRecordAsHudiRow;
+import org.apache.fluss.lake.hudi.tiering.writer.RecordWriteBuffer;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadata;
+import org.apache.fluss.lake.writer.WriterInitContext;
+import org.apache.fluss.record.LogRecord;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.client.model.HoodieFlinkInternalRow;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.WriteConcurrencyMode;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.index.bucket.BucketIdentifier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Base writer that writes Fluss log records to Hudi. */
+public abstract class RecordWriter implements AutoCloseable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(RecordWriter.class);
+
+ protected final int bucketNum;
+ protected final HudiWriteTableInfo hudiTableInfo;
+ protected final RecordWriteBuffer recordWriteBuffer;
+ protected final FlussRecordAsHudiRow flussRecordAsHudiRecord;
+ protected final CkpMetadata ckpMetadata;
+
+ private final Map<String, Map<Integer, String>> bucketIndex = new
HashMap<>();
+ private final Set<String> incBucketIndex = new HashSet<>();
+
+ public RecordWriter(
+ WriterInitContext writerInitContext,
+ HudiWriteTableInfo hudiTableInfo,
+ CkpMetadata ckpMetadata) {
+ this.bucketNum = writerInitContext.tableBucket().getBucket();
+ this.flussRecordAsHudiRecord =
+ new FlussRecordAsHudiRow(bucketNum,
hudiTableInfo.getRowType());
+ this.hudiTableInfo = hudiTableInfo;
+ this.ckpMetadata = ckpMetadata;
+ this.recordWriteBuffer =
+ new RecordWriteBuffer(
+ hudiTableInfo, ckpMetadata,
writerInitContext.tieringRoundTimestamp());
+ }
+
+ public abstract void write(LogRecord record) throws Exception;
+
+ public Map<String, List<WriteStatus>> complete() throws Exception {
+ try {
+ recordWriteBuffer.flushRemaining();
+ Map<String, List<WriteStatus>> writeResult =
recordWriteBuffer.getWriteStatuses();
+ LOG.info("Wrote Hudi records and got write statuses {}.",
writeResult);
+ return writeResult;
+ } catch (Exception e) {
+ throw new IOException("Failed to write Hudi records.", e);
+ }
+ }
+
+ @Override
+ public void close() {
+ hudiTableInfo.close();
+ }
+
+ public void setRecordLocation(HoodieFlinkInternalRow internalRow,
Configuration configuration) {
+ String partition = internalRow.getPartitionPath();
+
+ if (!configuration
+ .getString(FlinkOptions.OPERATION)
+ .equals(WriteOperationType.INSERT.value())) {
+ bootstrapIndexIfNeed(partition);
+ }
+
+ Map<Integer, String> bucketToFileId =
+ bucketIndex.computeIfAbsent(partition, ignored -> new
HashMap<>());
+ String bucketId = partition + "/" + bucketNum;
+
+ if (incBucketIndex.contains(bucketId)) {
+ internalRow.setInstantTime("I");
+ internalRow.setFileId(bucketToFileId.get(bucketNum));
+ } else if (bucketToFileId.containsKey(bucketNum)) {
+ internalRow.setInstantTime("U");
+ internalRow.setFileId(bucketToFileId.get(bucketNum));
+ } else {
+ String newFileId =
+ isNonBlockingConcurrencyControl(configuration)
+ ?
BucketIdentifier.newBucketFileIdForNBCC(bucketNum)
+ :
BucketIdentifier.newBucketFileIdPrefix(bucketNum);
+ internalRow.setInstantTime("I");
+ internalRow.setFileId(newFileId);
+ bucketToFileId.put(bucketNum, newFileId);
+ incBucketIndex.add(bucketId);
+ }
+ }
+
+ public boolean isNonBlockingConcurrencyControl(Configuration config) {
+ return WriteConcurrencyMode.isNonBlockingConcurrencyControl(
+ config.getString(
+ HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key(),
+
HoodieWriteConfig.WRITE_CONCURRENCY_MODE.defaultValue()));
+ }
+
+ private void bootstrapIndexIfNeed(String partition) {
+ if (bucketIndex.containsKey(partition) &&
!bucketIndex.get(partition).isEmpty()) {
+ return;
+ }
+
+ Map<Integer, String> bucketToFileIDMap = new HashMap<>();
+ hudiTableInfo
+ .getWriteClient()
+ .getHoodieTable()
+ .getHoodieView()
+ .getLatestFileSlices(partition)
+ .forEach(
+ fileSlice -> {
+ String fileId = fileSlice.getFileId();
+ int bucketNumber =
BucketIdentifier.bucketIdFromFileId(fileId);
+ if (bucketNumber != bucketNum) {
+ return;
+ }
+ if (bucketToFileIDMap.containsKey(bucketNumber)
+ && hudiTableInfo.getTableType()
+ == HoodieTableType.MERGE_ON_READ) {
+ throw new IllegalStateException(
+ String.format(
+ "Duplicate fileId %s for
bucket %s of partition %s.",
+ fileId, bucketNumber,
partition));
+ }
+ bucketToFileIDMap.put(bucketNumber, fileId);
+ });
+ bucketIndex.put(partition, bucketToFileIDMap);
+ LOG.debug("Bootstrapped Hudi bucket index {} for partition {}.",
bucketIndex, partition);
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussArrayAsHudiArray.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussArrayAsHudiArray.java
new file mode 100644
index 000000000..8103bc99d
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussArrayAsHudiArray.java
@@ -0,0 +1,193 @@
+/*
+ * 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.writer;
+
+import org.apache.fluss.row.Decimal;
+import org.apache.fluss.row.InternalArray;
+import org.apache.fluss.row.InternalMap;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.row.TimestampLtz;
+import org.apache.fluss.row.TimestampNtz;
+
+import org.apache.flink.table.data.ArrayData;
+import org.apache.flink.table.data.DecimalData;
+import org.apache.flink.table.data.MapData;
+import org.apache.flink.table.data.RawValueData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.types.logical.ArrayType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.LogicalTypeRoot;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.RowType;
+
+/** Wraps a Fluss {@link InternalArray} as a Hudi/Flink {@link ArrayData}. */
+public class FlussArrayAsHudiArray implements ArrayData {
+
+ private final InternalArray flussArray;
+ private final LogicalType elementType;
+
+ public FlussArrayAsHudiArray(InternalArray flussArray, LogicalType
elementType) {
+ this.flussArray = flussArray;
+ this.elementType = elementType;
+ }
+
+ @Override
+ public int size() {
+ return flussArray.size();
+ }
+
+ @Override
+ public boolean isNullAt(int pos) {
+ return flussArray.isNullAt(pos);
+ }
+
+ @Override
+ public boolean getBoolean(int pos) {
+ return flussArray.getBoolean(pos);
+ }
+
+ @Override
+ public byte getByte(int pos) {
+ return flussArray.getByte(pos);
+ }
+
+ @Override
+ public short getShort(int pos) {
+ return flussArray.getShort(pos);
+ }
+
+ @Override
+ public int getInt(int pos) {
+ return flussArray.getInt(pos);
+ }
+
+ @Override
+ public long getLong(int pos) {
+ return flussArray.getLong(pos);
+ }
+
+ @Override
+ public float getFloat(int pos) {
+ return flussArray.getFloat(pos);
+ }
+
+ @Override
+ public double getDouble(int pos) {
+ return flussArray.getDouble(pos);
+ }
+
+ @Override
+ public StringData getString(int pos) {
+ return StringData.fromBytes(flussArray.getString(pos).toBytes());
+ }
+
+ @Override
+ public DecimalData getDecimal(int pos, int precision, int scale) {
+ Decimal flussDecimal = flussArray.getDecimal(pos, precision, scale);
+ if (flussDecimal.isCompact()) {
+ return DecimalData.fromUnscaledLong(flussDecimal.toUnscaledLong(),
precision, scale);
+ }
+ return DecimalData.fromBigDecimal(flussDecimal.toBigDecimal(),
precision, scale);
+ }
+
+ @Override
+ public TimestampData getTimestamp(int pos, int precision) {
+ LogicalTypeRoot typeRoot = elementType.getTypeRoot();
+ if (typeRoot == LogicalTypeRoot.TIMESTAMP_WITHOUT_TIME_ZONE) {
+ TimestampNtz timestampNtz = flussArray.getTimestampNtz(pos,
precision);
+ return
TimestampData.fromLocalDateTime(timestampNtz.toLocalDateTime());
+ } else if (typeRoot == LogicalTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE)
{
+ TimestampLtz timestampLtz = flussArray.getTimestampLtz(pos,
precision);
+ return TimestampData.fromEpochMillis(
+ timestampLtz.getEpochMillisecond(),
timestampLtz.getNanoOfMillisecond());
+ }
+ throw new UnsupportedOperationException("Unsupported timestamp type: "
+ typeRoot);
+ }
+
+ @Override
+ public <T> RawValueData<T> getRawValue(int pos) {
+ throw new UnsupportedOperationException("getRawValue is not supported
for Fluss records.");
+ }
+
+ @Override
+ public byte[] getBinary(int pos) {
+ return flussArray.getBytes(pos);
+ }
+
+ @Override
+ public ArrayData getArray(int pos) {
+ InternalArray innerArray = flussArray.getArray(pos);
+ return innerArray == null
+ ? null
+ : new FlussArrayAsHudiArray(innerArray, ((ArrayType)
elementType).getElementType());
+ }
+
+ @Override
+ public MapData getMap(int pos) {
+ InternalMap flussMap = flussArray.getMap(pos);
+ MapType mapType = (MapType) elementType;
+ return flussMap == null
+ ? null
+ : new FlussMapAsHudiMap(flussMap, mapType.getKeyType(),
mapType.getValueType());
+ }
+
+ @Override
+ public RowData getRow(int pos, int numFields) {
+ InternalRow nestedFlussRow = flussArray.getRow(pos, numFields);
+ return nestedFlussRow == null
+ ? null
+ : new FlussRowAsHudiRow(nestedFlussRow, (RowType) elementType);
+ }
+
+ @Override
+ public boolean[] toBooleanArray() {
+ return flussArray.toBooleanArray();
+ }
+
+ @Override
+ public byte[] toByteArray() {
+ return flussArray.toByteArray();
+ }
+
+ @Override
+ public short[] toShortArray() {
+ return flussArray.toShortArray();
+ }
+
+ @Override
+ public int[] toIntArray() {
+ return flussArray.toIntArray();
+ }
+
+ @Override
+ public long[] toLongArray() {
+ return flussArray.toLongArray();
+ }
+
+ @Override
+ public float[] toFloatArray() {
+ return flussArray.toFloatArray();
+ }
+
+ @Override
+ public double[] toDoubleArray() {
+ return flussArray.toDoubleArray();
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussMapAsHudiMap.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussMapAsHudiMap.java
new file mode 100644
index 000000000..8089609d1
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussMapAsHudiMap.java
@@ -0,0 +1,58 @@
+/*
+ * 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.writer;
+
+import org.apache.fluss.row.InternalArray;
+import org.apache.fluss.row.InternalMap;
+
+import org.apache.flink.table.data.ArrayData;
+import org.apache.flink.table.data.MapData;
+import org.apache.flink.table.types.logical.LogicalType;
+
+/** Wraps a Fluss {@link InternalMap} as a Hudi/Flink {@link MapData}. */
+public class FlussMapAsHudiMap implements MapData {
+
+ private final InternalMap flussMap;
+ private final LogicalType keyType;
+ private final LogicalType valueType;
+
+ public FlussMapAsHudiMap(InternalMap flussMap, LogicalType keyType,
LogicalType valueType) {
+ this.flussMap = flussMap;
+ this.keyType = keyType;
+ this.valueType = valueType;
+ }
+
+ @Override
+ public int size() {
+ return flussMap.size();
+ }
+
+ @Override
+ public ArrayData keyArray() {
+ InternalArray flussKeyArray = flussMap.keyArray();
+ return flussKeyArray == null ? null : new
FlussArrayAsHudiArray(flussKeyArray, keyType);
+ }
+
+ @Override
+ public ArrayData valueArray() {
+ InternalArray flussValueArray = flussMap.valueArray();
+ return flussValueArray == null
+ ? null
+ : new FlussArrayAsHudiArray(flussValueArray, valueType);
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRecordAsHudiRow.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRecordAsHudiRow.java
new file mode 100644
index 000000000..f62adc76a
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRecordAsHudiRow.java
@@ -0,0 +1,90 @@
+/*
+ * 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.writer;
+
+import org.apache.fluss.record.LogRecord;
+
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.types.RowKind;
+
+import static org.apache.fluss.lake.hudi.HudiLakeCatalog.SYSTEM_COLUMNS;
+import static org.apache.fluss.lake.hudi.utils.HudiConversions.toRowKind;
+import static org.apache.fluss.utils.Preconditions.checkState;
+
+/** Wraps a Fluss {@link LogRecord} as a Hudi/Flink row with Fluss system
columns. */
+public class FlussRecordAsHudiRow extends FlussRowAsHudiRow {
+
+ private final int bucket;
+
+ private LogRecord logRecord;
+ private int originRowFieldCount;
+
+ public FlussRecordAsHudiRow(int bucket, RowType rowType) {
+ super(rowType);
+ this.bucket = bucket;
+ }
+
+ public void setFlussRecord(LogRecord logRecord) {
+ this.logRecord = logRecord;
+ this.internalRow = logRecord.getRow();
+ this.originRowFieldCount = internalRow.getFieldCount();
+ checkState(
+ originRowFieldCount == rowType.getFieldCount() -
SYSTEM_COLUMNS.size(),
+ "Hudi table field count must equal Fluss LogRecord field count
plus system fields.");
+ }
+
+ @Override
+ public RowKind getRowKind() {
+ return toRowKind(logRecord.getChangeType());
+ }
+
+ @Override
+ public boolean isNullAt(int pos) {
+ if (pos < originRowFieldCount) {
+ return super.isNullAt(pos);
+ }
+ return false;
+ }
+
+ @Override
+ public int getInt(int pos) {
+ if (pos == originRowFieldCount) {
+ return bucket;
+ }
+ return super.getInt(pos);
+ }
+
+ @Override
+ public long getLong(int pos) {
+ if (pos == originRowFieldCount + 1) {
+ return logRecord.logOffset();
+ } else if (pos == originRowFieldCount + 2) {
+ return logRecord.timestamp();
+ }
+ return super.getLong(pos);
+ }
+
+ @Override
+ public TimestampData getTimestamp(int pos, int precision) {
+ if (pos == originRowFieldCount + 2) {
+ return TimestampData.fromEpochMillis(logRecord.timestamp());
+ }
+ return super.getTimestamp(pos, precision);
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRowAsHudiRow.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRowAsHudiRow.java
new file mode 100644
index 000000000..36bc34b7d
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRowAsHudiRow.java
@@ -0,0 +1,183 @@
+/*
+ * 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.writer;
+
+import org.apache.fluss.row.Decimal;
+import org.apache.fluss.row.InternalArray;
+import org.apache.fluss.row.InternalMap;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.row.TimestampLtz;
+import org.apache.fluss.row.TimestampNtz;
+import org.apache.fluss.row.compacted.CompactedRow;
+import org.apache.fluss.row.indexed.IndexedRow;
+
+import org.apache.flink.table.data.ArrayData;
+import org.apache.flink.table.data.DecimalData;
+import org.apache.flink.table.data.MapData;
+import org.apache.flink.table.data.RawValueData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.types.logical.ArrayType;
+import org.apache.flink.table.types.logical.LogicalTypeRoot;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.types.RowKind;
+import org.apache.hudi.common.util.ObjectSizeCalculator;
+
+/** Wraps a Fluss {@link InternalRow} as a Hudi/Flink {@link RowData}. */
+public class FlussRowAsHudiRow implements RowData {
+
+ protected InternalRow internalRow;
+ protected final RowType rowType;
+
+ public FlussRowAsHudiRow(RowType rowType) {
+ this.rowType = rowType;
+ }
+
+ public FlussRowAsHudiRow(InternalRow internalRow, RowType rowType) {
+ this.internalRow = internalRow;
+ this.rowType = rowType;
+ }
+
+ @Override
+ public RowKind getRowKind() {
+ return RowKind.INSERT;
+ }
+
+ @Override
+ public boolean isNullAt(int pos) {
+ return internalRow.isNullAt(pos);
+ }
+
+ @Override
+ public boolean getBoolean(int pos) {
+ return internalRow.getBoolean(pos);
+ }
+
+ @Override
+ public byte getByte(int pos) {
+ return internalRow.getByte(pos);
+ }
+
+ @Override
+ public short getShort(int pos) {
+ return internalRow.getShort(pos);
+ }
+
+ @Override
+ public int getInt(int pos) {
+ return internalRow.getInt(pos);
+ }
+
+ @Override
+ public long getLong(int pos) {
+ return internalRow.getLong(pos);
+ }
+
+ @Override
+ public float getFloat(int pos) {
+ return internalRow.getFloat(pos);
+ }
+
+ @Override
+ public double getDouble(int pos) {
+ return internalRow.getDouble(pos);
+ }
+
+ @Override
+ public StringData getString(int pos) {
+ return StringData.fromBytes(internalRow.getString(pos).toBytes());
+ }
+
+ @Override
+ public DecimalData getDecimal(int pos, int precision, int scale) {
+ Decimal flussDecimal = internalRow.getDecimal(pos, precision, scale);
+ if (flussDecimal.isCompact()) {
+ return DecimalData.fromUnscaledLong(flussDecimal.toUnscaledLong(),
precision, scale);
+ }
+ return DecimalData.fromBigDecimal(flussDecimal.toBigDecimal(),
precision, scale);
+ }
+
+ @Override
+ public TimestampData getTimestamp(int pos, int precision) {
+ LogicalTypeRoot typeRoot = rowType.getTypeAt(pos).getTypeRoot();
+ if (typeRoot == LogicalTypeRoot.TIMESTAMP_WITHOUT_TIME_ZONE) {
+ TimestampNtz timestampNtz = internalRow.getTimestampNtz(pos,
precision);
+ return
TimestampData.fromLocalDateTime(timestampNtz.toLocalDateTime());
+ } else if (typeRoot == LogicalTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE)
{
+ TimestampLtz timestampLtz = internalRow.getTimestampLtz(pos,
precision);
+ return TimestampData.fromEpochMillis(
+ timestampLtz.getEpochMillisecond(),
timestampLtz.getNanoOfMillisecond());
+ }
+ throw new UnsupportedOperationException("Unsupported timestamp type: "
+ typeRoot);
+ }
+
+ @Override
+ public <T> RawValueData<T> getRawValue(int pos) {
+ throw new UnsupportedOperationException("getRawValue is not supported
for Fluss records.");
+ }
+
+ @Override
+ public byte[] getBinary(int pos) {
+ return internalRow.getBytes(pos);
+ }
+
+ @Override
+ public ArrayData getArray(int pos) {
+ InternalArray array = internalRow.getArray(pos);
+ return array == null
+ ? null
+ : new FlussArrayAsHudiArray(
+ array, ((ArrayType)
rowType.getTypeAt(pos)).getElementType());
+ }
+
+ @Override
+ public MapData getMap(int pos) {
+ InternalMap map = internalRow.getMap(pos);
+ MapType mapType = (MapType) rowType.getTypeAt(pos);
+ return map == null
+ ? null
+ : new FlussMapAsHudiMap(map, mapType.getKeyType(),
mapType.getValueType());
+ }
+
+ @Override
+ public RowData getRow(int pos, int numFields) {
+ InternalRow row = internalRow.getRow(pos, numFields);
+ return row == null ? null : new FlussRowAsHudiRow(row, (RowType)
rowType.getTypeAt(pos));
+ }
+
+ @Override
+ public int getArity() {
+ return rowType.getFieldCount();
+ }
+
+ @Override
+ public void setRowKind(RowKind rowKind) {
+ // Fluss records expose their row kind through LogRecord change type.
+ }
+
+ public long sizeInBytes() {
+ if (internalRow instanceof IndexedRow) {
+ return ((IndexedRow) internalRow).getSizeInBytes();
+ } else if (internalRow instanceof CompactedRow) {
+ return ((CompactedRow) internalRow).getSizeInBytes();
+ }
+ return ObjectSizeCalculator.getObjectSize(internalRow);
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/HudiRecordConverter.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/HudiRecordConverter.java
new file mode 100644
index 000000000..817522725
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/HudiRecordConverter.java
@@ -0,0 +1,44 @@
+/*
+ * 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.writer;
+
+import org.apache.flink.table.data.RowData;
+import org.apache.hudi.client.model.HoodieFlinkRecord;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieOperation;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.sink.bulk.RowDataKeyGen;
+import org.apache.hudi.table.action.commit.BucketInfo;
+
+import java.io.Serializable;
+
+/** Converts buffered Flink rows into Hudi records. */
+public interface HudiRecordConverter extends Serializable {
+
+ HoodieRecord<?> convert(RowData rowData, BucketInfo bucketInfo);
+
+ static HudiRecordConverter getInstance(RowDataKeyGen keyGen) {
+ return (rowData, bucketInfo) -> {
+ String key = keyGen.getRecordKey(rowData);
+ HoodieOperation operation =
+
HoodieOperation.fromValue(rowData.getRowKind().toByteValue());
+ HoodieKey hoodieKey = new HoodieKey(key,
bucketInfo.getPartitionPath());
+ return new HoodieFlinkRecord(hoodieKey, operation, rowData);
+ };
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/HudiRecordWriter.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/HudiRecordWriter.java
new file mode 100644
index 000000000..d059daa5c
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/HudiRecordWriter.java
@@ -0,0 +1,61 @@
+/*
+ * 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.writer;
+
+import org.apache.fluss.lake.hudi.tiering.HudiWriteTableInfo;
+import org.apache.fluss.lake.hudi.tiering.RecordWriter;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadata;
+import org.apache.fluss.lake.writer.WriterInitContext;
+import org.apache.fluss.record.LogRecord;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.hudi.client.model.HoodieFlinkInternalRow;
+import org.apache.hudi.common.model.HoodieOperation;
+import org.apache.hudi.sink.bulk.RowDataKeyGen;
+
+/** Hudi {@link RecordWriter} implementation. */
+public class HudiRecordWriter extends RecordWriter {
+
+ private final Configuration config;
+ private final RowDataKeyGen keyGen;
+
+ public HudiRecordWriter(
+ WriterInitContext writerInitContext,
+ HudiWriteTableInfo hudiTableInfo,
+ CkpMetadata ckpMetadata) {
+ super(writerInitContext, hudiTableInfo, ckpMetadata);
+ this.config = hudiTableInfo.getFlinkConfig();
+ this.keyGen =
+ RowDataKeyGen.instance(hudiTableInfo.getFlinkConfig(),
hudiTableInfo.getRowType());
+ }
+
+ @Override
+ public void write(LogRecord record) throws Exception {
+ flussRecordAsHudiRecord.setFlussRecord(record);
+ HoodieFlinkInternalRow internalRow =
+ new HoodieFlinkInternalRow(
+ keyGen.getRecordKey(flussRecordAsHudiRecord),
+ keyGen.getPartitionPath(flussRecordAsHudiRecord),
+ HoodieOperation.fromValue(
+
flussRecordAsHudiRecord.getRowKind().toByteValue())
+ .getName(),
+ flussRecordAsHudiRecord);
+ setRecordLocation(internalRow, config);
+ recordWriteBuffer.bufferRecord(internalRow);
+ }
+}
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
new file mode 100644
index 000000000..3a99ede2a
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBuffer.java
@@ -0,0 +1,392 @@
+/*
+ * 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.writer;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.lake.hudi.tiering.HudiWriteTableInfo;
+import org.apache.fluss.lake.hudi.utils.meta.CkpMetadata;
+import org.apache.fluss.lake.writer.WriterInitContext;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.binary.BinaryRowData;
+import org.apache.flink.table.runtime.operators.sort.BinaryInMemorySortBuffer;
+import org.apache.flink.util.MutableObjectIterator;
+import org.apache.hudi.client.HoodieFlinkWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.client.model.HoodieFlinkInternalRow;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator;
+import org.apache.hudi.common.util.ValidationUtils;
+import org.apache.hudi.common.util.collection.MappingIterator;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.sink.buffer.MemorySegmentPoolFactory;
+import org.apache.hudi.sink.bulk.RowDataKeyGen;
+import org.apache.hudi.sink.exception.MemoryPagesExhaustedException;
+import org.apache.hudi.sink.utils.BufferUtils;
+import org.apache.hudi.sink.utils.TimeWait;
+import org.apache.hudi.table.action.commit.BucketInfo;
+import org.apache.hudi.table.action.commit.BucketType;
+import org.apache.hudi.util.MutableIteratorWrapperIterator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Random;
+
+/** Buffers records and writes them to Hudi in batches. */
+public class RecordWriteBuffer {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(RecordWriteBuffer.class);
+ private static final long WAIT_INSTANT_TIMEOUT_MS = 5 * 60 * 1000L;
+
+ private DataBucket bucket;
+
+ private final TotalSizeTracer tracer;
+ private final HoodieFlinkWriteClient writeClient;
+ private final HudiWriteTableInfo hudiTableInfo;
+ private final Configuration config;
+ private final CkpMetadata ckpMetadata;
+ private final Map<String, List<WriteStatus>> writeStatuses;
+ private final HudiRecordConverter recordConverter;
+ private final long tieringRoundTimestamp;
+
+ public RecordWriteBuffer(
+ HudiWriteTableInfo hudiTableInfo, CkpMetadata ckpMetadata, long
tieringRoundTimestamp) {
+ this(
+ hudiTableInfo,
+ hudiTableInfo.getFlinkConfig(),
+ hudiTableInfo.getWriteClient(),
+ ckpMetadata,
+ HudiRecordConverter.getInstance(
+ RowDataKeyGen.instance(
+ hudiTableInfo.getFlinkConfig(),
hudiTableInfo.getRowType())),
+ tieringRoundTimestamp);
+ }
+
+ @VisibleForTesting
+ RecordWriteBuffer(
+ HudiWriteTableInfo hudiTableInfo,
+ Configuration config,
+ HoodieFlinkWriteClient writeClient,
+ CkpMetadata ckpMetadata,
+ HudiRecordConverter recordConverter,
+ long tieringRoundTimestamp) {
+ this.hudiTableInfo = hudiTableInfo;
+ this.config = config;
+ this.tracer = new TotalSizeTracer(config);
+ this.writeClient = writeClient;
+ this.ckpMetadata = ckpMetadata;
+ this.writeStatuses = new HashMap<>();
+ this.tieringRoundTimestamp = tieringRoundTimestamp;
+ this.recordConverter = recordConverter;
+ }
+
+ public void bufferRecord(HoodieFlinkInternalRow internalRow) throws
IOException {
+ BucketInfo bucketInfo = getBucketInfo(internalRow);
+ if (bucket != null && !isSameBucketInfo(bucket.getBucketInfo(),
bucketInfo)) {
+ flushAndDisposeBucket();
+ }
+
+ boolean success = doBufferRecord(internalRow, bucketInfo);
+ if (!success) {
+ flushAndDisposeBucket();
+ success = doBufferRecord(internalRow, bucketInfo);
+ if (!success) {
+ throw new IOException("Hudi write buffer is too small to hold
a single record.");
+ }
+ }
+
+ if (bucket != null) {
+ boolean totalSizeExceedsThreshold =
tracer.trace(bucket.getLastRecordSize());
+ if (bucket.isFull() || totalSizeExceedsThreshold) {
+ flushAndDisposeBucket();
+ }
+ }
+ }
+
+ public void flushRemaining() {
+ flushAndDisposeBucket();
+ tracer.reset();
+ writeClient.cleanHandles();
+ }
+
+ public Map<String, List<WriteStatus>> getWriteStatuses() {
+ return writeStatuses;
+ }
+
+ private boolean doBufferRecord(HoodieFlinkInternalRow internalRow,
BucketInfo bucketInfo)
+ throws IOException {
+ try {
+ if (bucket == null) {
+ bucket = createDataBucket(bucketInfo);
+ LOG.debug("Initialized a new Hudi data bucket.");
+ }
+ return bucket.writeRow(internalRow.getRowData());
+ } catch (MemoryPagesExhaustedException e) {
+ LOG.warn("Hudi write buffer memory pages are exhausted, flushing
current bucket.", e);
+ return false;
+ }
+ }
+
+ private void flushAndDisposeBucket() {
+ if (bucket == null) {
+ return;
+ }
+ if (bucket.isEmpty()) {
+ bucket.dispose();
+ bucket = null;
+ return;
+ }
+ if (flushBucket(bucket)) {
+ tracer.countDown(bucket.getBufferSize());
+ bucket.dispose();
+ bucket = null;
+ }
+ }
+
+ private boolean flushBucket(DataBucket bucket) {
+ String instantTime = getLastPendingInstant();
+ LOG.info(
+ "Flushing Hudi records for instant {}, size {}.",
+ instantTime,
+ bucket.getBufferSize());
+
+ 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));
+ writeStatuses.put(instantTime, writeStatus);
+ return true;
+ }
+
+ protected DataBucket createDataBucket(BucketInfo bucketInfo) {
+ return new DataBucket(
+ BufferUtils.createBuffer(
+ hudiTableInfo.getRowType(),
+
MemorySegmentPoolFactory.createMemorySegmentPool(config)),
+ bucketInfo,
+ config.getDouble(FlinkOptions.WRITE_BATCH_SIZE));
+ }
+
+ protected List<WriteStatus> writeRecords(
+ String instant, String writeOperation, DataBucket dataBucket) {
+ Iterator<BinaryRowData> rowItr =
+ new MutableIteratorWrapperIterator<>(
+ dataBucket.getDataIterator(),
+ () -> new
BinaryRowData(hudiTableInfo.getRowType().getFieldCount()));
+ Iterator<HoodieRecord> recordItr =
+ new MappingIterator<>(
+ rowItr,
+ rowData -> recordConverter.convert(rowData,
dataBucket.getBucketInfo()));
+
+ if (WriteOperationType.UPSERT.value().equals(writeOperation)) {
+ return writeClient.upsert(recordItr, dataBucket.bucketInfo,
instant);
+ } else if (WriteOperationType.INSERT.value().equals(writeOperation)) {
+ return writeClient.insert(recordItr, dataBucket.bucketInfo,
instant);
+ }
+ LOG.warn("Unsupported Hudi write operation {}, skip writing records.",
writeOperation);
+ return Collections.emptyList();
+ }
+
+ public String getLastPendingInstant() {
+ String instant = ckpMetadata.lastPendingInstant();
+ TimeWait timeWait =
+ TimeWait.builder()
+ .timeout(WAIT_INSTANT_TIMEOUT_MS)
+ .action("instant initialize")
+ .build();
+ String roundLowerBoundInstant = roundLowerBoundInstant();
+ while (instant == null
+ || (roundLowerBoundInstant != null
+ && roundLowerBoundInstant.compareTo(instant) >= 0)) {
+ timeWait.waitFor();
+ instant = ckpMetadata.lastPendingInstant();
+ LOG.info("Waiting for Hudi instant to be initialized: {}",
instant);
+ }
+ return instant;
+ }
+
+ private String roundLowerBoundInstant() {
+ if (tieringRoundTimestamp ==
WriterInitContext.UNKNOWN_TIERING_ROUND_TIMESTAMP) {
+ return null;
+ }
+ return HoodieInstantTimeGenerator.formatDate(new
Date(tieringRoundTimestamp));
+ }
+
+ private static BucketInfo getBucketInfo(HoodieFlinkInternalRow
internalRow) {
+ BucketType bucketType;
+ switch (internalRow.getInstantTime()) {
+ case "I":
+ bucketType = BucketType.INSERT;
+ break;
+ case "U":
+ bucketType = BucketType.UPDATE;
+ break;
+ default:
+ throw new HoodieException(
+ "Unexpected Hudi bucket type: " +
internalRow.getInstantTime());
+ }
+ return new BucketInfo(bucketType, internalRow.getFileId(),
internalRow.getPartitionPath());
+ }
+
+ private static boolean isSameBucketInfo(BucketInfo left, BucketInfo right)
{
+ return left.getBucketType() == right.getBucketType()
+ && Objects.equals(left.getFileIdPrefix(),
right.getFileIdPrefix())
+ && Objects.equals(left.getPartitionPath(),
right.getPartitionPath());
+ }
+
+ /** Buffered rows for one Hudi file bucket. */
+ protected static class DataBucket {
+
+ private final BinaryInMemorySortBuffer dataBuffer;
+ private final BucketInfo bucketInfo;
+ private final BufferSizeDetector detector;
+
+ protected DataBucket(
+ BinaryInMemorySortBuffer dataBuffer, BucketInfo bucketInfo,
double batchSize) {
+ this.dataBuffer = dataBuffer;
+ this.bucketInfo = bucketInfo;
+ this.detector = new BufferSizeDetector(batchSize);
+ }
+
+ public MutableObjectIterator<BinaryRowData> getDataIterator() {
+ return dataBuffer.getIterator();
+ }
+
+ public boolean writeRow(RowData rowData) throws IOException {
+ boolean success = dataBuffer.write(rowData);
+ if (success) {
+ detector.detect(rowData);
+ }
+ return success;
+ }
+
+ public BucketInfo getBucketInfo() {
+ return bucketInfo;
+ }
+
+ public long getBufferSize() {
+ return detector.totalSize;
+ }
+
+ public boolean isEmpty() {
+ return dataBuffer.isEmpty();
+ }
+
+ public boolean isFull() {
+ return detector.isFull();
+ }
+
+ public long getLastRecordSize() {
+ return detector.getLastRecordSize();
+ }
+
+ public void dispose() {
+ dataBuffer.dispose();
+ detector.reset();
+ }
+ }
+
+ private static class BufferSizeDetector {
+
+ private static final int DENOMINATOR = 100;
+
+ private final Random random = new Random(47);
+ private final double batchSizeBytes;
+
+ private long lastRecordSize = -1L;
+ private long totalSize;
+
+ BufferSizeDetector(double batchSizeMb) {
+ this.batchSizeBytes = batchSizeMb * 1024 * 1024;
+ }
+
+ void detect(Object record) {
+ if (record instanceof BinaryRowData) {
+ lastRecordSize = ((BinaryRowData) record).getSizeInBytes();
+ } else if (lastRecordSize == -1 || sampling()) {
+ lastRecordSize = ((FlussRowAsHudiRow) record).sizeInBytes();
+ }
+ totalSize += lastRecordSize;
+ }
+
+ boolean isFull() {
+ return totalSize > batchSizeBytes;
+ }
+
+ long getLastRecordSize() {
+ return lastRecordSize;
+ }
+
+ void reset() {
+ lastRecordSize = -1L;
+ totalSize = 0L;
+ }
+
+ private boolean sampling() {
+ return random.nextInt(DENOMINATOR) == 1;
+ }
+ }
+
+ private static class TotalSizeTracer {
+
+ private long bufferSize;
+ private final double maxBufferSize;
+
+ TotalSizeTracer(Configuration conf) {
+ long mergeReaderMem = 100;
+ long mergeMapMaxMem =
conf.getInteger(FlinkOptions.WRITE_MERGE_MAX_MEMORY);
+ this.maxBufferSize =
+ (conf.getDouble(FlinkOptions.WRITE_TASK_MAX_SIZE)
+ - mergeReaderMem
+ - mergeMapMaxMem)
+ * 1024
+ * 1024;
+ ValidationUtils.checkState(
+ maxBufferSize > 0,
+ String.format(
+ "'%s' should be greater than '%s' plus merge
reader memory.",
+ FlinkOptions.WRITE_TASK_MAX_SIZE.key(),
+ FlinkOptions.WRITE_MERGE_MAX_MEMORY.key()));
+ }
+
+ boolean trace(long recordSize) {
+ bufferSize += recordSize;
+ return bufferSize > maxBufferSize;
+ }
+
+ void countDown(long size) {
+ bufferSize -= size;
+ }
+
+ void reset() {
+ bufferSize = 0L;
+ }
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiConversions.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiConversions.java
index 5c4a66214..84d5416dc 100644
---
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiConversions.java
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiConversions.java
@@ -319,6 +319,23 @@ public class HudiConversions {
return primaryKeys;
}
+ /** Converts Fluss change type to Flink row kind used by Hudi row data. */
+ public static RowKind toRowKind(ChangeType changeType) {
+ switch (changeType) {
+ case APPEND_ONLY:
+ case INSERT:
+ return RowKind.INSERT;
+ case UPDATE_BEFORE:
+ return RowKind.UPDATE_BEFORE;
+ case UPDATE_AFTER:
+ return RowKind.UPDATE_AFTER;
+ case DELETE:
+ return RowKind.DELETE;
+ default:
+ throw new IllegalArgumentException("Unsupported change type: "
+ changeType);
+ }
+ }
+
public static ChangeType toChangeType(RowKind rowKind) {
switch (rowKind) {
case INSERT:
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMessage.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMessage.java
new file mode 100644
index 000000000..7b40f0350
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMessage.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils.meta;
+
+import org.apache.hadoop.fs.FileStatus;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import static org.apache.fluss.utils.Preconditions.checkState;
+
+/** A checkpoint message used to coordinate Hudi instant initialization across
tiering writers. */
+public class CkpMessage implements Serializable, Comparable<CkpMessage> {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final Comparator<CkpMessage> COMPARATOR =
+
Comparator.comparing(CkpMessage::getInstant).thenComparing(CkpMessage::getState);
+
+ private final String instant;
+ private final State state;
+
+ public CkpMessage(String instant, String state) {
+ this.instant = instant;
+ this.state = State.valueOf(state);
+ }
+
+ public CkpMessage(FileStatus fileStatus) {
+ String fileName = fileStatus.getPath().getName();
+ String[] nameAndExt = fileName.split("\\.");
+ checkState(nameAndExt.length == 2, "Invalid checkpoint metadata file:
%s", fileName);
+ this.instant = nameAndExt[0];
+ this.state = State.valueOf(nameAndExt[1]);
+ }
+
+ public String getInstant() {
+ return instant;
+ }
+
+ public State getState() {
+ return state;
+ }
+
+ public boolean isComplete() {
+ return State.COMPLETED == state;
+ }
+
+ public boolean isInflight() {
+ return State.INFLIGHT == state;
+ }
+
+ public static String getFileName(String instant, State state) {
+ return instant + "." + state.name();
+ }
+
+ public static List<String> getAllFileNames(String instant) {
+ List<String> fileNames = new ArrayList<>();
+ for (State state : State.values()) {
+ fileNames.add(getFileName(instant, state));
+ }
+ return fileNames;
+ }
+
+ @Override
+ public int compareTo(CkpMessage other) {
+ return COMPARATOR.compare(this, other);
+ }
+
+ /** Hudi instant state tracked by checkpoint metadata. */
+ public enum State {
+ INFLIGHT,
+ ABORTED,
+ COMPLETED
+ }
+
+ @Override
+ public String toString() {
+ return "CkpMessage{" + "instant='" + instant + '\'' + ", state=" +
state + '}';
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadata.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadata.java
new file mode 100644
index 000000000..59e9f6c45
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadata.java
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils.meta;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.storage.StoragePath;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** DFS-backed checkpoint metadata used as a lightweight message bus for Hudi
instants. */
+public class CkpMetadata implements AutoCloseable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(CkpMetadata.class);
+
+ private static final int MAX_RETAIN_CKP_NUM = 1;
+ private static final String CKP_META = "ckp_meta";
+
+ private final FileSystem fs;
+ private final Path path;
+
+ private List<CkpMessage> messages;
+ private List<String> instantCache;
+
+ CkpMetadata(FileSystem fs, String basePath, String uniqueId) {
+ this.fs = fs;
+ this.path = new Path(ckpMetaPath(basePath, uniqueId));
+ }
+
+ public void bootstrap() throws IOException {
+ if (!fs.exists(path)) {
+ fs.mkdirs(path);
+ }
+ }
+
+ public void startInstant(String instant) {
+ Path instantPath = fullPath(CkpMessage.getFileName(instant,
CkpMessage.State.INFLIGHT));
+ try {
+ fs.createNewFile(instantPath);
+ } catch (IOException e) {
+ throw new HoodieException(
+ "Exception while adding checkpoint start metadata for
instant: " + instant, e);
+ }
+ cache(instant);
+ clean();
+ }
+
+ public void commitInstant(String instant) {
+ Path instantPath = fullPath(CkpMessage.getFileName(instant,
CkpMessage.State.COMPLETED));
+ try {
+ fs.createNewFile(instantPath);
+ } catch (IOException e) {
+ throw new HoodieException(
+ "Exception while adding checkpoint commit metadata for
instant: " + instant, e);
+ }
+ }
+
+ public void abortInstant(String instant) {
+ Path instantPath = fullPath(CkpMessage.getFileName(instant,
CkpMessage.State.ABORTED));
+ try {
+ fs.createNewFile(instantPath);
+ } catch (IOException e) {
+ throw new HoodieException(
+ "Exception while adding checkpoint abort metadata for
instant: " + instant, e);
+ }
+ }
+
+ @Nullable
+ public String lastPendingInstant() {
+ load();
+ if (!messages.isEmpty()) {
+ CkpMessage ckpMsg = messages.get(messages.size() - 1);
+ if (ckpMsg.isInflight()) {
+ return ckpMsg.getInstant();
+ }
+ }
+ return null;
+ }
+
+ public List<CkpMessage> getMessages() {
+ load();
+ return messages;
+ }
+
+ @Nullable
+ @VisibleForTesting
+ public String lastCompleteInstant() {
+ load();
+ for (int i = messages.size() - 1; i >= 0; i--) {
+ CkpMessage ckpMsg = messages.get(i);
+ if (ckpMsg.isComplete()) {
+ return ckpMsg.getInstant();
+ }
+ }
+ return null;
+ }
+
+ @VisibleForTesting
+ public List<String> getInstantCache() {
+ return instantCache;
+ }
+
+ @Override
+ public void close() {
+ instantCache = null;
+ }
+
+ private void cache(String newInstant) {
+ if (instantCache == null) {
+ instantCache = new ArrayList<>();
+ }
+ instantCache.add(newInstant);
+ }
+
+ private void clean() {
+ load();
+ if (messages.size() <= MAX_RETAIN_CKP_NUM) {
+ return;
+ }
+ String instant = messages.get(0).getInstant();
+ for (String fileName : CkpMessage.getAllFileNames(instant)) {
+ Path filePath = fullPath(fileName);
+ try {
+ fs.delete(filePath, false);
+ LOG.info("Delete checkpoint metadata {}", filePath);
+ } catch (IOException e) {
+ LOG.warn("Exception while cleaning the checkpoint metadata
file: {}", filePath, e);
+ }
+ }
+ }
+
+ private void load() {
+ try {
+ messages = scanCkpMetadata(path);
+ } catch (IOException e) {
+ throw new HoodieException(
+ "Exception while scanning the checkpoint metadata files
under path: " + path,
+ e);
+ }
+ }
+
+ private Path fullPath(String fileName) {
+ return new Path(path, fileName);
+ }
+
+ protected Stream<CkpMessage> fetchCkpMessages(Path ckpMetaPath) throws
IOException {
+ if (!fs.exists(ckpMetaPath)) {
+ return Stream.empty();
+ }
+ return Arrays.stream(fs.listStatus(ckpMetaPath)).map(CkpMessage::new);
+ }
+
+ protected List<CkpMessage> scanCkpMetadata(Path ckpMetaPath) throws
IOException {
+ return fetchCkpMessages(ckpMetaPath)
+ .collect(Collectors.groupingBy(CkpMessage::getInstant))
+ .values()
+ .stream()
+ .map(
+ ckpMessages ->
+ ckpMessages.stream()
+ .reduce(
+ (left, right) ->
+
left.getState().compareTo(right.getState())
+ >= 0
+ ? left
+ : right)
+ .get())
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ protected static String ckpMetaPath(String basePath, String uniqueId) {
+ String metaPath =
+ basePath
+ + StoragePath.SEPARATOR
+ + HoodieTableMetaClient.AUXILIARYFOLDER_NAME
+ + StoragePath.SEPARATOR
+ + CKP_META;
+ return uniqueId == null || uniqueId.isEmpty() ? metaPath : metaPath +
"_" + uniqueId;
+ }
+}
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
new file mode 100644
index 000000000..40408b1cc
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataFactory.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils.meta;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.hadoop.fs.HadoopFSUtils;
+
+/** Factory for {@link CkpMetadata}. */
+public class CkpMetadataFactory {
+
+ private 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);
+ return new CkpMetadata(fs, basePath, uniqueId);
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataProvider.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataProvider.java
new file mode 100644
index 000000000..8a8a5e48c
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataProvider.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils.meta;
+
+import org.apache.fluss.lake.hudi.tiering.HudiWriteTableInfo;
+import org.apache.fluss.metadata.TablePath;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Provides table-scoped Hudi checkpoint metadata. */
+public class CkpMetadataProvider implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private transient volatile Map<TablePath, CkpMetadata> ckpMetadatas;
+
+ public CkpMetadata get(TablePath tablePath, HudiWriteTableInfo
hudiTableInfo)
+ throws IOException {
+ Map<TablePath, CkpMetadata> metadataCache = getMetadataCache();
+ CkpMetadata ckpMetadata = metadataCache.get(tablePath);
+ if (ckpMetadata != null) {
+ return ckpMetadata;
+ }
+ synchronized (this) {
+ metadataCache = getMetadataCache();
+ ckpMetadata = metadataCache.get(tablePath);
+ if (ckpMetadata == null) {
+ ckpMetadata =
CkpMetadataFactory.getCkpMetadata(hudiTableInfo.getFlinkConfig());
+ metadataCache.put(tablePath, ckpMetadata);
+ }
+ return ckpMetadata;
+ }
+ }
+
+ private Map<TablePath, CkpMetadata> getMetadataCache() {
+ Map<TablePath, CkpMetadata> metadataCache = ckpMetadatas;
+ if (metadataCache != null) {
+ return metadataCache;
+ }
+ synchronized (this) {
+ if (ckpMetadatas == null) {
+ ckpMetadatas = new ConcurrentHashMap<>();
+ }
+ return ckpMetadatas;
+ }
+ }
+}
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
new file mode 100644
index 000000000..33c93d777
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiWriteResultSerializerTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.utils.InstantiationUtils;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link HudiWriteResultSerializer}. */
+class HudiWriteResultSerializerTest {
+
+ @Test
+ void testSerializeAndDeserializeEmptyWriteResult() throws Exception {
+ HudiWriteResultSerializer serializer = new HudiWriteResultSerializer();
+ HudiWriteResult writeResult =
+ new HudiWriteResult(Collections.emptyMap(),
Collections.emptyMap());
+
+ HudiWriteResult deserialized =
+ serializer.deserialize(serializer.getVersion(),
serializer.serialize(writeResult));
+
+ assertThat(deserialized.getWriteStatuses()).isEmpty();
+ assertThat(deserialized.getCompactionWriteStatuses()).isEmpty();
+ }
+
+ @Test
+ void testRejectUnsupportedVersion() {
+ HudiWriteResultSerializer serializer = new HudiWriteResultSerializer();
+
+ assertThatThrownBy(() ->
serializer.deserialize(serializer.getVersion() + 1, new byte[0]))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Unsupported HudiWriteResult version");
+ }
+
+ @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);
+ }
+
+ assertThatThrownBy(
+ () -> serializer.deserialize(serializer.getVersion(),
baos.toByteArray()))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Corrupted serialization: invalid
CompactionWriteResult");
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRecordAsHudiRowTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRecordAsHudiRowTest.java
new file mode 100644
index 000000000..1e291fc13
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/writer/FlussRecordAsHudiRowTest.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.writer;
+
+import org.apache.fluss.record.GenericRecord;
+import org.apache.fluss.record.LogRecord;
+import org.apache.fluss.row.BinaryString;
+import org.apache.fluss.row.Decimal;
+import org.apache.fluss.row.GenericRow;
+import org.apache.fluss.row.TimestampLtz;
+import org.apache.fluss.row.TimestampNtz;
+
+import org.apache.flink.table.types.logical.BigIntType;
+import org.apache.flink.table.types.logical.BinaryType;
+import org.apache.flink.table.types.logical.BooleanType;
+import org.apache.flink.table.types.logical.DecimalType;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.LocalZonedTimestampType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.table.types.logical.TimestampType;
+import org.apache.flink.table.types.logical.VarCharType;
+import org.apache.flink.types.RowKind;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.apache.fluss.record.ChangeType.APPEND_ONLY;
+import static org.apache.fluss.record.ChangeType.DELETE;
+import static org.apache.fluss.record.ChangeType.UPDATE_AFTER;
+import static org.apache.fluss.record.ChangeType.UPDATE_BEFORE;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link FlussRecordAsHudiRow}. */
+class FlussRecordAsHudiRowTest {
+
+ @Test
+ void testLogRecordFieldsAndSystemColumns() {
+ RowType rowType =
+ RowType.of(
+ new BooleanType(),
+ new IntType(),
+ new BigIntType(),
+ new VarCharType(),
+ new DecimalType(10, 2),
+ new LocalZonedTimestampType(6),
+ new TimestampType(6),
+ new BinaryType(),
+ new IntType(),
+ new BigIntType(),
+ new TimestampType(6));
+ GenericRow row = new GenericRow(8);
+ row.setField(0, true);
+ row.setField(1, 1);
+ row.setField(2, 2L);
+ row.setField(3, BinaryString.fromString("fluss"));
+ row.setField(4, Decimal.fromBigDecimal(new BigDecimal("12.34"), 10,
2));
+ row.setField(5, TimestampLtz.fromEpochMillis(1698235273182L, 5678));
+ row.setField(6, TimestampNtz.fromMillis(1698235273182L, 5678));
+ row.setField(7, new byte[] {1, 2, 3});
+
+ int bucket = 3;
+ long offset = 11L;
+ long timestamp = 1698235273999L;
+ LogRecord logRecord = new GenericRecord(offset, timestamp,
APPEND_ONLY, row);
+ FlussRecordAsHudiRow hudiRow = new FlussRecordAsHudiRow(bucket,
rowType);
+ hudiRow.setFlussRecord(logRecord);
+
+ assertThat(hudiRow.getBoolean(0)).isTrue();
+ assertThat(hudiRow.getInt(1)).isEqualTo(1);
+ assertThat(hudiRow.getLong(2)).isEqualTo(2L);
+ assertThat(hudiRow.getString(3).toString()).isEqualTo("fluss");
+ assertThat(hudiRow.getDecimal(4, 10, 2).toBigDecimal()).isEqualTo(new
BigDecimal("12.34"));
+ assertThat(hudiRow.getTimestamp(5,
6).getMillisecond()).isEqualTo(1698235273182L);
+ assertThat(hudiRow.getTimestamp(5,
6).getNanoOfMillisecond()).isEqualTo(5678);
+ assertThat(hudiRow.getTimestamp(6,
6).getMillisecond()).isEqualTo(1698235273182L);
+ assertThat(hudiRow.getBinary(7)).containsExactly(1, 2, 3);
+
+ assertThat(hudiRow.getInt(8)).isEqualTo(bucket);
+ assertThat(hudiRow.getLong(9)).isEqualTo(offset);
+ assertThat(hudiRow.getLong(10)).isEqualTo(timestamp);
+ assertThat(hudiRow.getTimestamp(10,
6).getMillisecond()).isEqualTo(timestamp);
+ assertThat(hudiRow.getArity()).isEqualTo(11);
+ assertThat(hudiRow.getRowKind()).isEqualTo(RowKind.INSERT);
+ }
+
+ @Test
+ void testChangeTypeToRowKind() {
+ RowType rowType =
+ RowType.of(
+ new BooleanType(), new IntType(), new BigIntType(),
new TimestampType(6));
+ GenericRow row = new GenericRow(1);
+ row.setField(0, true);
+ FlussRecordAsHudiRow hudiRow = new FlussRecordAsHudiRow(0, rowType);
+
+ hudiRow.setFlussRecord(new GenericRecord(0, 1, UPDATE_BEFORE, row));
+ assertThat(hudiRow.getRowKind()).isEqualTo(RowKind.UPDATE_BEFORE);
+
+ hudiRow.setFlussRecord(new GenericRecord(0, 1, UPDATE_AFTER, row));
+ assertThat(hudiRow.getRowKind()).isEqualTo(RowKind.UPDATE_AFTER);
+
+ hudiRow.setFlussRecord(new GenericRecord(0, 1, DELETE, row));
+ assertThat(hudiRow.getRowKind()).isEqualTo(RowKind.DELETE);
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBufferTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBufferTest.java
new file mode 100644
index 000000000..140211d00
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/writer/RecordWriteBufferTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.writer;
+
+import org.apache.fluss.lake.writer.WriterInitContext;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.binary.BinaryRowData;
+import org.apache.flink.util.MutableObjectIterator;
+import org.apache.hudi.client.HoodieFlinkWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.client.model.HoodieFlinkInternalRow;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.table.action.commit.BucketInfo;
+import org.apache.hudi.table.action.commit.BucketType;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/** Test for {@link RecordWriteBuffer}. */
+class RecordWriteBufferTest {
+
+ @Test
+ void testFlushesCurrentBucketWhenBucketInfoChanges() throws Exception {
+ TestingRecordWriteBuffer buffer = new TestingRecordWriteBuffer(false);
+ BucketInfo firstBucketInfo = bucketInfo("partition_a", "file_a",
BucketType.INSERT);
+ BucketInfo secondBucketInfo = bucketInfo("partition_b", "file_b",
BucketType.INSERT);
+
+ buffer.bufferRecord(row("partition_a", "file_a", "I"));
+ buffer.bufferRecord(row("partition_a", "file_a", "I"));
+ assertThat(buffer.flushedBucketInfos).isEmpty();
+
+ buffer.bufferRecord(row("partition_b", "file_b", "I"));
+ assertThat(buffer.flushedBucketInfos).containsExactly(firstBucketInfo);
+
+ buffer.flushRemaining();
+ assertThat(buffer.flushedBucketInfos).containsExactly(firstBucketInfo,
secondBucketInfo);
+ }
+
+ @Test
+ void testDisposesEmptyBucketBeforeRetryingRecord() throws Exception {
+ TestingRecordWriteBuffer buffer = new TestingRecordWriteBuffer(true);
+ BucketInfo bucketInfo = bucketInfo("partition", "file",
BucketType.INSERT);
+
+ buffer.bufferRecord(row("partition", "file", "I"));
+
+ assertThat(buffer.createdBuckets).hasSize(2);
+ assertThat(buffer.createdBuckets.get(0).disposed).isTrue();
+ assertThat(buffer.createdBuckets.get(1).isEmpty()).isFalse();
+ assertThat(buffer.flushedBucketInfos).isEmpty();
+
+ buffer.flushRemaining();
+ assertThat(buffer.flushedBucketInfos).containsExactly(bucketInfo);
+ }
+
+ private static HoodieFlinkInternalRow row(String partition, String fileId,
String instantTime) {
+ HoodieFlinkInternalRow internalRow =
+ new HoodieFlinkInternalRow(
+ "record_key", partition,
WriteOperationType.INSERT.value(), rowData());
+ internalRow.setFileId(fileId);
+ internalRow.setInstantTime(instantTime);
+ return internalRow;
+ }
+
+ private static RowData rowData() {
+ return mock(RowData.class);
+ }
+
+ private static BucketInfo bucketInfo(String partition, String fileId,
BucketType bucketType) {
+ return new BucketInfo(bucketType, fileId, partition);
+ }
+
+ @SuppressWarnings("rawtypes")
+ private static class TestingRecordWriteBuffer extends RecordWriteBuffer {
+
+ private final List<TestingDataBucket> createdBuckets = new
ArrayList<>();
+ private final List<BucketInfo> flushedBucketInfos = new ArrayList<>();
+ private boolean failNextBucketWrite;
+
+ private TestingRecordWriteBuffer(boolean failFirstBucketWrite) {
+ super(
+ null,
+ createConfig(),
+ mock(HoodieFlinkWriteClient.class),
+ null,
+ null,
+ WriterInitContext.UNKNOWN_TIERING_ROUND_TIMESTAMP);
+ this.failNextBucketWrite = failFirstBucketWrite;
+ }
+
+ @Override
+ protected DataBucket createDataBucket(BucketInfo bucketInfo) {
+ TestingDataBucket dataBucket = new TestingDataBucket(bucketInfo,
failNextBucketWrite);
+ failNextBucketWrite = false;
+ createdBuckets.add(dataBucket);
+ return dataBucket;
+ }
+
+ @Override
+ protected List<WriteStatus> writeRecords(
+ String instant, String writeOperation, DataBucket dataBucket) {
+ flushedBucketInfos.add(dataBucket.getBucketInfo());
+ return Collections.emptyList();
+ }
+
+ @Override
+ public String getLastPendingInstant() {
+ return "001";
+ }
+ }
+
+ private static Configuration createConfig() {
+ Configuration config = new Configuration();
+ config.set(FlinkOptions.OPERATION, WriteOperationType.INSERT.value());
+ config.set(FlinkOptions.WRITE_TASK_MAX_SIZE, 128.0);
+ config.set(FlinkOptions.WRITE_BATCH_SIZE, 128.0);
+ config.set(FlinkOptions.WRITE_MERGE_MAX_MEMORY, 1);
+ return config;
+ }
+
+ private static class TestingDataBucket extends
RecordWriteBuffer.DataBucket {
+
+ private final boolean failFirstWrite;
+
+ private boolean failed;
+ private boolean empty = true;
+ private boolean disposed;
+
+ private TestingDataBucket(BucketInfo bucketInfo, boolean
failFirstWrite) {
+ super(null, bucketInfo, 128.0);
+ this.failFirstWrite = failFirstWrite;
+ }
+
+ @Override
+ public MutableObjectIterator<BinaryRowData> getDataIterator() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean writeRow(RowData rowData) throws IOException {
+ if (failFirstWrite && !failed) {
+ failed = true;
+ return false;
+ }
+ empty = false;
+ return true;
+ }
+
+ @Override
+ public long getBufferSize() {
+ return empty ? 0L : 1L;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return empty;
+ }
+
+ @Override
+ public boolean isFull() {
+ return false;
+ }
+
+ @Override
+ public long getLastRecordSize() {
+ return 1L;
+ }
+
+ @Override
+ public void dispose() {
+ disposed = true;
+ empty = true;
+ }
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataTest.java
new file mode 100644
index 000000000..8fae89dcc
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/meta/CkpMetadataTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils.meta;
+
+import org.apache.fluss.utils.InstantiationUtils;
+
+import org.apache.hadoop.fs.FileSystem;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link CkpMetadata}. */
+class CkpMetadataTest {
+
+ @TempDir private Path tempDir;
+
+ @Test
+ void testAbortedInstantIsNotPending() throws Exception {
+ FileSystem fs = FileSystem.getLocal(new
org.apache.hadoop.conf.Configuration());
+ CkpMetadata ckpMetadata = new CkpMetadata(fs, tempDir.toString(), "");
+
+ assertThat(ckpMetadata).isNotInstanceOf(Serializable.class);
+ ckpMetadata.bootstrap();
+ ckpMetadata.startInstant("20260622000100000");
+
assertThat(ckpMetadata.lastPendingInstant()).isEqualTo("20260622000100000");
+
+ ckpMetadata.abortInstant("20260622000100000");
+ assertThat(ckpMetadata.lastPendingInstant()).isNull();
+ }
+
+ @Test
+ void testCkpMetadataProviderSerializable() throws Exception {
+ CkpMetadataProvider ckpMetadataProvider = new CkpMetadataProvider();
+
+ byte[] serialized =
InstantiationUtils.serializeObject(ckpMetadataProvider);
+ CkpMetadataProvider deserialized =
+ InstantiationUtils.deserializeObject(serialized,
getClass().getClassLoader());
+
+ assertThat(deserialized).isNotNull();
+ }
+}