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 aa7101080 [lake/hudi] Introduce Hudi source reader (#3499)
aa7101080 is described below
commit aa7101080a474e5688bd2696e252c7cc9cbcebe7
Author: fhan <[email protected]>
AuthorDate: Sat Jun 20 10:16:07 2026 +0800
[lake/hudi] Introduce Hudi source reader (#3499)
---
.../lake/hudi/source/HudiArrayAsFlussArray.java | 224 +++++++++++++++
.../fluss/lake/hudi/source/HudiLakeSource.java | 12 +-
.../fluss/lake/hudi/source/HudiMapAsFlussMap.java | 48 ++++
.../fluss/lake/hudi/source/HudiRecordReader.java | 291 +++++++++++++++++++
.../fluss/lake/hudi/source/HudiRowAsFlussRow.java | 175 ++++++++++++
.../lake/hudi/source/UnifiedHudiTableReader.java | 310 +++++++++++++++++++++
.../fluss/lake/hudi/utils/HudiConversions.java | 17 ++
.../fluss/lake/hudi/source/HudiLakeSourceTest.java | 7 +-
.../lake/hudi/source/HudiRecordReaderTest.java | 208 ++++++++++++++
.../lake/hudi/source/HudiRowAsFlussRowTest.java | 104 +++++++
10 files changed, 1390 insertions(+), 6 deletions(-)
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiArrayAsFlussArray.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiArrayAsFlussArray.java
new file mode 100644
index 000000000..9e2569957
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiArrayAsFlussArray.java
@@ -0,0 +1,224 @@
+/*
+ * 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.source;
+
+import org.apache.fluss.row.BinaryString;
+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.RowData;
+import org.apache.flink.table.data.TimestampData;
+
+/** Wraps a Hudi/Flink {@link ArrayData} as a Fluss {@link InternalArray}. */
+public class HudiArrayAsFlussArray implements InternalArray {
+
+ private final ArrayData hudiArray;
+
+ public HudiArrayAsFlussArray(ArrayData hudiArray) {
+ this.hudiArray = hudiArray;
+ }
+
+ @Override
+ public int size() {
+ return hudiArray.size();
+ }
+
+ @Override
+ public boolean isNullAt(int pos) {
+ return hudiArray.isNullAt(pos);
+ }
+
+ @Override
+ public boolean getBoolean(int pos) {
+ return hudiArray.getBoolean(pos);
+ }
+
+ @Override
+ public byte getByte(int pos) {
+ return hudiArray.getByte(pos);
+ }
+
+ @Override
+ public short getShort(int pos) {
+ return hudiArray.getShort(pos);
+ }
+
+ @Override
+ public int getInt(int pos) {
+ return hudiArray.getInt(pos);
+ }
+
+ @Override
+ public long getLong(int pos) {
+ return hudiArray.getLong(pos);
+ }
+
+ @Override
+ public float getFloat(int pos) {
+ return hudiArray.getFloat(pos);
+ }
+
+ @Override
+ public double getDouble(int pos) {
+ return hudiArray.getDouble(pos);
+ }
+
+ @Override
+ public BinaryString getChar(int pos, int length) {
+ return BinaryString.fromBytes(hudiArray.getString(pos).toBytes());
+ }
+
+ @Override
+ public BinaryString getString(int pos) {
+ return BinaryString.fromBytes(hudiArray.getString(pos).toBytes());
+ }
+
+ @Override
+ public Decimal getDecimal(int pos, int precision, int scale) {
+ DecimalData decimalData = hudiArray.getDecimal(pos, precision, scale);
+ if (decimalData.isCompact()) {
+ return Decimal.fromUnscaledLong(decimalData.toUnscaledLong(),
precision, scale);
+ }
+ return Decimal.fromBigDecimal(decimalData.toBigDecimal(), precision,
scale);
+ }
+
+ @Override
+ public TimestampNtz getTimestampNtz(int pos, int precision) {
+ TimestampData timestamp = hudiArray.getTimestamp(pos, precision);
+ if (TimestampNtz.isCompact(precision)) {
+ return TimestampNtz.fromMillis(timestamp.getMillisecond());
+ }
+ return TimestampNtz.fromMillis(
+ timestamp.getMillisecond(), timestamp.getNanoOfMillisecond());
+ }
+
+ @Override
+ public TimestampLtz getTimestampLtz(int pos, int precision) {
+ TimestampData timestamp = hudiArray.getTimestamp(pos, precision);
+ if (TimestampLtz.isCompact(precision)) {
+ return TimestampLtz.fromEpochMillis(timestamp.getMillisecond());
+ }
+ return TimestampLtz.fromEpochMillis(
+ timestamp.getMillisecond(), timestamp.getNanoOfMillisecond());
+ }
+
+ @Override
+ public byte[] getBinary(int pos, int length) {
+ return hudiArray.getBinary(pos);
+ }
+
+ @Override
+ public byte[] getBytes(int pos) {
+ return hudiArray.getBinary(pos);
+ }
+
+ @Override
+ public InternalArray getArray(int pos) {
+ ArrayData nestedArray = hudiArray.getArray(pos);
+ return nestedArray == null ? null : new
HudiArrayAsFlussArray(nestedArray);
+ }
+
+ @Override
+ public InternalMap getMap(int pos) {
+ MapData nestedMap = hudiArray.getMap(pos);
+ return nestedMap == null ? null : new HudiMapAsFlussMap(nestedMap);
+ }
+
+ @Override
+ public InternalRow getRow(int pos, int numFields) {
+ RowData nestedRow = hudiArray.getRow(pos, numFields);
+ return nestedRow == null ? null : new HudiRowAsFlussRow(nestedRow,
false);
+ }
+
+ @Override
+ public boolean[] toBooleanArray() {
+ int arraySize = hudiArray.size();
+ boolean[] result = new boolean[arraySize];
+ for (int i = 0; i < arraySize; i++) {
+ result[i] = hudiArray.getBoolean(i);
+ }
+ return result;
+ }
+
+ @Override
+ public byte[] toByteArray() {
+ int arraySize = hudiArray.size();
+ byte[] result = new byte[arraySize];
+ for (int i = 0; i < arraySize; i++) {
+ result[i] = hudiArray.getByte(i);
+ }
+ return result;
+ }
+
+ @Override
+ public short[] toShortArray() {
+ int arraySize = hudiArray.size();
+ short[] result = new short[arraySize];
+ for (int i = 0; i < arraySize; i++) {
+ result[i] = hudiArray.getShort(i);
+ }
+ return result;
+ }
+
+ @Override
+ public int[] toIntArray() {
+ int arraySize = hudiArray.size();
+ int[] result = new int[arraySize];
+ for (int i = 0; i < arraySize; i++) {
+ result[i] = hudiArray.getInt(i);
+ }
+ return result;
+ }
+
+ @Override
+ public long[] toLongArray() {
+ int arraySize = hudiArray.size();
+ long[] result = new long[arraySize];
+ for (int i = 0; i < arraySize; i++) {
+ result[i] = hudiArray.getLong(i);
+ }
+ return result;
+ }
+
+ @Override
+ public float[] toFloatArray() {
+ int arraySize = hudiArray.size();
+ float[] result = new float[arraySize];
+ for (int i = 0; i < arraySize; i++) {
+ result[i] = hudiArray.getFloat(i);
+ }
+ return result;
+ }
+
+ @Override
+ public double[] toDoubleArray() {
+ int arraySize = hudiArray.size();
+ double[] result = new double[arraySize];
+ for (int i = 0; i < arraySize; i++) {
+ result[i] = hudiArray.getDouble(i);
+ }
+ return result;
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiLakeSource.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiLakeSource.java
index c491ddfa6..87f9ee0de 100644
---
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiLakeSource.java
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiLakeSource.java
@@ -25,6 +25,8 @@ import org.apache.fluss.lake.source.RecordReader;
import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.predicate.Predicate;
+import javax.annotation.Nullable;
+
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
@@ -37,6 +39,7 @@ public class HudiLakeSource implements LakeSource<HudiSplit> {
private final Configuration hudiConfig;
private final TablePath tablePath;
+ private @Nullable int[][] project;
public HudiLakeSource(Configuration hudiConfig, TablePath tablePath) {
this.hudiConfig = hudiConfig;
@@ -45,7 +48,7 @@ public class HudiLakeSource implements LakeSource<HudiSplit> {
@Override
public void withProject(int[][] project) {
- // Projection is applied by the Hudi record reader, which is not
implemented yet.
+ this.project = project;
}
@Override
@@ -65,8 +68,11 @@ public class HudiLakeSource implements LakeSource<HudiSplit>
{
@Override
public RecordReader createRecordReader(ReaderContext<HudiSplit> context)
throws IOException {
- throw new UnsupportedOperationException(
- "Hudi lake source does not support record reading yet.");
+ try {
+ return new HudiRecordReader(hudiConfig, tablePath,
context.lakeSplit(), project);
+ } catch (Exception e) {
+ throw new IOException("Fail to create Hudi record reader for " +
tablePath + ".", e);
+ }
}
@Override
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiMapAsFlussMap.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiMapAsFlussMap.java
new file mode 100644
index 000000000..3ada5e5cf
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiMapAsFlussMap.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.source;
+
+import org.apache.fluss.row.InternalArray;
+import org.apache.fluss.row.InternalMap;
+
+import org.apache.flink.table.data.MapData;
+
+/** Wraps a Hudi/Flink {@link MapData} as a Fluss {@link InternalMap}. */
+public class HudiMapAsFlussMap implements InternalMap {
+
+ private final MapData hudiMap;
+
+ public HudiMapAsFlussMap(MapData hudiMap) {
+ this.hudiMap = hudiMap;
+ }
+
+ @Override
+ public int size() {
+ return hudiMap.size();
+ }
+
+ @Override
+ public InternalArray keyArray() {
+ return new HudiArrayAsFlussArray(hudiMap.keyArray());
+ }
+
+ @Override
+ public InternalArray valueArray() {
+ return new HudiArrayAsFlussArray(hudiMap.valueArray());
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRecordReader.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRecordReader.java
new file mode 100644
index 000000000..9cd52a15b
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRecordReader.java
@@ -0,0 +1,291 @@
+/*
+ * 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.source;
+
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.lake.hudi.utils.HudiTableInfo;
+import org.apache.fluss.lake.source.RecordReader;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.ChangeType;
+import org.apache.fluss.record.GenericRecord;
+import org.apache.fluss.record.LogRecord;
+import org.apache.fluss.row.ProjectedRow;
+import org.apache.fluss.utils.CloseableIterator;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.hudi.avro.AvroSchemaCache;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.util.collection.ClosableIterator;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.org.apache.avro.Schema;
+import org.apache.hudi.source.ExpressionPredicates;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+import org.apache.hudi.table.format.InternalSchemaManager;
+import org.apache.hudi.util.AvroSchemaConverter;
+import org.apache.hudi.util.StreamerUtil;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.apache.fluss.lake.hudi.HudiLakeCatalog.SYSTEM_COLUMNS;
+import static org.apache.fluss.lake.hudi.utils.HudiConversions.toChangeType;
+import static org.apache.fluss.metadata.TableDescriptor.OFFSET_COLUMN_NAME;
+import static org.apache.fluss.metadata.TableDescriptor.TIMESTAMP_COLUMN_NAME;
+
+/** Record reader for Hudi tables. */
+public class HudiRecordReader implements RecordReader {
+
+ private static final String HUDI_METADATA_COLUMN_PREFIX = "_hoodie_";
+
+ private final HudiRecordAsFlussRecordIterator iterator;
+
+ public HudiRecordReader(
+ Configuration hudiConfig,
+ TablePath tablePath,
+ HudiSplit hudiSplit,
+ @Nullable int[][] project)
+ throws Exception {
+ this(hudiConfig, tablePath, hudiSplit, project,
java.util.Collections.emptyList());
+ }
+
+ public HudiRecordReader(
+ Configuration hudiConfig,
+ TablePath tablePath,
+ HudiSplit hudiSplit,
+ @Nullable int[][] project,
+ List<ExpressionPredicates.Predicate> predicates)
+ throws Exception {
+ FileSlice fileSlice = hudiSplit.getFileSlice();
+ try (HudiTableInfo hudiTableInfo = HudiTableInfo.create(tablePath,
hudiConfig)) {
+ Schema avroSchema =
+
StreamerUtil.getTableAvroSchema(hudiTableInfo.getMetaClient(), true);
+ org.apache.flink.configuration.Configuration flinkHudiOptions =
+ buildFlinkHudiOptions(hudiTableInfo, tablePath,
avroSchema);
+
+ int metadataFieldCount = metadataFieldCount(avroSchema);
+ int[] selectedFields = selectedFields(avroSchema, project,
metadataFieldCount);
+ Schema requiredSchema = createRequiredSchema(avroSchema,
selectedFields);
+
+ InternalSchemaManager internalSchemaManager =
+ InternalSchemaManager.get(
+ new HadoopStorageConfiguration(
+
HadoopConfigurations.getHadoopConf(flinkHudiOptions)),
+ hudiTableInfo.getMetaClient());
+
+ boolean emitDelete =
+ hudiTableInfo.getTableType() ==
HoodieTableType.MERGE_ON_READ
+ && (flinkHudiOptions
+ .getString(FlinkOptions.QUERY_TYPE)
+
.equals(FlinkOptions.QUERY_TYPE_SNAPSHOT)
+ || flinkHudiOptions
+ .getString(FlinkOptions.QUERY_TYPE)
+
.equals(FlinkOptions.QUERY_TYPE_INCREMENTAL));
+
+ UnifiedHudiTableReader unifiedHudiTableReader =
+ UnifiedHudiTableReader.newBuilder()
+ .withMetaClient(hudiTableInfo.getMetaClient())
+ .withInternalSchemaManager(internalSchemaManager)
+ .withProps(flinkHudiOptions)
+ .withTableSchema(avroSchema)
+ .withRequiredSchema(requiredSchema)
+ .withSelectedFields(selectedFields)
+ .withPredicates(predicates)
+ .withEmitDelete(emitDelete)
+
.withLatestCommitTime(fileSlice.getLatestInstantTime())
+ .build();
+
+ ClosableIterator<RowData> hudiRecordIterator =
+ unifiedHudiTableReader.readFileSlice(fileSlice);
+ this.iterator =
+ new HudiRecordAsFlussRecordIterator(
+ hudiRecordIterator,
+ requiredSchema,
+ metadataFieldCount,
+ SYSTEM_COLUMNS.size());
+ }
+ }
+
+ @Override
+ public CloseableIterator<LogRecord> read() throws IOException {
+ return iterator;
+ }
+
+ private static org.apache.flink.configuration.Configuration
buildFlinkHudiOptions(
+ HudiTableInfo hudiTableInfo, TablePath tablePath, Schema
avroSchema) {
+ Map<String, String> hudiOptions = new
HashMap<>(hudiTableInfo.getTableOptions());
+ hudiOptions.put(FlinkOptions.PATH.key(), hudiTableInfo.getBasePath());
+ hudiOptions.put(FlinkOptions.TABLE_NAME.key(),
tablePath.getTableName());
+ hudiOptions.put(FlinkOptions.SOURCE_AVRO_SCHEMA.key(),
avroSchema.toString());
+ return
org.apache.flink.configuration.Configuration.fromMap(hudiOptions);
+ }
+
+ private static int metadataFieldCount(Schema schema) {
+ int metadataFieldCount = 0;
+ for (Schema.Field field : schema.getFields()) {
+ if (!field.name().startsWith(HUDI_METADATA_COLUMN_PREFIX)) {
+ break;
+ }
+ metadataFieldCount++;
+ }
+ return metadataFieldCount;
+ }
+
+ private static int[] selectedFields(
+ Schema schema, @Nullable int[][] project, int metadataFieldCount) {
+ if (project == null) {
+ return IntStream.range(0, schema.getFields().size()).toArray();
+ }
+
+ int[] hudiMetadataFields = IntStream.range(0,
metadataFieldCount).toArray();
+ int[] projectedDataFields =
+ Arrays.stream(project)
+ .filter(projectPath -> projectPath.length > 0)
+ .mapToInt(projectPath -> projectPath[0] +
metadataFieldCount)
+ .toArray();
+ int[] systemFields =
+ SYSTEM_COLUMNS.keySet().stream()
+ .mapToInt(systemColumn ->
requiredFieldPosition(schema, systemColumn))
+ .toArray();
+
+ return IntStream.concat(
+ IntStream.concat(
+ IntStream.of(hudiMetadataFields),
+ IntStream.of(projectedDataFields)),
+ IntStream.of(systemFields))
+ .toArray();
+ }
+
+ private static Schema createRequiredSchema(Schema avroSchema, int[]
selectedFields) {
+ DataType rowDataType =
AvroSchemaConverter.convertToDataType(avroSchema);
+ RowType rowType = (RowType) rowDataType.getLogicalType();
+ return AvroSchemaCache.intern(
+ new Schema.Parser()
+ .parse(
+ AvroSchemaConverter.convertToSchema(
+ producedDataType(rowType,
selectedFields)
+ .notNull()
+ .getLogicalType())
+ .toString()));
+ }
+
+ private static DataType producedDataType(RowType rowType, int[]
selectedFields) {
+ List<String> fieldNames = rowType.getFieldNames();
+ List<LogicalType> fieldLogicalTypes =
+ rowType.getFields().stream()
+ .map(RowType.RowField::getType)
+ .collect(Collectors.toList());
+ List<DataType> fieldDataTypes =
+
fieldLogicalTypes.stream().map(DataTypes::of).collect(Collectors.toList());
+
+ return DataTypes.ROW(
+ Arrays.stream(selectedFields)
+ .mapToObj(
+ pos ->
+ DataTypes.FIELD(
+ fieldNames.get(pos),
+
fieldDataTypes.get(pos)))
+ .toArray(DataTypes.Field[]::new))
+ .bridgedTo(RowData.class);
+ }
+
+ private static int requiredFieldPosition(Schema schema, String fieldName) {
+ Schema.Field field = schema.getField(fieldName);
+ if (field == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Required Hudi system column '%s' does not exist
in Hudi schema.",
+ fieldName));
+ }
+ return field.pos();
+ }
+
+ /** Iterator for Hudi {@link RowData} as Fluss {@link LogRecord}. */
+ public static class HudiRecordAsFlussRecordIterator implements
CloseableIterator<LogRecord> {
+
+ private final ClosableIterator<RowData> hudiRecordIterator;
+ private final ProjectedRow projectedRow;
+ private final HudiRowAsFlussRow hudiRowAsFlussRow;
+ private final int logOffsetColIndex;
+ private final int timestampColIndex;
+
+ private boolean closed;
+
+ public HudiRecordAsFlussRecordIterator(
+ ClosableIterator<RowData> hudiRecordIterator,
+ Schema schema,
+ int metadataFieldCount,
+ int systemFieldCount) {
+ this.hudiRecordIterator = hudiRecordIterator;
+ this.logOffsetColIndex = requiredFieldPosition(schema,
OFFSET_COLUMN_NAME);
+ this.timestampColIndex = requiredFieldPosition(schema,
TIMESTAMP_COLUMN_NAME);
+ this.hudiRowAsFlussRow = new HudiRowAsFlussRow();
+ this.projectedRow =
+ ProjectedRow.from(
+ IntStream.range(
+ metadataFieldCount,
+ schema.getFields().size() -
systemFieldCount)
+ .toArray());
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ try {
+ hudiRecordIterator.close();
+ } catch (Exception e) {
+ throw new RuntimeException("Fail to close iterator.", e);
+ } finally {
+ closed = true;
+ }
+ }
+
+ @Override
+ public boolean hasNext() {
+ return !closed && hudiRecordIterator.hasNext();
+ }
+
+ @Override
+ public LogRecord next() {
+ RowData rowData = hudiRecordIterator.next();
+ ChangeType changeType = toChangeType(rowData.getRowKind());
+ long offset = rowData.getLong(logOffsetColIndex);
+ long timestamp = rowData.getTimestamp(timestampColIndex,
6).getMillisecond();
+
+ return new GenericRecord(
+ offset,
+ timestamp,
+ changeType,
+
projectedRow.replaceRow(hudiRowAsFlussRow.replaceRow(rowData)));
+ }
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRowAsFlussRow.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRowAsFlussRow.java
new file mode 100644
index 000000000..059dd317f
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiRowAsFlussRow.java
@@ -0,0 +1,175 @@
+/*
+ * 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.source;
+
+import org.apache.fluss.row.BinaryString;
+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.RowData;
+import org.apache.flink.table.data.TimestampData;
+
+import static org.apache.fluss.lake.hudi.HudiLakeCatalog.SYSTEM_COLUMNS;
+
+/** Wraps a Hudi/Flink {@link RowData} as a Fluss {@link InternalRow}. */
+public class HudiRowAsFlussRow implements InternalRow {
+
+ private RowData rowData;
+ private final boolean stripSystemColumns;
+
+ public HudiRowAsFlussRow() {
+ this(true);
+ }
+
+ public HudiRowAsFlussRow(RowData rowData) {
+ this(rowData, true);
+ }
+
+ HudiRowAsFlussRow(RowData rowData, boolean stripSystemColumns) {
+ this.rowData = rowData;
+ this.stripSystemColumns = stripSystemColumns;
+ }
+
+ private HudiRowAsFlussRow(boolean stripSystemColumns) {
+ this.stripSystemColumns = stripSystemColumns;
+ }
+
+ public HudiRowAsFlussRow replaceRow(RowData rowData) {
+ this.rowData = rowData;
+ return this;
+ }
+
+ @Override
+ public int getFieldCount() {
+ return stripSystemColumns ? rowData.getArity() - SYSTEM_COLUMNS.size()
: rowData.getArity();
+ }
+
+ @Override
+ public boolean isNullAt(int pos) {
+ return rowData.isNullAt(pos);
+ }
+
+ @Override
+ public boolean getBoolean(int pos) {
+ return rowData.getBoolean(pos);
+ }
+
+ @Override
+ public byte getByte(int pos) {
+ return rowData.getByte(pos);
+ }
+
+ @Override
+ public short getShort(int pos) {
+ return rowData.getShort(pos);
+ }
+
+ @Override
+ public int getInt(int pos) {
+ return rowData.getInt(pos);
+ }
+
+ @Override
+ public long getLong(int pos) {
+ return rowData.getLong(pos);
+ }
+
+ @Override
+ public float getFloat(int pos) {
+ return rowData.getFloat(pos);
+ }
+
+ @Override
+ public double getDouble(int pos) {
+ return rowData.getDouble(pos);
+ }
+
+ @Override
+ public BinaryString getChar(int pos, int length) {
+ return BinaryString.fromBytes(rowData.getString(pos).toBytes());
+ }
+
+ @Override
+ public BinaryString getString(int pos) {
+ return BinaryString.fromBytes(rowData.getString(pos).toBytes());
+ }
+
+ @Override
+ public Decimal getDecimal(int pos, int precision, int scale) {
+ DecimalData decimalData = rowData.getDecimal(pos, precision, scale);
+ if (decimalData.isCompact()) {
+ return Decimal.fromUnscaledLong(decimalData.toUnscaledLong(),
precision, scale);
+ }
+ return Decimal.fromBigDecimal(decimalData.toBigDecimal(), precision,
scale);
+ }
+
+ @Override
+ public TimestampNtz getTimestampNtz(int pos, int precision) {
+ TimestampData timestamp = rowData.getTimestamp(pos, precision);
+ if (TimestampNtz.isCompact(precision)) {
+ return TimestampNtz.fromMillis(timestamp.getMillisecond());
+ }
+ return TimestampNtz.fromMillis(
+ timestamp.getMillisecond(), timestamp.getNanoOfMillisecond());
+ }
+
+ @Override
+ public TimestampLtz getTimestampLtz(int pos, int precision) {
+ TimestampData timestamp = rowData.getTimestamp(pos, precision);
+ if (TimestampLtz.isCompact(precision)) {
+ return TimestampLtz.fromEpochMillis(timestamp.getMillisecond());
+ }
+ return TimestampLtz.fromEpochMillis(
+ timestamp.getMillisecond(), timestamp.getNanoOfMillisecond());
+ }
+
+ @Override
+ public byte[] getBinary(int pos, int length) {
+ return rowData.getBinary(pos);
+ }
+
+ @Override
+ public byte[] getBytes(int pos) {
+ return rowData.getBinary(pos);
+ }
+
+ @Override
+ public InternalArray getArray(int pos) {
+ ArrayData value = rowData.getArray(pos);
+ return value == null ? null : new HudiArrayAsFlussArray(value);
+ }
+
+ @Override
+ public InternalMap getMap(int pos) {
+ MapData value = rowData.getMap(pos);
+ return value == null ? null : new HudiMapAsFlussMap(value);
+ }
+
+ @Override
+ public InternalRow getRow(int pos, int numFields) {
+ RowData value = rowData.getRow(pos, numFields);
+ return value == null ? null : new HudiRowAsFlussRow(value, false);
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/UnifiedHudiTableReader.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/UnifiedHudiTableReader.java
new file mode 100644
index 000000000..8c4160685
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/UnifiedHudiTableReader.java
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.source;
+
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.DataType;
+import org.apache.hadoop.fs.Path;
+import org.apache.hudi.common.config.HoodieReaderConfig;
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.log.InstantRange;
+import org.apache.hudi.common.table.read.HoodieFileGroupReader;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.collection.ClosableIterator;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.org.apache.avro.Schema;
+import org.apache.hudi.source.ExpressionPredicates.Predicate;
+import org.apache.hudi.table.format.FilePathUtils;
+import org.apache.hudi.table.format.FlinkRowDataReaderContext;
+import org.apache.hudi.table.format.InternalSchemaManager;
+import org.apache.hudi.table.format.RecordIterators;
+import org.apache.hudi.util.AvroSchemaConverter;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static
org.apache.hudi.configuration.HadoopConfigurations.getParquetConf;
+
+/** Unified reader for both Copy-On-Write and Merge-On-Read Hudi file slices.
*/
+public class UnifiedHudiTableReader implements AutoCloseable {
+
+ private final HoodieTableMetaClient metaClient;
+ private final org.apache.flink.configuration.Configuration
flinkHudiOptions;
+ private final Schema tableSchema;
+ private final Schema requiredSchema;
+ private final int[] selectedFields;
+ private final InternalSchemaManager internalSchemaManager;
+ private final boolean caseSensitive;
+ private final int batchSize;
+ private final boolean emitDelete;
+ private final List<Predicate> predicates;
+ private final String latestCommitTime;
+ private final Option<InstantRange> instantRangeOpt;
+
+ private ClosableIterator<RowData> currentIterator;
+ private boolean closed;
+
+ private UnifiedHudiTableReader(
+ HoodieTableMetaClient metaClient,
+ org.apache.flink.configuration.Configuration flinkHudiOptions,
+ Schema tableSchema,
+ Schema requiredSchema,
+ int[] selectedFields,
+ InternalSchemaManager internalSchemaManager,
+ boolean caseSensitive,
+ int batchSize,
+ boolean emitDelete,
+ List<Predicate> predicates,
+ String latestCommitTime) {
+ this.metaClient = metaClient;
+ this.flinkHudiOptions = flinkHudiOptions;
+ this.tableSchema = tableSchema;
+ this.requiredSchema = requiredSchema;
+ this.selectedFields = selectedFields;
+ this.internalSchemaManager = internalSchemaManager;
+ this.caseSensitive = caseSensitive;
+ this.batchSize = batchSize;
+ this.emitDelete = emitDelete;
+ this.predicates = predicates;
+ this.latestCommitTime = latestCommitTime;
+ this.instantRangeOpt = Option.empty();
+ }
+
+ public ClosableIterator<RowData> readFileSlice(FileSlice fileSlice) throws
IOException {
+ if (closed) {
+ throw new IllegalStateException("Reader is already closed.");
+ }
+ closeCurrentIterator();
+ if (fileSlice.getLogFiles().findAny().isPresent()) {
+ currentIterator = readMorFileSlice(fileSlice);
+ } else {
+ currentIterator = readCowFileSlice(fileSlice);
+ }
+ return currentIterator;
+ }
+
+ private ClosableIterator<RowData> readCowFileSlice(FileSlice fileSlice)
throws IOException {
+ if (!fileSlice.getBaseFile().isPresent()) {
+ throw new IllegalArgumentException("COW file slice must have a
base file.");
+ }
+
+ HoodieBaseFile baseFile = fileSlice.getBaseFile().get();
+ String filePath = baseFile.getPath();
+ Path hadoopPath = new Path(filePath);
+
+ List<String> fieldNames =
+ tableSchema.getFields().stream()
+ .map(Schema.Field::name)
+ .collect(Collectors.toList());
+ List<DataType> fieldTypes =
+ tableSchema.getFields().stream()
+ .map(field ->
AvroSchemaConverter.convertToDataType(field.schema()))
+ .collect(Collectors.toList());
+ LinkedHashMap<String, Object> partitionSpec =
+ FilePathUtils.generatePartitionSpecs(
+ filePath,
+ fieldNames,
+ fieldTypes,
+
flinkHudiOptions.get(FlinkOptions.PARTITION_DEFAULT_NAME),
+
flinkHudiOptions.get(FlinkOptions.PARTITION_PATH_FIELD),
+
flinkHudiOptions.get(FlinkOptions.HIVE_STYLE_PARTITIONING));
+
+ return RecordIterators.getParquetRecordIterator(
+ internalSchemaManager,
+ flinkHudiOptions.get(FlinkOptions.READ_UTC_TIMEZONE),
+ caseSensitive,
+ getParquetConf(
+ flinkHudiOptions,
HadoopConfigurations.getHadoopConf(flinkHudiOptions)),
+ fieldNames.toArray(new String[0]),
+ fieldTypes.toArray(new DataType[0]),
+ partitionSpec,
+ selectedFields,
+ batchSize,
+ new org.apache.flink.core.fs.Path(hadoopPath.toUri()),
+ 0L,
+ baseFile.getFileLen(),
+ predicates);
+ }
+
+ private ClosableIterator<RowData> readMorFileSlice(FileSlice fileSlice)
throws IOException {
+ FlinkRowDataReaderContext readerContext =
+ new FlinkRowDataReaderContext(
+ metaClient.getStorageConf(),
+ () -> internalSchemaManager,
+ predicates,
+ metaClient.getTableConfig(),
+ instantRangeOpt);
+
+ TypedProperties typedProperties = getReadProps(metaClient,
flinkHudiOptions);
+ typedProperties.put(
+ HoodieReaderConfig.MERGE_TYPE.key(),
flinkHudiOptions.get(FlinkOptions.MERGE_TYPE));
+
+ return HoodieFileGroupReader.<RowData>newBuilder()
+ .withReaderContext(readerContext)
+ .withHoodieTableMetaClient(metaClient)
+ .withLatestCommitTime(latestCommitTime)
+ .withFileSlice(fileSlice)
+ .withDataSchema(tableSchema)
+ .withRequestedSchema(requiredSchema)
+
.withInternalSchema(Option.ofNullable(internalSchemaManager.getQuerySchema()))
+ .withProps(typedProperties)
+ .withShouldUseRecordPosition(false)
+ .withEmitDelete(emitDelete)
+ .build()
+ .getClosableIterator();
+ }
+
+ public static TypedProperties getReadProps(
+ HoodieTableMetaClient metaClient,
+ org.apache.flink.configuration.Configuration flinkHudiOptions) {
+ TypedProperties properties = new TypedProperties();
+ properties.putAll(metaClient.getTableConfig().getProps());
+ properties.putAll(flinkHudiOptions.toMap());
+ return properties;
+ }
+
+ private void closeCurrentIterator() {
+ if (currentIterator != null) {
+ currentIterator.close();
+ currentIterator = null;
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (closed) {
+ return;
+ }
+ closeCurrentIterator();
+ closed = true;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /** Builder for {@link UnifiedHudiTableReader}. */
+ public static class Builder {
+ private HoodieTableMetaClient metaClient;
+ private org.apache.flink.configuration.Configuration flinkHudiOptions;
+ private Schema tableSchema;
+ private Schema requiredSchema;
+ private int[] selectedFields;
+ private InternalSchemaManager internalSchemaManager =
InternalSchemaManager.DISABLED;
+ private boolean caseSensitive = true;
+ private int batchSize = 2048;
+ private boolean emitDelete;
+ private List<Predicate> predicates = Collections.emptyList();
+ private String latestCommitTime;
+
+ public Builder withMetaClient(HoodieTableMetaClient metaClient) {
+ this.metaClient = metaClient;
+ return this;
+ }
+
+ public Builder withProps(org.apache.flink.configuration.Configuration
flinkHudiOptions) {
+ this.flinkHudiOptions = flinkHudiOptions;
+ return this;
+ }
+
+ public Builder withTableSchema(Schema tableSchema) {
+ this.tableSchema = tableSchema;
+ return this;
+ }
+
+ public Builder withRequiredSchema(Schema requiredSchema) {
+ this.requiredSchema = requiredSchema;
+ return this;
+ }
+
+ public Builder withSelectedFields(int[] selectedFields) {
+ this.selectedFields = selectedFields;
+ return this;
+ }
+
+ public Builder withInternalSchemaManager(InternalSchemaManager
internalSchemaManager) {
+ this.internalSchemaManager = internalSchemaManager;
+ return this;
+ }
+
+ public Builder withCaseSensitive(boolean caseSensitive) {
+ this.caseSensitive = caseSensitive;
+ return this;
+ }
+
+ public Builder withBatchSize(int batchSize) {
+ this.batchSize = batchSize;
+ return this;
+ }
+
+ public Builder withEmitDelete(boolean emitDelete) {
+ this.emitDelete = emitDelete;
+ return this;
+ }
+
+ public Builder withPredicates(List<Predicate> predicates) {
+ this.predicates = predicates;
+ return this;
+ }
+
+ public Builder withLatestCommitTime(String latestCommitTime) {
+ this.latestCommitTime = latestCommitTime;
+ return this;
+ }
+
+ public UnifiedHudiTableReader build() {
+ if (metaClient == null) {
+ throw new IllegalArgumentException("metaClient is required.");
+ }
+ if (flinkHudiOptions == null) {
+ throw new IllegalArgumentException("flinkHudiOptions is
required.");
+ }
+ if (tableSchema == null) {
+ throw new IllegalArgumentException("tableSchema is required.");
+ }
+ if (requiredSchema == null) {
+ requiredSchema = tableSchema;
+ }
+ if (selectedFields == null) {
+ throw new IllegalArgumentException("selectedFields is
required.");
+ }
+ if (latestCommitTime == null) {
+ throw new IllegalArgumentException("latestCommitTime is
required.");
+ }
+ return new UnifiedHudiTableReader(
+ metaClient,
+ flinkHudiOptions,
+ tableSchema,
+ requiredSchema,
+ selectedFields,
+ internalSchemaManager,
+ caseSensitive,
+ batchSize,
+ emitDelete,
+ predicates,
+ latestCommitTime);
+ }
+ }
+}
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 2f90124e5..5c4a66214 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
@@ -24,6 +24,7 @@ import org.apache.fluss.lake.hudi.FlussDataTypeToHudiDataType;
import org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils;
import org.apache.fluss.metadata.TableDescriptor;
import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.ChangeType;
import org.apache.flink.table.api.Schema;
import org.apache.flink.table.catalog.CatalogTable;
@@ -33,6 +34,7 @@ import org.apache.flink.table.catalog.ResolvedSchema;
import org.apache.flink.table.catalog.UniqueConstraint;
import org.apache.flink.table.factories.FactoryUtil;
import org.apache.flink.table.types.DataType;
+import org.apache.flink.types.RowKind;
import org.apache.hudi.common.model.HoodieTableType;
import org.apache.hudi.configuration.FlinkOptions;
import org.apache.hudi.index.HoodieIndex;
@@ -316,4 +318,19 @@ public class HudiConversions {
}
return primaryKeys;
}
+
+ public static ChangeType toChangeType(RowKind rowKind) {
+ switch (rowKind) {
+ case INSERT:
+ return ChangeType.INSERT;
+ case UPDATE_BEFORE:
+ return ChangeType.UPDATE_BEFORE;
+ case UPDATE_AFTER:
+ return ChangeType.UPDATE_AFTER;
+ case DELETE:
+ return ChangeType.DELETE;
+ default:
+ throw new IllegalArgumentException("Unsupported change type: "
+ rowKind);
+ }
+ }
}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiLakeSourceTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiLakeSourceTest.java
index 1b0e2fe89..e8446ee85 100644
---
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiLakeSourceTest.java
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiLakeSourceTest.java
@@ -27,6 +27,7 @@ import org.apache.fluss.types.RowType;
import org.junit.jupiter.api.Test;
+import java.io.IOException;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
@@ -75,12 +76,12 @@ class HudiLakeSourceTest {
}
@Test
- void testCreateRecordReaderIsExplicitlyUnsupported() {
+ void testCreateRecordReaderWrapsReaderErrors() {
HudiLakeSource source =
new HudiLakeSource(new Configuration(), TablePath.of("db1",
"table1"));
assertThatThrownBy(() -> source.createRecordReader(() -> null))
- .isInstanceOf(UnsupportedOperationException.class)
- .hasMessageContaining("record reading");
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Fail to create Hudi record reader");
}
}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiRecordReaderTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiRecordReaderTest.java
new file mode 100644
index 000000000..64f802a9a
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiRecordReaderTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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.source;
+
+import org.apache.fluss.record.ChangeType;
+import org.apache.fluss.record.LogRecord;
+import org.apache.fluss.row.BinaryString;
+
+import org.apache.flink.table.data.GenericRowData;
+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.types.RowKind;
+import org.apache.hudi.common.util.collection.ClosableIterator;
+import org.apache.hudi.org.apache.avro.Schema;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link HudiRecordReader}. */
+class HudiRecordReaderTest {
+
+ @Test
+ void testIteratorConvertsHudiRowDataToFlussLogRecord() {
+ TestingClosableIterator hudiIterator =
+ new TestingClosableIterator(
+ rowData(RowKind.UPDATE_AFTER, 11, "value", 3, 42L,
1234L));
+ HudiRecordReader.HudiRecordAsFlussRecordIterator iterator =
+ new HudiRecordReader.HudiRecordAsFlussRecordIterator(
+ hudiIterator, fullSchema(), 2, 3);
+
+ assertThat(iterator.hasNext()).isTrue();
+ LogRecord logRecord = iterator.next();
+
+
assertThat(logRecord.getChangeType()).isEqualTo(ChangeType.UPDATE_AFTER);
+ assertThat(logRecord.logOffset()).isEqualTo(42L);
+ assertThat(logRecord.timestamp()).isEqualTo(1234L);
+ assertThat(logRecord.getRow().getFieldCount()).isEqualTo(2);
+ assertThat(logRecord.getRow().getInt(0)).isEqualTo(11);
+
assertThat(logRecord.getRow().getString(1)).isEqualTo(BinaryString.fromString("value"));
+
+ iterator.close();
+ iterator.close();
+ assertThat(iterator.hasNext()).isFalse();
+ assertThat(hudiIterator.getCloseCount()).isEqualTo(1);
+ }
+
+ @Test
+ void testIteratorSkipsMetadataAndAllSystemColumnsForProjectedSchema() {
+ TestingClosableIterator hudiIterator =
+ new TestingClosableIterator(
+ projectedRowData(RowKind.DELETE, "projected", 5, 7L,
999L));
+ HudiRecordReader.HudiRecordAsFlussRecordIterator iterator =
+ new HudiRecordReader.HudiRecordAsFlussRecordIterator(
+ hudiIterator, projectedSchema(), 2, 3);
+
+ LogRecord logRecord = iterator.next();
+
+ assertThat(logRecord.getChangeType()).isEqualTo(ChangeType.DELETE);
+ assertThat(logRecord.logOffset()).isEqualTo(7L);
+ assertThat(logRecord.timestamp()).isEqualTo(999L);
+ assertThat(logRecord.getRow().getFieldCount()).isEqualTo(1);
+
assertThat(logRecord.getRow().getString(0)).isEqualTo(BinaryString.fromString("projected"));
+
+ iterator.close();
+ assertThat(hudiIterator.getCloseCount()).isEqualTo(1);
+ }
+
+ @Test
+ void testIteratorFailsClearlyWhenRequiredSystemColumnIsMissing() {
+ TestingClosableIterator hudiIterator =
+ new TestingClosableIterator(
+ projectedRowData(RowKind.DELETE, "projected", 5, 7L,
999L));
+
+ assertThatThrownBy(
+ () ->
+ new
HudiRecordReader.HudiRecordAsFlussRecordIterator(
+ hudiIterator,
schemaWithoutOffsetColumn(), 2, 2))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("__offset")
+ .hasMessageContaining("does not exist");
+ }
+
+ private static RowData rowData(
+ RowKind rowKind, int id, String value, int bucket, long offset,
long timestamp) {
+ GenericRowData rowData = new GenericRowData(7);
+ rowData.setRowKind(rowKind);
+ rowData.setField(0, StringData.fromString("commit"));
+ rowData.setField(1, StringData.fromString("record-key"));
+ rowData.setField(2, id);
+ rowData.setField(3, StringData.fromString(value));
+ rowData.setField(4, bucket);
+ rowData.setField(5, offset);
+ rowData.setField(6, TimestampData.fromEpochMillis(timestamp));
+ return rowData;
+ }
+
+ private static RowData projectedRowData(
+ RowKind rowKind, String value, int bucket, long offset, long
timestamp) {
+ GenericRowData rowData = new GenericRowData(6);
+ rowData.setRowKind(rowKind);
+ rowData.setField(0, StringData.fromString("commit"));
+ rowData.setField(1, StringData.fromString("record-key"));
+ rowData.setField(2, StringData.fromString(value));
+ rowData.setField(3, bucket);
+ rowData.setField(4, offset);
+ rowData.setField(5, TimestampData.fromEpochMillis(timestamp));
+ return rowData;
+ }
+
+ private static Schema fullSchema() {
+ return parseSchema(
+ "["
+ +
"{\"name\":\"_hoodie_commit_time\",\"type\":\"string\"},"
+ +
"{\"name\":\"_hoodie_record_key\",\"type\":\"string\"},"
+ + "{\"name\":\"id\",\"type\":\"int\"},"
+ + "{\"name\":\"value\",\"type\":\"string\"},"
+ + "{\"name\":\"__bucket\",\"type\":\"int\"},"
+ + "{\"name\":\"__offset\",\"type\":\"long\"},"
+ + "{\"name\":\"__timestamp\",\"type\":\"long\"}"
+ + "]");
+ }
+
+ private static Schema projectedSchema() {
+ return parseSchema(
+ "["
+ +
"{\"name\":\"_hoodie_commit_time\",\"type\":\"string\"},"
+ +
"{\"name\":\"_hoodie_record_key\",\"type\":\"string\"},"
+ + "{\"name\":\"value\",\"type\":\"string\"},"
+ + "{\"name\":\"__bucket\",\"type\":\"int\"},"
+ + "{\"name\":\"__offset\",\"type\":\"long\"},"
+ + "{\"name\":\"__timestamp\",\"type\":\"long\"}"
+ + "]");
+ }
+
+ private static Schema schemaWithoutOffsetColumn() {
+ return parseSchema(
+ "["
+ +
"{\"name\":\"_hoodie_commit_time\",\"type\":\"string\"},"
+ +
"{\"name\":\"_hoodie_record_key\",\"type\":\"string\"},"
+ + "{\"name\":\"value\",\"type\":\"string\"},"
+ + "{\"name\":\"__bucket\",\"type\":\"int\"},"
+ + "{\"name\":\"__timestamp\",\"type\":\"long\"}"
+ + "]");
+ }
+
+ private static Schema parseSchema(String fields) {
+ return new Schema.Parser()
+ .parse(
+ "{"
+ + "\"type\":\"record\","
+ + "\"name\":\"hudi_record\","
+ + "\"fields\":"
+ + fields
+ + "}");
+ }
+
+ private static class TestingClosableIterator implements
ClosableIterator<RowData> {
+ private final Iterator<RowData> iterator;
+ private int closeCount;
+
+ private TestingClosableIterator(RowData rowData) {
+ this.iterator = Collections.singletonList(rowData).iterator();
+ }
+
+ @Override
+ public void close() {
+ closeCount++;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return iterator.hasNext();
+ }
+
+ @Override
+ public RowData next() {
+ if (!iterator.hasNext()) {
+ throw new NoSuchElementException();
+ }
+ return iterator.next();
+ }
+
+ private int getCloseCount() {
+ return closeCount;
+ }
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiRowAsFlussRowTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiRowAsFlussRowTest.java
new file mode 100644
index 000000000..814c89651
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiRowAsFlussRowTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.source;
+
+import org.apache.fluss.row.BinaryString;
+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.DecimalData;
+import org.apache.flink.table.data.GenericArrayData;
+import org.apache.flink.table.data.GenericMapData;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.data.TimestampData;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link HudiRowAsFlussRow}. */
+class HudiRowAsFlussRowTest {
+
+ @Test
+ void testAccessPrimitiveAndNestedValues() {
+ GenericRowData nestedRow = new GenericRowData(1);
+ nestedRow.setField(0, StringData.fromString("nested"));
+
+ GenericRowData rowData = new GenericRowData(19);
+ rowData.setField(0, true);
+ rowData.setField(1, (byte) 1);
+ rowData.setField(2, (short) 2);
+ rowData.setField(3, 3);
+ rowData.setField(4, 4L);
+ rowData.setField(5, 1.5f);
+ rowData.setField(6, 2.5d);
+ rowData.setField(7, StringData.fromString("string"));
+ rowData.setField(8, DecimalData.fromUnscaledLong(1234, 10, 2));
+ rowData.setField(9, DecimalData.fromBigDecimal(new
BigDecimal("1234567890"), 20, 0));
+ rowData.setField(10, TimestampData.fromEpochMillis(1000L));
+ rowData.setField(11, TimestampData.fromEpochMillis(2000L, 123456));
+ rowData.setField(12, new byte[] {1, 2});
+ rowData.setField(13, new GenericArrayData(new Object[]
{StringData.fromString("a")}));
+ rowData.setField(
+ 14,
+ new GenericMapData(
+ Collections.singletonMap(
+ StringData.fromString("key"),
StringData.fromString("value"))));
+ rowData.setField(15, nestedRow);
+ rowData.setField(16, 0);
+ rowData.setField(17, 1L);
+ rowData.setField(18, TimestampData.fromEpochMillis(3000L));
+
+ HudiRowAsFlussRow row = new HudiRowAsFlussRow(rowData);
+
+ assertThat(row.getFieldCount()).isEqualTo(16);
+ assertThat(row.getBoolean(0)).isTrue();
+ assertThat(row.getByte(1)).isEqualTo((byte) 1);
+ assertThat(row.getShort(2)).isEqualTo((short) 2);
+ assertThat(row.getInt(3)).isEqualTo(3);
+ assertThat(row.getLong(4)).isEqualTo(4L);
+ assertThat(row.getFloat(5)).isEqualTo(1.5f);
+ assertThat(row.getDouble(6)).isEqualTo(2.5d);
+
assertThat(row.getString(7)).isEqualTo(BinaryString.fromString("string"));
+ assertThat(row.getDecimal(8, 10,
2)).isEqualTo(Decimal.fromUnscaledLong(1234, 10, 2));
+ assertThat(row.getDecimal(9, 20, 0))
+ .isEqualTo(Decimal.fromBigDecimal(new
BigDecimal("1234567890"), 20, 0));
+ assertThat(row.getTimestampNtz(10,
3)).isEqualTo(TimestampNtz.fromMillis(1000L));
+ assertThat(row.getTimestampLtz(11, 6))
+ .isEqualTo(TimestampLtz.fromEpochMillis(2000L, 123456));
+ assertThat(row.getBytes(12)).containsExactly(1, 2);
+
+ InternalArray array = row.getArray(13);
+ assertThat(array.getString(0)).isEqualTo(BinaryString.fromString("a"));
+
+ InternalMap map = row.getMap(14);
+
assertThat(map.keyArray().getString(0)).isEqualTo(BinaryString.fromString("key"));
+
assertThat(map.valueArray().getString(0)).isEqualTo(BinaryString.fromString("value"));
+
+ InternalRow nested = row.getRow(15, 1);
+ assertThat(nested.getFieldCount()).isEqualTo(1);
+
assertThat(nested.getString(0)).isEqualTo(BinaryString.fromString("nested"));
+ }
+}