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 03dad8871 [lake/hudi] Introduce Hudi source split planner (#3456)
03dad8871 is described below
commit 03dad8871bebed1bb7f27950d395e1690ff152c6
Author: fhan <[email protected]>
AuthorDate: Mon Jun 15 14:21:17 2026 +0800
[lake/hudi] Introduce Hudi source split planner (#3456)
---
.../apache/fluss/lake/hudi/HudiLakeStorage.java | 16 +-
.../fluss/lake/hudi/source/HudiLakeSource.java | 76 +++++
.../apache/fluss/lake/hudi/source/HudiSplit.java | 122 +++++++
.../fluss/lake/hudi/source/HudiSplitPlanner.java | 183 +++++++++++
.../lake/hudi/source/HudiSplitSerializer.java | 61 ++++
.../fluss/lake/hudi/utils/HudiConversions.java | 11 +-
.../fluss/lake/hudi/utils/HudiTableInfo.java | 361 +++++++++++++++++++++
.../fluss/lake/hudi/source/HudiLakeSourceTest.java | 86 +++++
.../lake/hudi/source/HudiSplitPlannerTest.java | 304 +++++++++++++++++
.../lake/hudi/source/HudiSplitSerializerTest.java | 73 +++++
.../fluss/lake/hudi/source/HudiSplitTest.java | 77 +++++
.../fluss/lake/hudi/utils/HudiConversionsTest.java | 94 ++++++
.../fluss/lake/hudi/utils/HudiTableInfoTest.java | 118 +++++++
13 files changed, 1571 insertions(+), 11 deletions(-)
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
index 66db56057..55ce818a9 100644
---
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/HudiLakeStorage.java
@@ -18,6 +18,8 @@
package org.apache.fluss.lake.hudi;
import org.apache.fluss.config.Configuration;
+import org.apache.fluss.lake.hudi.source.HudiLakeSource;
+import org.apache.fluss.lake.hudi.source.HudiSplit;
import org.apache.fluss.lake.lakestorage.LakeCatalog;
import org.apache.fluss.lake.lakestorage.LakeStorage;
import org.apache.fluss.lake.source.LakeSource;
@@ -36,9 +38,8 @@ public class HudiLakeStorage implements LakeStorage {
@Override
public LakeTieringFactory<?, ?> createLakeTieringFactory() {
throw new UnsupportedOperationException(
- "HudiLakeStorage is currently a scaffold and does not support
creating a "
- + "LakeTieringFactory yet. Verify that Hudi lake
storage was selected "
- + "intentionally and that the required Hudi
support/module is available.");
+ "Hudi lake tiering writer is not implemented yet, so
HudiLakeStorage does not "
+ + "support creating a LakeTieringFactory.");
}
@Override
@@ -47,12 +48,7 @@ public class HudiLakeStorage implements LakeStorage {
}
@Override
- public LakeSource<?> createLakeSource(TablePath tablePath) {
- throw new UnsupportedOperationException(
- "HudiLakeStorage is currently a scaffold and does not support
creating a "
- + "LakeSource for table '"
- + tablePath
- + "' yet. Verify that Hudi lake storage was selected
intentionally "
- + "and that the required Hudi support/module is
available.");
+ public LakeSource<HudiSplit> createLakeSource(TablePath tablePath) {
+ return new HudiLakeSource(hudiConfig, tablePath);
}
}
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
new file mode 100644
index 000000000..c491ddfa6
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiLakeSource.java
@@ -0,0 +1,76 @@
+/*
+ * 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.serializer.SimpleVersionedSerializer;
+import org.apache.fluss.lake.source.LakeSource;
+import org.apache.fluss.lake.source.Planner;
+import org.apache.fluss.lake.source.RecordReader;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.predicate.Predicate;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Hudi implementation of {@link LakeSource}. */
+public class HudiLakeSource implements LakeSource<HudiSplit> {
+
+ private static final long serialVersionUID = 1L;
+
+ private final Configuration hudiConfig;
+ private final TablePath tablePath;
+
+ public HudiLakeSource(Configuration hudiConfig, TablePath tablePath) {
+ this.hudiConfig = hudiConfig;
+ this.tablePath = tablePath;
+ }
+
+ @Override
+ public void withProject(int[][] project) {
+ // Projection is applied by the Hudi record reader, which is not
implemented yet.
+ }
+
+ @Override
+ public void withLimit(int limit) {
+ throw new UnsupportedOperationException("Hudi lake source does not
support limit yet.");
+ }
+
+ @Override
+ public FilterPushDownResult withFilters(List<Predicate> predicates) {
+ return FilterPushDownResult.of(Collections.emptyList(), new
ArrayList<>(predicates));
+ }
+
+ @Override
+ public Planner<HudiSplit> createPlanner(PlannerContext context) throws
IOException {
+ return new HudiSplitPlanner(hudiConfig, tablePath,
context.snapshotId());
+ }
+
+ @Override
+ public RecordReader createRecordReader(ReaderContext<HudiSplit> context)
throws IOException {
+ throw new UnsupportedOperationException(
+ "Hudi lake source does not support record reading yet.");
+ }
+
+ @Override
+ public SimpleVersionedSerializer<HudiSplit> getSplitSerializer() {
+ return new HudiSplitSerializer();
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplit.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplit.java
new file mode 100644
index 000000000..3b51d823f
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplit.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.source;
+
+import org.apache.fluss.lake.source.LakeSplit;
+
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieBaseFile;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/** A readable split of a Hudi table. */
+public class HudiSplit implements LakeSplit {
+
+ private static final long serialVersionUID = 1L;
+
+ private final FileSlice fileSlice;
+ private final int bucket;
+ private final List<String> partition;
+
+ public HudiSplit(FileSlice fileSlice, int bucket, List<String> partition) {
+ this.fileSlice = Objects.requireNonNull(fileSlice, "fileSlice cannot
be null");
+ this.bucket = bucket;
+ this.partition =
+ Collections.unmodifiableList(
+ new ArrayList<>(
+ Objects.requireNonNull(partition, "partition
cannot be null")));
+ }
+
+ @Override
+ public int bucket() {
+ return bucket;
+ }
+
+ @Override
+ public List<String> partition() {
+ return partition;
+ }
+
+ public FileSlice getFileSlice() {
+ return fileSlice;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof HudiSplit)) {
+ return false;
+ }
+ HudiSplit hudiSplit = (HudiSplit) o;
+ return bucket == hudiSplit.bucket
+ && equalsFileSlice(fileSlice, hudiSplit.fileSlice)
+ && Objects.equals(partition, hudiSplit.partition);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ fileSlice.getFileGroupId(),
+ fileSlice.getBaseInstantTime(),
+ baseFilePath(fileSlice),
+ logFilePaths(fileSlice),
+ bucket,
+ partition);
+ }
+
+ @Override
+ public String toString() {
+ return "HudiSplit{"
+ + "fileSlice="
+ + fileSlice
+ + ", bucket="
+ + bucket
+ + ", partition="
+ + partition
+ + '}';
+ }
+
+ private static boolean equalsFileSlice(FileSlice first, FileSlice second) {
+ return Objects.equals(first.getFileGroupId(), second.getFileGroupId())
+ && Objects.equals(first.getBaseInstantTime(),
second.getBaseInstantTime())
+ && Objects.equals(baseFilePath(first), baseFilePath(second))
+ && Objects.equals(logFilePaths(first), logFilePaths(second));
+ }
+
+ private static String baseFilePath(FileSlice fileSlice) {
+ if (!fileSlice.getBaseFile().isPresent()) {
+ return null;
+ }
+ HoodieBaseFile baseFile = fileSlice.getBaseFile().get();
+ return baseFile.getPath();
+ }
+
+ private static List<String> logFilePaths(FileSlice fileSlice) {
+ return fileSlice
+ .getLogFiles()
+ .map(logFile -> logFile.getPath().toString())
+ .sorted()
+ .collect(Collectors.toList());
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplitPlanner.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplitPlanner.java
new file mode 100644
index 000000000..b57c984b0
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplitPlanner.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.source;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.lake.hudi.utils.HudiTableInfo;
+import org.apache.fluss.lake.source.Planner;
+import org.apache.fluss.metadata.TablePath;
+
+import org.apache.hudi.common.fs.FSUtils;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView;
+import org.apache.hudi.index.bucket.BucketIdentifier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+
+/** Planner for creating Hudi splits. */
+public class HudiSplitPlanner implements Planner<HudiSplit> {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(HudiSplitPlanner.class);
+
+ private final Configuration hudiConfig;
+ private final TablePath tablePath;
+ private final long snapshotId;
+
+ public HudiSplitPlanner(Configuration hudiConfig, TablePath tablePath,
long snapshotId) {
+ this.hudiConfig = hudiConfig;
+ this.tablePath = tablePath;
+ this.snapshotId = snapshotId;
+ }
+
+ @Override
+ public List<HudiSplit> plan() throws IOException {
+ String snapshotTime = toHudiInstantTime(snapshotId);
+ try (HudiTableInfo hudiTableInfo = HudiTableInfo.create(tablePath,
hudiConfig)) {
+ if
(!hudiTableInfo.getCompletedTimeline().containsInstant(snapshotTime)) {
+ throw new IOException(
+ String.format(
+ "Hudi instant time %s does not exist in table
%s.",
+ snapshotTime, tablePath));
+ }
+
+ List<String> partitionPaths =
+ FSUtils.getAllPartitionPaths(
+ hudiTableInfo.getEngineContext(),
hudiTableInfo.getMetaClient(), false);
+ if (partitionPaths.isEmpty()) {
+ if (hudiTableInfo.isPartitioned()) {
+ LOG.info(
+ "Planned no Hudi splits for partitioned table {}
with table type {} at instant {} because no Hudi partition paths were
discovered.",
+ tablePath,
+ hudiTableInfo.getTableType(),
+ snapshotTime);
+ return Collections.emptyList();
+ } else {
+ partitionPaths = Collections.singletonList("");
+ }
+ }
+
+ List<HudiSplit> splits = new ArrayList<>();
+ for (String partitionPath : partitionPaths) {
+ splits.addAll(planPartition(hudiTableInfo, snapshotTime,
partitionPath));
+ }
+ if (splits.isEmpty()) {
+ LOG.info(
+ "Planned no Hudi splits for table {} at instant {}
across {} Hudi partition paths.",
+ tablePath,
+ snapshotTime,
+ partitionPaths.size());
+ } else {
+ LOG.debug(
+ "Planned {} Hudi splits for table {} at instant {}.",
+ splits.size(),
+ tablePath,
+ snapshotTime);
+ }
+ return splits;
+ }
+ }
+
+ @VisibleForTesting
+ static String toHudiInstantTime(long snapshotId) {
+ return String.format(Locale.ROOT, "%017d", snapshotId);
+ }
+
+ private List<HudiSplit> planPartition(
+ HudiTableInfo hudiTableInfo, String snapshotTime, String
partitionPath)
+ throws IOException {
+ HoodieTableFileSystemView fileSystemView =
hudiTableInfo.getFileSystemView();
+ List<HudiSplit> splits = new ArrayList<>();
+ if (hudiTableInfo.getTableType() == HoodieTableType.MERGE_ON_READ) {
+ List<FileSlice> fileSlices =
+ fileSystemView
+
.getLatestMergedFileSlicesBeforeOrOn(partitionPath, snapshotTime)
+ .collect(Collectors.toList());
+ for (FileSlice fileSlice : fileSlices) {
+ splits.add(toHudiSplit(hudiTableInfo, partitionPath,
fileSlice));
+ }
+ return splits;
+ } else if (hudiTableInfo.getTableType() ==
HoodieTableType.COPY_ON_WRITE) {
+ List<HoodieBaseFile> baseFiles =
+ fileSystemView
+ .getLatestBaseFilesBeforeOrOn(partitionPath,
snapshotTime)
+ .collect(Collectors.toList());
+ for (HoodieBaseFile baseFile : baseFiles) {
+ splits.add(
+ toHudiSplit(
+ hudiTableInfo,
+ partitionPath,
+ toFileSlice(partitionPath, baseFile)));
+ }
+ return splits;
+ }
+ throw new IOException(
+ String.format(
+ "Unsupported Hudi table type %s for table %s.",
+ hudiTableInfo.getTableType(), tablePath));
+ }
+
+ private FileSlice toFileSlice(String partitionPath, HoodieBaseFile
baseFile) {
+ return new FileSlice(
+ new HoodieFileGroupId(partitionPath, baseFile.getFileId()),
+ baseFile.getCommitTime(),
+ baseFile,
+ Collections.emptyList());
+ }
+
+ private HudiSplit toHudiSplit(
+ HudiTableInfo hudiTableInfo, String partitionPath, FileSlice
fileSlice)
+ throws IOException {
+ return new HudiSplit(
+ fileSlice,
+ extractBucket(hudiTableInfo, fileSlice),
+ hudiTableInfo.partitionValues(partitionPath));
+ }
+
+ private int extractBucket(HudiTableInfo hudiTableInfo, FileSlice
fileSlice) throws IOException {
+ if (!hudiTableInfo.isBucketAware()) {
+ return -1;
+ }
+ String fileId = fileSlice.getFileGroupId().getFileId();
+ if (fileId == null || fileId.isEmpty()) {
+ throw new IOException(
+ String.format(
+ "Failed to extract Hudi bucket id for bucket-aware
table %s because file id is empty.",
+ tablePath));
+ }
+ try {
+ return BucketIdentifier.bucketIdFromFileId(fileId);
+ } catch (RuntimeException e) {
+ throw new IOException(
+ String.format(
+ "Failed to extract Hudi bucket id from file id
'%s' for bucket-aware table %s.",
+ fileId, tablePath),
+ e);
+ }
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplitSerializer.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplitSerializer.java
new file mode 100644
index 000000000..d9578bc8c
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/source/HudiSplitSerializer.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.source;
+
+import org.apache.fluss.lake.serializer.SimpleVersionedSerializer;
+import org.apache.fluss.utils.InstantiationUtils;
+
+import java.io.IOException;
+
+/** Serializer for {@link HudiSplit}. */
+public class HudiSplitSerializer implements
SimpleVersionedSerializer<HudiSplit> {
+
+ private static final int CURRENT_VERSION = 1;
+
+ @Override
+ public int getVersion() {
+ return CURRENT_VERSION;
+ }
+
+ @Override
+ public byte[] serialize(HudiSplit hudiSplit) throws IOException {
+ return InstantiationUtils.serializeObject(hudiSplit);
+ }
+
+ @Override
+ public HudiSplit deserialize(int version, byte[] serialized) throws
IOException {
+ if (version != CURRENT_VERSION) {
+ throw new IOException("Unsupported HudiSplit serialization
version: " + version);
+ }
+ try {
+ Object deserialized =
+ InstantiationUtils.deserializeObject(serialized,
getClass().getClassLoader());
+ if (!(deserialized instanceof HudiSplit)) {
+ throw new IOException(
+ "Expected HudiSplit but found "
+ + (deserialized == null
+ ? "null"
+ : deserialized.getClass().getName())
+ + " during deserialization.");
+ }
+ return (HudiSplit) deserialized;
+ } catch (ClassNotFoundException e) {
+ throw new IOException("Failed to deserialize HudiSplit.", e);
+ }
+ }
+}
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 ae98201d0..2f90124e5 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
@@ -66,6 +66,10 @@ public class HudiConversions {
private static final String HUDI_RECORD_KEY_FIELD_OPTION =
HUDI_CONF_PREFIX + FlinkOptions.RECORD_KEY_FIELD.key();
+ public static final String FLUSS_BUCKET_KEYS_OPTION = "fluss.bucket.keys";
+ public static final String FLUSS_BUCKET_AWARE_OPTION =
"fluss.bucket-aware";
+ public static final String FLUSS_PARTITION_KEYS_OPTION =
"fluss.partition.keys";
+
/** Hudi config options set by Fluss should not be set by users. */
@VisibleForTesting public static final Set<String> HUDI_UNSETTABLE_OPTIONS
= new HashSet<>();
@@ -181,7 +185,7 @@ public class HudiConversions {
recordKeyField); // use primary key as index key
}
- // buket keys column
+ // bucket keys column
hudiProperties.put(FlinkOptions.INDEX_TYPE.key(),
HoodieIndex.IndexType.BUCKET.name());
List<String> bucketKeys = tableDescriptor.getBucketKeys();
int numBuckets =
@@ -210,6 +214,11 @@ public class HudiConversions {
.getCustomProperties()
.forEach((k, v) -> setFlussPropertyToHudi(k, v,
hudiProperties));
+ hudiProperties.put(FLUSS_BUCKET_KEYS_OPTION, String.join(DELIMITER,
bucketKeys));
+ hudiProperties.put(
+ FLUSS_BUCKET_AWARE_OPTION, String.valueOf(isPkTable ||
!bucketKeys.isEmpty()));
+ hudiProperties.put(FLUSS_PARTITION_KEYS_OPTION, String.join(DELIMITER,
partitionKeys));
+
return hudiProperties;
}
diff --git
a/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiTableInfo.java
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiTableInfo.java
new file mode 100644
index 000000000..cb819fab2
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/main/java/org/apache/fluss/lake/hudi/utils/HudiTableInfo.java
@@ -0,0 +1,361 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.lake.hudi.utils.catalog.HudiCatalogUtils;
+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.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.engine.HoodieLocalEngineContext;
+import org.apache.hudi.common.model.HoodieTableType;
+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.configuration.FlinkOptions;
+import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.exception.TableNotFoundException;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+import org.apache.hudi.table.catalog.CatalogOptions;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_BUCKET_AWARE_OPTION;
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_BUCKET_KEYS_OPTION;
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_PARTITION_KEYS_OPTION;
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.toHudiObjectPath;
+
+/** Resolved Hudi table metadata used by lake source planning. */
+public class HudiTableInfo implements AutoCloseable {
+
+ private static final String DEFAULT_HUDI_WAREHOUSE = "/tmp/hudi_warehouse";
+ private static final String DELIMITER = ",";
+
+ private final TablePath tablePath;
+ private final Catalog hudiCatalog;
+ private final CatalogBaseTable hudiTable;
+ private final Map<String, String> tableOptions;
+ private final HoodieTableMetaClient metaClient;
+ private final HoodieEngineContext engineContext;
+ private final HoodieTimeline completedTimeline;
+ private final HoodieTableFileSystemView fileSystemView;
+ private final HoodieTableType tableType;
+ private final String basePath;
+ private final List<String> partitionFields;
+ private final boolean bucketAware;
+ private boolean closed;
+
+ private HudiTableInfo(
+ TablePath tablePath,
+ Catalog hudiCatalog,
+ CatalogBaseTable hudiTable,
+ Map<String, String> tableOptions,
+ HoodieTableMetaClient metaClient,
+ HoodieEngineContext engineContext,
+ HoodieTimeline completedTimeline,
+ HoodieTableFileSystemView fileSystemView,
+ HoodieTableType tableType,
+ String basePath,
+ List<String> partitionFields,
+ boolean bucketAware) {
+ this.tablePath = tablePath;
+ this.hudiCatalog = hudiCatalog;
+ this.hudiTable = hudiTable;
+ this.tableOptions = tableOptions;
+ this.metaClient = metaClient;
+ this.engineContext = engineContext;
+ this.completedTimeline = completedTimeline;
+ this.fileSystemView = fileSystemView;
+ this.tableType = tableType;
+ this.basePath = basePath;
+ this.partitionFields = partitionFields;
+ this.bucketAware = bucketAware;
+ }
+
+ public static HudiTableInfo create(TablePath tablePath, Configuration
hudiConfig)
+ throws IOException {
+ Catalog hudiCatalog = HudiCatalogUtils.createHudiCatalog(hudiConfig);
+ hudiCatalog.open();
+ try {
+ CatalogBaseTable hudiTable =
hudiCatalog.getTable(toHudiObjectPath(tablePath));
+ Map<String, String> tableOptions = new
HashMap<>(hudiTable.getOptions());
+ tableOptions.putAll(hudiConfig.toMap());
+
+ String basePath = resolveBasePath(tablePath, tableOptions);
+ tableOptions.put(FlinkOptions.PATH.key(), basePath);
+
+ HoodieTableFileSystemView fileSystemView = null;
+ HoodieTableMetaClient metaClient = createMetaClient(basePath,
hudiConfig);
+ HoodieTimeline completedTimeline =
+ metaClient.getCommitsTimeline().filterCompletedInstants();
+ HoodieEngineContext engineContext =
+ new HoodieLocalEngineContext(metaClient.getStorageConf());
+ try {
+ HoodieTimeline fileSystemViewTimeline =
+ metaClient
+ .getCommitsAndCompactionTimeline()
+ .filterCompletedAndCompactionInstants();
+ fileSystemView =
+
HoodieTableFileSystemView.fileListingBasedFileSystemView(
+ engineContext, metaClient,
fileSystemViewTimeline);
+ HoodieTableType tableType = metaClient.getTableType();
+ List<String> partitionFields =
+ splitCommaSeparated(
+ tableOptions.getOrDefault(
+ FLUSS_PARTITION_KEYS_OPTION,
+
tableOptions.get(FlinkOptions.PARTITION_PATH_FIELD.key())));
+ boolean bucketAware = resolveBucketAware(tableOptions,
tableType);
+
+ return new HudiTableInfo(
+ tablePath,
+ hudiCatalog,
+ hudiTable,
+ Collections.unmodifiableMap(new
HashMap<>(tableOptions)),
+ metaClient,
+ engineContext,
+ completedTimeline,
+ fileSystemView,
+ tableType,
+ basePath,
+ Collections.unmodifiableList(new
ArrayList<>(partitionFields)),
+ bucketAware);
+ } catch (Exception e) {
+ closeFileSystemView(fileSystemView);
+ throw e;
+ }
+ } catch (Exception e) {
+ closeCatalog(hudiCatalog);
+ if (e instanceof IOException) {
+ throw (IOException) e;
+ }
+ throw new IOException("Failed to resolve Hudi table info for " +
tablePath + ".", e);
+ }
+ }
+
+ 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();
+ }
+
+ @VisibleForTesting
+ public static boolean resolveBucketAware(
+ Map<String, String> tableOptions, HoodieTableType tableType) {
+ String bucketAware = tableOptions.get(FLUSS_BUCKET_AWARE_OPTION);
+ if (bucketAware != null) {
+ return Boolean.parseBoolean(bucketAware.trim());
+ }
+ String bucketKeys = tableOptions.get(FLUSS_BUCKET_KEYS_OPTION);
+ if (bucketKeys != null) {
+ return !bucketKeys.trim().isEmpty();
+ }
+ return tableType == HoodieTableType.MERGE_ON_READ;
+ }
+
+ @VisibleForTesting
+ public static List<String> extractPartitionValues(
+ String partitionPath, List<String> partitionFields) {
+ if (partitionFields.isEmpty() || partitionPath == null ||
partitionPath.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ String[] pathSegments = partitionPath.split("/");
+ if (pathSegments.length != partitionFields.size()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Hudi partition path '%s' does not match partition
fields %s.",
+ partitionPath, partitionFields));
+ }
+
+ boolean hiveStyle = false;
+ boolean rawStyle = false;
+ Map<String, String> hivePartitionValues = new HashMap<>();
+ List<String> rawPartitionValues = new ArrayList<>(pathSegments.length);
+ for (String pathSegment : pathSegments) {
+ int valueStart = pathSegment.indexOf('=');
+ if (valueStart > 0) {
+ hiveStyle = true;
+ String field = pathSegment.substring(0, valueStart);
+ if (!partitionFields.contains(field)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Hudi partition path '%s' contains unknown
partition field '%s'.",
+ partitionPath, field));
+ }
+ if (hivePartitionValues.put(field,
pathSegment.substring(valueStart + 1)) != null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Hudi partition path '%s' contains
duplicate partition field '%s'.",
+ partitionPath, field));
+ }
+ } else {
+ rawStyle = true;
+ rawPartitionValues.add(pathSegment);
+ }
+ }
+
+ if (hiveStyle && rawStyle) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Hudi partition path '%s' mixes hive-style and raw
partition segments.",
+ partitionPath));
+ }
+
+ if (!hiveStyle) {
+ return rawPartitionValues;
+ }
+
+ List<String> partitionValues = new ArrayList<>(partitionFields.size());
+ for (String partitionField : partitionFields) {
+ String partitionValue = hivePartitionValues.get(partitionField);
+ if (partitionValue == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Hudi partition path '%s' does not contain
partition field '%s'.",
+ partitionPath, partitionField));
+ }
+ partitionValues.add(partitionValue);
+ }
+ return partitionValues;
+ }
+
+ private static List<String> splitCommaSeparated(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+ String[] parts = value.split(DELIMITER);
+ List<String> values = new ArrayList<>(parts.length);
+ for (String part : parts) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ values.add(trimmed);
+ }
+ }
+ return values;
+ }
+
+ private static String ensureEndsWithSlash(String path) {
+ return path.endsWith("/") ? path : path + "/";
+ }
+
+ private static void closeCatalog(Catalog hudiCatalog) {
+ if (hudiCatalog instanceof AutoCloseable) {
+ IOUtils.closeQuietly((AutoCloseable) hudiCatalog, "hudi catalog");
+ }
+ }
+
+ private static void closeFileSystemView(HoodieTableFileSystemView
fileSystemView) {
+ if (fileSystemView != null) {
+ IOUtils.closeQuietly(fileSystemView::close, "hudi table file
system view");
+ }
+ }
+
+ public TablePath getTablePath() {
+ return tablePath;
+ }
+
+ public CatalogBaseTable getHudiTable() {
+ return hudiTable;
+ }
+
+ public Map<String, String> getTableOptions() {
+ return tableOptions;
+ }
+
+ public HoodieTableMetaClient getMetaClient() {
+ return metaClient;
+ }
+
+ public HoodieEngineContext getEngineContext() {
+ return engineContext;
+ }
+
+ public HoodieTimeline getCompletedTimeline() {
+ return completedTimeline;
+ }
+
+ public HoodieTableFileSystemView getFileSystemView() {
+ return fileSystemView;
+ }
+
+ public HoodieTableType getTableType() {
+ return tableType;
+ }
+
+ public String getBasePath() {
+ return basePath;
+ }
+
+ public boolean isBucketAware() {
+ return bucketAware;
+ }
+
+ public boolean isPartitioned() {
+ return !partitionFields.isEmpty();
+ }
+
+ public List<String> partitionValues(String partitionPath) {
+ return extractPartitionValues(partitionPath, partitionFields);
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ closeFileSystemView(fileSystemView);
+ closeCatalog(hudiCatalog);
+ }
+}
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
new file mode 100644
index 000000000..1b0e2fe89
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiLakeSourceTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.source.LakeSource;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.predicate.Predicate;
+import org.apache.fluss.predicate.PredicateBuilder;
+import org.apache.fluss.types.IntType;
+import org.apache.fluss.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link HudiLakeSource}. */
+class HudiLakeSourceTest {
+
+ @Test
+ void testCreatePlannerReturnsHudiSplitPlanner() throws Exception {
+ HudiLakeSource source =
+ new HudiLakeSource(new Configuration(), TablePath.of("db1",
"table1"));
+
+ assertThat(source.createPlanner(() ->
1L)).isInstanceOf(HudiSplitPlanner.class);
+ }
+
+ @Test
+ void testGetSplitSerializerReturnsHudiSplitSerializer() {
+ HudiLakeSource source =
+ new HudiLakeSource(new Configuration(), TablePath.of("db1",
"table1"));
+
+
assertThat(source.getSplitSerializer()).isInstanceOf(HudiSplitSerializer.class);
+ }
+
+ @Test
+ void testFiltersRemainWhenPushDownIsNotSupportedYet() {
+ HudiLakeSource source =
+ new HudiLakeSource(new Configuration(), TablePath.of("db1",
"table1"));
+ Predicate predicate = new PredicateBuilder(RowType.of(new
IntType())).equal(0, 1);
+
+ LakeSource.FilterPushDownResult result =
+ source.withFilters(Collections.singletonList(predicate));
+
+ assertThat(result.acceptedPredicates()).isEmpty();
+ assertThat(result.remainingPredicates()).containsExactly(predicate);
+ }
+
+ @Test
+ void testWithLimitIsExplicitlyUnsupported() {
+ HudiLakeSource source =
+ new HudiLakeSource(new Configuration(), TablePath.of("db1",
"table1"));
+
+ assertThatThrownBy(() -> source.withLimit(1))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("limit");
+ }
+
+ @Test
+ void testCreateRecordReaderIsExplicitlyUnsupported() {
+ HudiLakeSource source =
+ new HudiLakeSource(new Configuration(), TablePath.of("db1",
"table1"));
+
+ assertThatThrownBy(() -> source.createRecordReader(() -> null))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("record reading");
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitPlannerTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitPlannerTest.java
new file mode 100644
index 000000000..d3293dc0c
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitPlannerTest.java
@@ -0,0 +1,304 @@
+/*
+ * 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.HudiLakeCatalog;
+import org.apache.fluss.lake.hudi.utils.HudiTableInfo;
+import org.apache.fluss.lake.lakestorage.TestingLakeCatalogContext;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.types.DataTypes;
+
+import org.apache.hudi.common.fs.FSUtils;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieFileFormat;
+import org.apache.hudi.common.model.HoodieWriteStat;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.index.bucket.BucketIdentifier;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link HudiSplitPlanner}. */
+class HudiSplitPlannerTest {
+
+ private static final String INSTANT_TIME = "20260608010101000";
+ private static final String LEADING_ZERO_INSTANT_TIME =
"02026060801010100";
+ private static final String DATABASE = "db1";
+ private static final String TABLE = "table1";
+
+ @TempDir private File tempWarehouseDir;
+
+ private Configuration hudiConfig;
+
+ @BeforeEach
+ void setUp() {
+ hudiConfig = new Configuration();
+ hudiConfig.setString("catalog.path",
tempWarehouseDir.toURI().toString());
+ hudiConfig.setString("mode", "dfs");
+ }
+
+ @Test
+ void testPlanCopyOnWriteSplitsForCompletedInstant() throws Exception {
+ TablePath tablePath = TablePath.of(DATABASE, TABLE);
+ createCowTable(tablePath);
+ String partitionPath = "";
+ int bucket = 1;
+ String fileId = BucketIdentifier.newBucketFileIdPrefix(bucket);
+ String fileName = createBaseFile(tablePath, partitionPath,
INSTANT_TIME, fileId);
+ createCompletedCommit(tablePath, partitionPath, INSTANT_TIME, fileId,
fileName);
+ assertHudiFixtureVisible(tablePath, partitionPath);
+
+ List<HudiSplit> splits =
+ new HudiSplitPlanner(hudiConfig, tablePath,
Long.parseLong(INSTANT_TIME)).plan();
+
+ assertThat(splits).hasSize(1);
+ HudiSplit split = splits.get(0);
+ assertThat(split.bucket()).isEqualTo(bucket);
+ assertThat(split.partition()).isEmpty();
+
assertThat(split.getFileSlice().getPartitionPath()).isEqualTo(partitionPath);
+ assertThat(split.getFileSlice().getFileId()).isEqualTo(fileId);
+ assertThat(split.getFileSlice().getBaseFile().isPresent()).isTrue();
+ }
+
+ @Test
+ void testPlanFailsWhenInstantDoesNotExist() throws Exception {
+ TablePath tablePath = TablePath.of(DATABASE, TABLE);
+ createCowTable(tablePath);
+
+ assertThatThrownBy(
+ () ->
+ new HudiSplitPlanner(
+ hudiConfig, tablePath,
Long.parseLong(INSTANT_TIME))
+ .plan())
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("does not exist");
+ }
+
+ @Test
+ void testPlanLeadingZeroInstantTime() throws Exception {
+ TablePath tablePath = TablePath.of(DATABASE, TABLE);
+ createCowTable(tablePath);
+ String partitionPath = "";
+ int bucket = 1;
+ String fileId = BucketIdentifier.newBucketFileIdPrefix(bucket);
+ String fileName =
+ createBaseFile(tablePath, partitionPath,
LEADING_ZERO_INSTANT_TIME, fileId);
+ createCompletedCommit(
+ tablePath, partitionPath, LEADING_ZERO_INSTANT_TIME, fileId,
fileName);
+
+ List<HudiSplit> splits =
+ new HudiSplitPlanner(
+ hudiConfig, tablePath,
Long.parseLong(LEADING_ZERO_INSTANT_TIME))
+ .plan();
+
+ assertThat(splits).hasSize(1);
+ assertThat(splits.get(0).getFileSlice().getBaseInstantTime())
+ .isEqualTo(LEADING_ZERO_INSTANT_TIME);
+ }
+
+ @Test
+ void
testPlanPartitionedTableWithoutDiscoveredPartitionsReturnsEmptySplits() throws
Exception {
+ TablePath tablePath = TablePath.of(DATABASE, TABLE);
+ createPartitionedCowTable(tablePath);
+ createCompletedCommit(tablePath, INSTANT_TIME);
+
+ List<HudiSplit> splits =
+ new HudiSplitPlanner(hudiConfig, tablePath,
Long.parseLong(INSTANT_TIME)).plan();
+
+ assertThat(splits).isEmpty();
+ }
+
+ @Test
+ void testPlanBucketUnawareCopyOnWriteSplitsAsBucketMinusOne() throws
Exception {
+ TablePath tablePath = TablePath.of(DATABASE, TABLE);
+ createCowTable(tablePath, false);
+ String partitionPath = "";
+ String fileId = "plainfileid";
+ String fileName = createBaseFile(tablePath, partitionPath,
INSTANT_TIME, fileId);
+ createCompletedCommit(tablePath, partitionPath, INSTANT_TIME, fileId,
fileName);
+ assertHudiFixtureVisible(tablePath, partitionPath);
+
+ List<HudiSplit> splits =
+ new HudiSplitPlanner(hudiConfig, tablePath,
Long.parseLong(INSTANT_TIME)).plan();
+
+ assertThat(splits).hasSize(1);
+ assertThat(splits.get(0).bucket()).isEqualTo(-1);
+ }
+
+ @Test
+ void testPlanFailsForBucketAwareTableWithUnparsableFileId() throws
Exception {
+ TablePath tablePath = TablePath.of(DATABASE, TABLE);
+ createCowTable(tablePath);
+ String partitionPath = "";
+ String fileId = "plainfileid";
+ String fileName = createBaseFile(tablePath, partitionPath,
INSTANT_TIME, fileId);
+ createCompletedCommit(tablePath, partitionPath, INSTANT_TIME, fileId,
fileName);
+ assertHudiFixtureVisible(tablePath, partitionPath);
+
+ assertThatThrownBy(
+ () ->
+ new HudiSplitPlanner(
+ hudiConfig, tablePath,
Long.parseLong(INSTANT_TIME))
+ .plan())
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Failed to extract Hudi bucket id")
+ .hasMessageContaining(fileId);
+ }
+
+ private void createCowTable(TablePath tablePath) throws Exception {
+ createCowTable(tablePath, true);
+ }
+
+ private void createCowTable(TablePath tablePath, boolean bucketAware)
throws Exception {
+ Schema schema = Schema.newBuilder().column("id",
DataTypes.BIGINT()).build();
+ TableDescriptor.Builder tableDescriptorBuilder =
+ TableDescriptor.builder()
+ .schema(schema)
+
.property("hudi.hoodie.datasource.write.recordkey.field", "id");
+ if (bucketAware) {
+ tableDescriptorBuilder.distributedBy(4, "id");
+ } else {
+ tableDescriptorBuilder.distributedBy(4);
+ }
+ TableDescriptor tableDescriptor = tableDescriptorBuilder.build();
+ try (HudiLakeCatalog catalog = new HudiLakeCatalog(hudiConfig)) {
+ catalog.createTable(tablePath, tableDescriptor, new
TestingLakeCatalogContext());
+ }
+ }
+
+ private void createPartitionedCowTable(TablePath tablePath) throws
Exception {
+ Schema schema =
+ Schema.newBuilder()
+ .column("id", DataTypes.BIGINT())
+ .column("dt", DataTypes.STRING())
+ .build();
+ TableDescriptor tableDescriptor =
+ TableDescriptor.builder()
+ .schema(schema)
+ .partitionedBy("dt")
+ .distributedBy(4, "id")
+
.property("hudi.hoodie.datasource.write.recordkey.field", "id")
+ .build();
+ try (HudiLakeCatalog catalog = new HudiLakeCatalog(hudiConfig)) {
+ catalog.createTable(tablePath, tableDescriptor, new
TestingLakeCatalogContext());
+ }
+ }
+
+ private String createBaseFile(
+ TablePath tablePath, String partitionPath, String instantTime,
String fileId)
+ throws IOException {
+ File partitionDir =
+ partitionPath.isEmpty()
+ ? basePath(tablePath)
+ : new File(basePath(tablePath), partitionPath);
+ Files.createDirectories(partitionDir.toPath());
+ String fileName =
+ FSUtils.makeBaseFileName(
+ instantTime,
+ FSUtils.makeWriteToken(0, 0, 1),
+ fileId,
+ HoodieFileFormat.PARQUET.getFileExtension());
+ Files.write(new File(partitionDir, fileName).toPath(), new byte[] {1});
+ return fileName;
+ }
+
+ private void createCompletedCommit(
+ TablePath tablePath,
+ String partitionPath,
+ String instantTime,
+ String fileId,
+ String fileName) {
+ HoodieCommitMetadata commitMetadata = new HoodieCommitMetadata();
+ HoodieWriteStat writeStat = new HoodieWriteStat();
+ writeStat.setPartitionPath(partitionPath);
+ writeStat.setFileId(fileId);
+ writeStat.setPath(partitionPath.isEmpty() ? fileName : partitionPath +
"/" + fileName);
+ writeStat.setTotalWriteBytes(1);
+ writeStat.setFileSizeInBytes(1);
+ writeStat.setNumWrites(1);
+ commitMetadata.addWriteStat(partitionPath, writeStat);
+
+ createCompletedCommit(tablePath, instantTime, commitMetadata);
+ }
+
+ private void createCompletedCommit(TablePath tablePath, String
instantTime) {
+ createCompletedCommit(tablePath, instantTime, new
HoodieCommitMetadata());
+ }
+
+ private void createCompletedCommit(
+ TablePath tablePath, String instantTime, HoodieCommitMetadata
commitMetadata) {
+ HoodieTableMetaClient metaClient =
+ HoodieTableMetaClient.builder()
+ .setBasePath(basePath(tablePath).getAbsolutePath())
+ .setConf(
+ new HadoopStorageConfiguration(
+
HudiTableInfo.getHadoopConfiguration(hudiConfig)))
+ .build();
+ HoodieInstant inflightInstant =
+ metaClient.createNewInstant(
+ HoodieInstant.State.INFLIGHT,
HoodieTimeline.COMMIT_ACTION, instantTime);
+ metaClient.getActiveTimeline().createNewInstant(inflightInstant);
+
+ HoodieInstant instant =
+ metaClient
+ .getActiveTimeline()
+ .saveAsComplete(inflightInstant,
Option.of(commitMetadata));
+ assertThat(instant.isCompleted()).isTrue();
+ metaClient.reloadActiveTimeline();
+ }
+
+ private void assertHudiFixtureVisible(TablePath tablePath, String
partitionPath)
+ throws IOException {
+ try (HudiTableInfo hudiTableInfo = HudiTableInfo.create(tablePath,
hudiConfig)) {
+ assertThat(
+ FSUtils.getAllPartitionPaths(
+ hudiTableInfo.getEngineContext(),
+ hudiTableInfo.getMetaClient(),
+ false))
+ .isEmpty();
+ List<HoodieBaseFile> baseFiles =
+ hudiTableInfo
+ .getFileSystemView()
+ .getLatestBaseFilesBeforeOrOn(partitionPath,
INSTANT_TIME)
+ .collect(Collectors.toList());
+ assertThat(baseFiles).hasSize(1);
+ }
+ }
+
+ private File basePath(TablePath tablePath) {
+ return new File(
+ new File(tempWarehouseDir, tablePath.getDatabaseName()),
tablePath.getTableName());
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitSerializerTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitSerializerTest.java
new file mode 100644
index 000000000..16a768cc8
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitSerializerTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.utils.InstantiationUtils;
+
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+import org.junit.jupiter.api.Test;
+
+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 HudiSplitSerializer}. */
+class HudiSplitSerializerTest {
+
+ @Test
+ void testSerializeAndDeserialize() throws Exception {
+ HudiSplitSerializer serializer = new HudiSplitSerializer();
+ HudiSplit split =
+ new HudiSplit(
+ new FileSlice(
+ new HoodieFileGroupId(
+ "dt=20260608",
"00000000-0000-0000-0000-000000000001"),
+ "20260608010101000"),
+ 1,
+ Collections.singletonList("20260608"));
+
+ HudiSplit deserialized =
+ serializer.deserialize(serializer.getVersion(),
serializer.serialize(split));
+
+ assertThat(deserialized).isEqualTo(split);
+ assertThat(deserialized.getFileSlice().getFileGroupId())
+ .isEqualTo(split.getFileSlice().getFileGroupId());
+ }
+
+ @Test
+ void testDeserializeRejectsUnknownVersion() {
+ HudiSplitSerializer serializer = new HudiSplitSerializer();
+
+ assertThatThrownBy(() -> serializer.deserialize(2, new byte[0]))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Unsupported HudiSplit serialization
version");
+ }
+
+ @Test
+ void testDeserializeRejectsUnexpectedObjectType() throws Exception {
+ HudiSplitSerializer serializer = new HudiSplitSerializer();
+ byte[] serialized = InstantiationUtils.serializeObject("not a hudi
split");
+
+ assertThatThrownBy(() ->
serializer.deserialize(serializer.getVersion(), serialized))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Expected HudiSplit");
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitTest.java
new file mode 100644
index 000000000..ae611754e
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/source/HudiSplitTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.hudi.common.fs.FSUtils;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+import org.apache.hudi.common.model.HoodieLogFile;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link HudiSplit}. */
+class HudiSplitTest {
+
+ private static final String PARTITION_PATH = "dt=20260608";
+ private static final String FILE_ID =
"00000000-0000-0000-0000-000000000001";
+ private static final String INSTANT_TIME = "20260608010101000";
+
+ @Test
+ void testEqualsUsesStableFileSliceFields() {
+ HudiSplit first = new HudiSplit(fileSlice("/tmp/base-1.parquet", 1),
1, partition());
+ HudiSplit same = new HudiSplit(fileSlice("/tmp/base-1.parquet", 1), 1,
partition());
+ HudiSplit differentBaseFile =
+ new HudiSplit(fileSlice("/tmp/base-2.parquet", 1), 1,
partition());
+ HudiSplit differentLogFile =
+ new HudiSplit(fileSlice("/tmp/base-1.parquet", 2), 1,
partition());
+
+ assertThat(first).isEqualTo(same);
+ assertThat(first).hasSameHashCodeAs(same);
+ assertThat(first).isNotEqualTo(differentBaseFile);
+ assertThat(first).isNotEqualTo(differentLogFile);
+ }
+
+ private static FileSlice fileSlice(String baseFilePath, int logVersion) {
+ HoodieFileGroupId fileGroupId = new HoodieFileGroupId(PARTITION_PATH,
FILE_ID);
+ FileSlice fileSlice =
+ new FileSlice(
+ fileGroupId,
+ INSTANT_TIME,
+ new HoodieBaseFile(baseFilePath),
+ Collections.<HoodieLogFile>emptyList());
+ fileSlice.addLogFile(
+ new HoodieLogFile(
+ "/tmp/"
+ + FSUtils.makeLogFileName(
+ FILE_ID,
+ HoodieLogFile.DELTA_EXTENSION,
+ INSTANT_TIME,
+ logVersion,
+ FSUtils.makeWriteToken(0, 0, 1))));
+ return fileSlice;
+ }
+
+ private static List<String> partition() {
+ return Collections.singletonList("20260608");
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/HudiConversionsTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/HudiConversionsTest.java
new file mode 100644
index 000000000..07421c279
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/HudiConversionsTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils;
+
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_BUCKET_AWARE_OPTION;
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_BUCKET_KEYS_OPTION;
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_PARTITION_KEYS_OPTION;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link HudiConversions}. */
+class HudiConversionsTest {
+
+ @Test
+ void testBuildHudiTablePropertiesCarriesFlussBucketMetadataForPkTable() {
+ Schema schema =
+ Schema.newBuilder().column("id",
DataTypes.BIGINT()).primaryKey("id").build();
+ TableDescriptor tableDescriptor =
+ TableDescriptor.builder().schema(schema).distributedBy(3,
"id").build();
+
+ Map<String, String> properties =
+ HudiConversions.buildHudiTableProperties(
+ TablePath.of("db1", "table1"), tableDescriptor, true);
+
+ assertThat(properties).containsEntry(FLUSS_BUCKET_AWARE_OPTION,
"true");
+ assertThat(properties).containsEntry(FLUSS_BUCKET_KEYS_OPTION, "id");
+ assertThat(properties).containsEntry(FLUSS_PARTITION_KEYS_OPTION, "");
+ }
+
+ @Test
+ void
testBuildHudiTablePropertiesCarriesFlussBucketMetadataForBucketUnawareLogTable()
{
+ Schema schema = Schema.newBuilder().column("id",
DataTypes.BIGINT()).build();
+ TableDescriptor tableDescriptor =
+ TableDescriptor.builder()
+ .schema(schema)
+ .distributedBy(3)
+
.property("hudi.hoodie.datasource.write.recordkey.field", "id")
+ .build();
+
+ Map<String, String> properties =
+ HudiConversions.buildHudiTableProperties(
+ TablePath.of("db1", "table1"), tableDescriptor, false);
+
+ assertThat(properties).containsEntry(FLUSS_BUCKET_AWARE_OPTION,
"false");
+ assertThat(properties).containsEntry(FLUSS_BUCKET_KEYS_OPTION, "");
+ assertThat(properties).containsEntry(FLUSS_PARTITION_KEYS_OPTION, "");
+ }
+
+ @Test
+ void testBuildHudiTablePropertiesCarriesFlussPartitionMetadata() {
+ Schema schema =
+ Schema.newBuilder()
+ .column("id", DataTypes.BIGINT())
+ .column("dt", DataTypes.STRING())
+ .column("hr", DataTypes.STRING())
+ .build();
+ TableDescriptor tableDescriptor =
+ TableDescriptor.builder()
+ .schema(schema)
+ .distributedBy(3, "id")
+ .partitionedBy("dt", "hr")
+
.property("hudi.hoodie.datasource.write.recordkey.field", "id")
+ .build();
+
+ Map<String, String> properties =
+ HudiConversions.buildHudiTableProperties(
+ TablePath.of("db1", "table1"), tableDescriptor, false);
+
+ assertThat(properties).containsEntry(FLUSS_PARTITION_KEYS_OPTION,
"dt,hr");
+ }
+}
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/HudiTableInfoTest.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/HudiTableInfoTest.java
new file mode 100644
index 000000000..bab3a6c57
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/utils/HudiTableInfoTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.fluss.lake.hudi.utils;
+
+import org.apache.hudi.common.model.HoodieTableType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_BUCKET_AWARE_OPTION;
+import static
org.apache.fluss.lake.hudi.utils.HudiConversions.FLUSS_BUCKET_KEYS_OPTION;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link HudiTableInfo}. */
+class HudiTableInfoTest {
+
+ @Test
+ void testExtractPartitionValues() {
+ assertThat(
+ HudiTableInfo.extractPartitionValues(
+ "dt=20260608/hr=10", Arrays.asList("dt",
"hr")))
+ .containsExactly("20260608", "10");
+ assertThat(
+ HudiTableInfo.extractPartitionValues(
+ "hr=10/dt=20260608", Arrays.asList("dt",
"hr")))
+ .containsExactly("20260608", "10");
+ assertThat(HudiTableInfo.extractPartitionValues("20260608/10",
Arrays.asList("dt", "hr")))
+ .containsExactly("20260608", "10");
+ assertThat(HudiTableInfo.extractPartitionValues("",
Collections.singletonList("dt")))
+ .isEmpty();
+ }
+
+ @Test
+ void testExtractPartitionValuesRejectsMismatchedPartitionPath() {
+ assertThatThrownBy(
+ () ->
+ HudiTableInfo.extractPartitionValues(
+ "dt=20260608", Arrays.asList("dt",
"hr")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("does not match partition fields");
+ assertThatThrownBy(
+ () ->
+ HudiTableInfo.extractPartitionValues(
+ "dt=20260608/unknown=10",
Arrays.asList("dt", "hr")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unknown partition field");
+ assertThatThrownBy(
+ () ->
+ HudiTableInfo.extractPartitionValues(
+ "dt=20260608/dt=20260609",
Arrays.asList("dt", "hr")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("duplicate partition field");
+ assertThatThrownBy(
+ () ->
+ HudiTableInfo.extractPartitionValues(
+ "dt=20260608/10", Arrays.asList("dt",
"hr")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("mixes hive-style and raw partition
segments");
+ }
+
+ @Test
+ void testResolveBucketAwareUsesExplicitBucketAwareOption() {
+ Map<String, String> tableOptions = new HashMap<>();
+ tableOptions.put(FLUSS_BUCKET_AWARE_OPTION, "false");
+ tableOptions.put(FLUSS_BUCKET_KEYS_OPTION, "id");
+
+ assertThat(HudiTableInfo.resolveBucketAware(tableOptions,
HoodieTableType.MERGE_ON_READ))
+ .isFalse();
+ }
+
+ @Test
+ void testResolveBucketAwareUsesBucketKeysOption() {
+ Map<String, String> bucketAwareOptions = new HashMap<>();
+ bucketAwareOptions.put(FLUSS_BUCKET_KEYS_OPTION, "id");
+ assertThat(
+ HudiTableInfo.resolveBucketAware(
+ bucketAwareOptions,
HoodieTableType.COPY_ON_WRITE))
+ .isTrue();
+
+ Map<String, String> bucketUnawareOptions = new HashMap<>();
+ bucketUnawareOptions.put(FLUSS_BUCKET_KEYS_OPTION, "");
+ assertThat(
+ HudiTableInfo.resolveBucketAware(
+ bucketUnawareOptions,
HoodieTableType.MERGE_ON_READ))
+ .isFalse();
+ }
+
+ @Test
+ void testResolveBucketAwareDefaultsByTableTypeWhenMetadataIsMissing() {
+ assertThat(
+ HudiTableInfo.resolveBucketAware(
+ Collections.emptyMap(),
HoodieTableType.MERGE_ON_READ))
+ .isTrue();
+ assertThat(
+ HudiTableInfo.resolveBucketAware(
+ Collections.emptyMap(),
HoodieTableType.COPY_ON_WRITE))
+ .isFalse();
+ }
+}