This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new ac179e48a0c1 feat(spark): Support data skipping based on partitioned
RLI (#19013)
ac179e48a0c1 is described below
commit ac179e48a0c111532fe37682016f46132e96cc6d
Author: Shuo Cheng <[email protected]>
AuthorDate: Wed Jul 15 12:58:26 2026 +0800
feat(spark): Support data skipping based on partitioned RLI (#19013)
---
...titionedRecordIndexFileGroupLookupFunction.java | 79 ++++++++++++++
.../index/SparkMetadataTableRecordLevelIndex.java | 41 +------
.../hudi/GlobalRecordLevelIndexSupport.scala | 53 +++++++++
.../scala/org/apache/hudi/HoodieFileIndex.scala | 2 +-
.../hudi/PartitionedRecordLevelIndexSupport.scala | 120 +++++++++++++++++++++
.../org/apache/hudi/RecordLevelIndexSupport.scala | 116 +++++++++++++-------
.../org/apache/hudi/SecondaryIndexSupport.scala | 2 +-
.../hudi/functional/RecordLevelIndexTestBase.scala | 3 +-
.../TestGlobalRecordLevelIndexWithSQL.scala | 63 ++++++++---
.../functional/TestRecordLevelIndexWithSQL.scala | 84 +++++++++++++++
10 files changed, 470 insertions(+), 93 deletions(-)
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java
new file mode 100644
index 000000000000..c32dd7706e95
--- /dev/null
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java
@@ -0,0 +1,79 @@
+/*
+ * 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.hudi.index;
+
+import org.apache.hudi.common.data.HoodieListData;
+import org.apache.hudi.common.data.HoodiePairData;
+import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.collection.Pair;
+import org.apache.hudi.metadata.HoodieTableMetadata;
+
+import org.apache.spark.api.java.function.PairFlatMapFunction;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import scala.Tuple2;
+
+/**
+ * Looks up record-index keys that have already been grouped into one shard of
a partitioned record index.
+ */
+public class PartitionedRecordIndexFileGroupLookupFunction
+ implements PairFlatMapFunction<Iterator<Pair<String, String>>, String,
HoodieRecordGlobalLocation> {
+
+ private final HoodieTableMetadata metadataTable;
+
+ public PartitionedRecordIndexFileGroupLookupFunction(HoodieTableMetadata
metadataTable) {
+ this.metadataTable = metadataTable;
+ }
+
+ @Override
+ public Iterator<Tuple2<String, HoodieRecordGlobalLocation>>
call(Iterator<Pair<String, String>> partitionPathRecordKeyIterator) {
+ String partitionName = null;
+ List<String> keysToLookup = new ArrayList<>();
+ while (partitionPathRecordKeyIterator.hasNext()) {
+ Pair<String, String> partitionPathRecordKey =
partitionPathRecordKeyIterator.next();
+ keysToLookup.add(partitionPathRecordKey.getRight());
+ if (partitionName == null) {
+ partitionName = partitionPathRecordKey.getLeft();
+ }
+ }
+
+ if (keysToLookup.isEmpty()) {
+ return Collections.emptyIterator();
+ }
+
+ HoodiePairData<String, HoodieRecordGlobalLocation> recordIndexData =
+
metadataTable.readRecordIndexLocationsWithKeys(HoodieListData.eager(keysToLookup),
Option.of(partitionName));
+ try {
+ Map<String, HoodieRecordGlobalLocation> recordIndexInfo =
recordIndexData.collectAsList().stream()
+ .collect(HashMap::new, (map, pair) -> map.put(pair.getKey(),
pair.getValue()), HashMap::putAll);
+ return recordIndexInfo.entrySet().stream()
+ .map(e -> new Tuple2<>(e.getKey(), e.getValue())).iterator();
+ } finally {
+ recordIndexData.unpersistWithDependencies();
+ }
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
index e35e04d8f20a..6a12dc3c67a5 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
@@ -20,14 +20,12 @@
package org.apache.hudi.index;
import org.apache.hudi.common.data.HoodieData;
-import org.apache.hudi.common.data.HoodieListData;
import org.apache.hudi.common.data.HoodiePairData;
import org.apache.hudi.common.engine.HoodieEngineContext;
import org.apache.hudi.common.model.FileSlice;
import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
import org.apache.hudi.common.util.Either;
-import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.ValidationUtils;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.config.HoodieWriteConfig;
@@ -39,16 +37,11 @@ import org.apache.hudi.metadata.MetadataPartitionType;
import org.apache.hudi.table.HoodieTable;
import org.apache.spark.api.java.JavaRDD;
-import org.apache.spark.api.java.function.PairFlatMapFunction;
-import java.util.ArrayList;
import java.util.HashMap;
-import java.util.Iterator;
import java.util.List;
import java.util.Map;
-import scala.Tuple2;
-
/**
* Index to be used with RLI. Queries the record index for tables with
non-global record keys
*/
@@ -85,7 +78,7 @@ public class SparkMetadataTableRecordLevelIndex extends
SparkMetadataTableGlobal
.map(t -> t._2);
ValidationUtils.checkState(partitionedKeyRDD.getNumPartitions() <=
numFileGroups);
// Lookup the keys in the record index
- return HoodieJavaPairRDD.of(partitionedKeyRDD.mapPartitionsToPair(new
PartitionedRecordIndexFileGroupLookupFunction(hoodieTable)));
+ return HoodieJavaPairRDD.of(partitionedKeyRDD.mapPartitionsToPair(new
PartitionedRecordIndexFileGroupLookupFunction(hoodieTable.getTableMetadata())));
}
@Override
@@ -105,36 +98,4 @@ public class SparkMetadataTableRecordLevelIndex extends
SparkMetadataTableGlobal
protected boolean shouldUpdatePartitionPath(HoodieTable hoodieTable) {
return false;
}
-
- /**
- * Function that lookups a list of keys in a single shard of the record index
- */
- private static class PartitionedRecordIndexFileGroupLookupFunction
implements PairFlatMapFunction<Iterator<Pair<String,String>>, String,
HoodieRecordGlobalLocation> {
- private final HoodieTable hoodieTable;
-
- public PartitionedRecordIndexFileGroupLookupFunction(HoodieTable
hoodieTable) {
- this.hoodieTable = hoodieTable;
- }
-
- @Override
- public Iterator<Tuple2<String, HoodieRecordGlobalLocation>>
call(Iterator<Pair<String, String>> partitionPathRecordKeyIterator) throws
Exception {
- //Needs to be final, so we must use 1 element array to store the value
- final String[] partitionName = {null};
- List<String> keysToLookup = new ArrayList<>();
- partitionPathRecordKeyIterator.forEachRemaining(p -> {
- keysToLookup.add(p.getRight());
- if (partitionName[0] == null) {
- partitionName[0] = p.getLeft();
- }
- });
-
- // recordIndexInfo object only contains records that are present in
record_index.
- assert partitionName[0] != null || keysToLookup.isEmpty();
- Map<String, HoodieRecordGlobalLocation> recordIndexInfo =
-
hoodieTable.getTableMetadata().readRecordIndexLocationsWithKeys(HoodieListData.eager(keysToLookup),
Option.ofNullable(partitionName[0]))
- .collectAsList().stream().collect(HashMap::new, (map, pair) ->
map.put(pair.getKey(), pair.getValue()), HashMap::putAll);
- return recordIndexInfo.entrySet().stream()
- .map(e -> new Tuple2<>(e.getKey(), e.getValue())).iterator();
- }
- }
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/GlobalRecordLevelIndexSupport.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/GlobalRecordLevelIndexSupport.scala
new file mode 100644
index 000000000000..ca8832a64cfe
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/GlobalRecordLevelIndexSupport.scala
@@ -0,0 +1,53 @@
+/*
+ * 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.hudi
+
+import org.apache.hudi.RecordLevelIndexSupport.getPrunedStoragePaths
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.data.HoodieListData
+import org.apache.hudi.common.model.FileSlice
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.core.read.BaseHoodieTableFileIndex
+
+import org.apache.spark.sql.SparkSession
+
+import scala.collection.JavaConverters._
+
+/**
+ * Data skipping based on a global Record Level Index (RLI), where a single
set of file groups indexes
+ * the record keys across the whole table. All record keys are resolved with
one metadata table lookup.
+ */
+class GlobalRecordLevelIndexSupport(spark: SparkSession,
+ metadataConfig: HoodieMetadataConfig,
+ metaClient: HoodieTableMetaClient)
+ extends RecordLevelIndexSupport(spark, metadataConfig, metaClient) {
+
+ override protected def lookupCandidateFilesForRecordKeys(fileIndex:
HoodieFileIndex,
+
prunedPartitionsAndFileSlices:
Seq[(Option[BaseHoodieTableFileIndex.PartitionPath], Seq[FileSlice])],
+ recordKeys:
List[String]): Option[Set[String]] = {
+ val prunedStoragePaths =
getPrunedStoragePaths(prunedPartitionsAndFileSlices, fileIndex)
+ val recordIndexData =
metadataTable.readRecordIndexLocationsWithKeys(HoodieListData.eager(recordKeys.asJava))
+ try {
+ val fileIdToPartitionMap = collectFileIdToPartitionMap(recordIndexData)
+ Option.apply(filterCandidateFiles(prunedStoragePaths,
fileIdToPartitionMap))
+ } finally {
+ // Clean up the RDD to avoid memory leaks
+ recordIndexData.unpersistWithDependencies()
+ }
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieFileIndex.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieFileIndex.scala
index 078a317000b2..020fc3fc8c7b 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieFileIndex.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieFileIndex.scala
@@ -117,7 +117,7 @@ case class HoodieFileIndex(spark: SparkSession,
* during `lookupCandidateFilesInMetadataTable`
*/
@transient private lazy val indicesSupport: List[SparkBaseIndexSupport] =
List(
- new RecordLevelIndexSupport(spark, metadataConfig, metaClient),
+ RecordLevelIndexSupport.create(spark, metadataConfig, metaClient),
if
(PartitionBucketIndexUtils.isPartitionSimpleBucketIndex(metaClient.getStorageConf,
metaClient.getBasePath.toString)) {
new PartitionBucketIndexSupport(spark, metadataConfig, metaClient,
options.get(DataSourceReadOptions.TIME_TRAVEL_AS_OF_INSTANT.key).map(HoodieSqlCommonUtils.formatQueryInstant))
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionedRecordLevelIndexSupport.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionedRecordLevelIndexSupport.scala
new file mode 100644
index 000000000000..d2fe4589c095
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionedRecordLevelIndexSupport.scala
@@ -0,0 +1,120 @@
+/*
+ * 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.hudi
+
+import org.apache.hudi.RecordLevelIndexSupport.{getPrunedStoragePaths,
MAX_PARTITIONS}
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.model.FileSlice
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.common.util.ValidationUtils
+import org.apache.hudi.common.util.collection.Pair
+import org.apache.hudi.core.read.BaseHoodieTableFileIndex
+import org.apache.hudi.index.PartitionedRecordIndexFileGroupLookupFunction
+import org.apache.hudi.metadata.{BucketizedMetadataTableFileGroupIndexParser,
HoodieTableMetadataUtil, MetadataPartitionType}
+
+import org.apache.spark.Partitioner
+import org.apache.spark.internal.Logging
+import org.apache.spark.sql.SparkSession
+
+import scala.collection.JavaConverters._
+import scala.collection.mutable
+
+/**
+ * Data skipping based on a partitioned Record Level Index (RLI), where the
file groups indexing the
+ * record keys are sharded per data-table partition. The metadata lookup must
therefore be scoped to each
+ * candidate partition.
+ *
+ * The candidate partitions are derived from the already pruned partitions.
Because each partition requires a
+ * separate metadata table lookup, if the number of candidate partitions
exceeds {@code MAX_PARTITIONS} the
+ * record index filtering is skipped (returns [[None]]) and the reader falls
back to other indexes.
+ */
+class PartitionedRecordLevelIndexSupport(spark: SparkSession,
+ metadataConfig: HoodieMetadataConfig,
+ metaClient: HoodieTableMetaClient)
+ extends RecordLevelIndexSupport(spark, metadataConfig, metaClient) with
Logging {
+
+ override protected def lookupCandidateFilesForRecordKeys(fileIndex:
HoodieFileIndex,
+
prunedPartitionsAndFileSlices:
Seq[(Option[BaseHoodieTableFileIndex.PartitionPath], Seq[FileSlice])],
+ recordKeys:
List[String]): Option[Set[String]] = {
+ val partitions = prunedPartitionsAndFileSlices.flatMap { case
(partitionPathOpt, _) =>
+ partitionPathOpt.map(_.getPath)
+ }.toSet
+ if (partitions.isEmpty) {
+ // Cannot resolve candidate partitions, fall back to other indexes
rather than over-pruning
+ Option.empty
+ } else if (partitions.size > MAX_PARTITIONS) {
+ logInfo(s"The number of candidate partitions ${partitions.size} exceeds
the partitioned record level index " +
+ s"lookup threshold $MAX_PARTITIONS. Skipping record level index
pruning.")
+ Option.empty
+ } else {
+ lookupRecordKeys(partitions, recordKeys) match {
+ case Some(fileIdToPartitionMap) =>
+ val prunedStoragePaths =
getPrunedStoragePaths(prunedPartitionsAndFileSlices, fileIndex)
+ Option.apply(filterCandidateFiles(prunedStoragePaths,
fileIdToPartitionMap))
+ case None =>
+ // None of the candidate partitions are indexed by the partitioned
RLI (e.g. partitions
+ // not yet indexed), so we cannot determine the matching files. Fall
back to other indexes
+ // rather than over-pruning to an empty candidate set.
+ Option.empty
+ }
+ }
+ }
+
+ private def lookupRecordKeys(partitions: Set[String],
+ recordKeys: List[String]):
Option[mutable.Map[String, String]] = {
+ val fileGroups =
metadataTable.getBucketizedFileGroupsForPartitionedRLI(MetadataPartitionType.RECORD_INDEX)
+ val fileGroupCountPerDataPartition = fileGroups.asScala
+ .filter { case (partition, _) => partitions.contains(partition) }
+ .map { case (partition, slices) => partition ->
Integer.valueOf(slices.size()) }
+ .toMap
+ if (fileGroupCountPerDataPartition.isEmpty) {
+ None
+ } else {
+ val numFileGroups =
BucketizedMetadataTableFileGroupIndexParser.calculateNumberOfFileGroups(fileGroupCountPerDataPartition.asJava)
+ val partitionOffsetIndexes =
BucketizedMetadataTableFileGroupIndexParser.generatePartitionToBaseIndexOffsets(fileGroupCountPerDataPartition.asJava).asScala
+ // Like SparkMetadataTableRecordLevelIndex#lookupRecords: build
(partition, recordKey)
+ // pairs, key by the global partitioned-RLI shard id, and let each Spark
partition look up
+ // one record-index shard through
PartitionedRecordIndexFileGroupLookupFunction.
+ val partitionRecordKeys =
fileGroupCountPerDataPartition.keys.toSeq.flatMap { partition =>
+ recordKeys.map { recordKey => Pair.of(partition, recordKey) }
+ }
+ val partitionedKeyRDD =
spark.sparkContext.parallelize(partitionRecordKeys, numFileGroups)
+ .keyBy { partitionRecordKey =>
+ val partition = partitionRecordKey.getLeft
+ partitionOffsetIndexes(partition).intValue() +
+
HoodieTableMetadataUtil.mapRecordKeyToFileGroupIndex(partitionRecordKey.getRight,
fileGroupCountPerDataPartition(partition).intValue())
+ }
+ .partitionBy(new PartitionIdPassthrough(numFileGroups))
+ .map(_._2)
+ .toJavaRDD()
+ ValidationUtils.checkState(partitionedKeyRDD.getNumPartitions <=
numFileGroups)
+ val fileIdToPartitionMap = partitionedKeyRDD.mapPartitionsToPair(new
PartitionedRecordIndexFileGroupLookupFunction(metadataTable))
+ .collect()
+ .asScala
+ .foldLeft(mutable.Map.empty[String, String]) { (acc, location) =>
+ acc.put(location._2.getFileId, location._2.getPartitionPath)
+ acc
+ }
+ Some(fileIdToPartitionMap)
+ }
+ }
+}
+
+private class PartitionIdPassthrough(override val numPartitions: Int) extends
Partitioner {
+ override def getPartition(key: Any): Int = key.asInstanceOf[Int]
+}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/RecordLevelIndexSupport.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/RecordLevelIndexSupport.scala
index eb6d7d669690..9832ed33ac08 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/RecordLevelIndexSupport.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/RecordLevelIndexSupport.scala
@@ -18,35 +18,43 @@
package org.apache.hudi
import org.apache.hudi.DataSourceReadOptions.{QUERY_TYPE,
TIME_TRAVEL_AS_OF_INSTANT}
-import org.apache.hudi.RecordLevelIndexSupport.getPrunedStoragePaths
import org.apache.hudi.common.config.HoodieMetadataConfig
-import org.apache.hudi.common.data.HoodieListData
+import org.apache.hudi.common.data.HoodiePairData
import org.apache.hudi.common.fs.FSUtils
-import org.apache.hudi.common.model.FileSlice
+import org.apache.hudi.common.model.{FileSlice, HoodieRecordGlobalLocation}
import org.apache.hudi.common.model.HoodieRecord.HoodieMetadataField
import org.apache.hudi.common.model.HoodieTableQueryType.SNAPSHOT
import org.apache.hudi.common.table.HoodieTableMetaClient
import org.apache.hudi.common.table.timeline.InstantComparison
import
org.apache.hudi.common.table.timeline.InstantComparison.compareTimestamps
import org.apache.hudi.common.util.HoodieDataUtils
+import org.apache.hudi.core.index.record.HoodieRecordIndex
import org.apache.hudi.core.read.BaseHoodieTableFileIndex
import org.apache.hudi.keygen.KeyGenerator
import org.apache.hudi.metadata.HoodieTableMetadataUtil
+import
org.apache.hudi.metadata.HoodieTableMetadataUtil.PARTITION_NAME_RECORD_INDEX
import org.apache.hudi.storage.StoragePath
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.expressions.{And, AttributeReference,
Cast, EqualTo, Expression, In, Literal}
import org.apache.spark.sql.hudi.HoodieSqlCommonUtils
-import scala.collection.{mutable, JavaConverters}
import scala.collection.JavaConverters._
+import scala.collection.mutable
-class RecordLevelIndexSupport(spark: SparkSession,
- metadataConfig: HoodieMetadataConfig,
- metaClient: HoodieTableMetaClient)
+/**
+ * Base class for data skipping based on the Record Level Index (RLI) in the
metadata table.
+ *
+ * The RLI maps a record key to the file group that stores it. The actual
metadata lookup differs between
+ * a global RLI ([[GlobalRecordLevelIndexSupport]]) and a partitioned RLI
+ * ([[PartitionedRecordLevelIndexSupport]]); subclasses implement
[[lookupCandidateFilesForRecordKeys]].
+ * Use [[RecordLevelIndexSupport.create]] to instantiate the right
implementation for a table.
+ */
+abstract class RecordLevelIndexSupport(spark: SparkSession,
+ metadataConfig: HoodieMetadataConfig,
+ metaClient: HoodieTableMetaClient)
extends SparkBaseIndexSupport(spark, metadataConfig, metaClient) {
-
override def getIndexName: String = RecordLevelIndexSupport.INDEX_NAME
override def computeCandidateFileNames(fileIndex: HoodieFileIndex,
@@ -56,48 +64,56 @@ class RecordLevelIndexSupport(spark: SparkSession,
shouldPushDownFilesFilter: Boolean
): Option[Set[String]] = {
lazy val (_, recordKeys) = filterQueriesWithRecordKey(queryFilters)
- val prunedStoragePaths =
getPrunedStoragePaths(prunedPartitionsAndFileSlices, fileIndex)
if (recordKeys.nonEmpty) {
- Option.apply(getCandidateFilesForRecordKeys(prunedStoragePaths,
recordKeys))
+ lookupCandidateFilesForRecordKeys(fileIndex,
prunedPartitionsAndFileSlices, recordKeys)
} else {
Option.empty
}
}
+ /**
+ * Looks up the candidate files which may store the provided record keys
from the record level index.
+ * Implemented differently for a global vs a partitioned RLI.
+ *
+ * @param fileIndex the file index of the query
+ * @param prunedPartitionsAndFileSlices already pruned partitions and file
slices
+ * @param recordKeys the record key literals extracted
from the query filters
+ * @return the set of candidate file names, or [[None]] if the index could
not be used and pruning
+ * should be skipped (falling back to other indexes).
+ */
+ protected def lookupCandidateFilesForRecordKeys(fileIndex: HoodieFileIndex,
+
prunedPartitionsAndFileSlices:
Seq[(Option[BaseHoodieTableFileIndex.PartitionPath], Seq[FileSlice])],
+ recordKeys: List[String]):
Option[Set[String]]
+
override def invalidateCaches(): Unit = {
// no caches for this index type, do nothing
}
/**
- * Returns the list of candidate files which store the provided record keys
based on Metadata Table Record Index.
- *
- * @param allFiles - List of all files which needs to be considered for
the query
- * @param recordKeys - List of record keys.
- * @return Sequence of file names which need to be queried
+ * Builds a map from fileId to data-table partition path from record index
lookup results.
*/
- private def getCandidateFilesForRecordKeys(allFiles: Seq[StoragePath],
recordKeys: List[String]): Set[String] = {
- val recordIndexData = metadataTable.readRecordIndexLocationsWithKeys(
-
HoodieListData.eager(JavaConverters.seqAsJavaListConverter(recordKeys).asJava))
- try {
- val recordKeyLocationsList =
HoodieDataUtils.dedupeAndCollectAsList(recordIndexData)
- val fileIdToPartitionMap: mutable.Map[String, String] = mutable.Map.empty
- val candidateFiles: mutable.Set[String] = mutable.Set.empty
- for (recordKeyLocation <- recordKeyLocationsList.asScala) {
- val location = recordKeyLocation.getValue
- fileIdToPartitionMap.put(location.getFileId, location.getPartitionPath)
- }
- for (file <- allFiles) {
- val fileId = FSUtils.getFileIdFromFilePath(file)
- val partitionOpt = fileIdToPartitionMap.get(fileId)
- if (partitionOpt.isDefined) {
- candidateFiles += file.getName
- }
+ protected def collectFileIdToPartitionMap(recordIndexData:
HoodiePairData[String, HoodieRecordGlobalLocation]): mutable.Map[String,
String] = {
+ val recordKeyLocationsList =
HoodieDataUtils.dedupeAndCollectAsList(recordIndexData)
+ val fileIdToPartitionMap: mutable.Map[String, String] = mutable.Map.empty
+ for (recordKeyLocation <- recordKeyLocationsList.asScala) {
+ val location = recordKeyLocation.getValue
+ fileIdToPartitionMap.put(location.getFileId, location.getPartitionPath)
+ }
+ fileIdToPartitionMap
+ }
+
+ /**
+ * Filters the input files, keeping only those whose fileId is present in
the record index lookup results.
+ */
+ protected def filterCandidateFiles(allFiles: Seq[StoragePath],
fileIdToPartitionMap: mutable.Map[String, String]): Set[String] = {
+ val candidateFiles: mutable.Set[String] = mutable.Set.empty
+ for (file <- allFiles) {
+ val fileId = FSUtils.getFileIdFromFilePath(file)
+ if (fileIdToPartitionMap.contains(fileId)) {
+ candidateFiles += file.getName
}
- candidateFiles.toSet
- } finally {
- // Clean up the RDD to avoid memory leaks
- recordIndexData.unpersistWithDependencies()
}
+ candidateFiles.toSet
}
/**
@@ -133,6 +149,34 @@ class RecordLevelIndexSupport(spark: SparkSession,
object RecordLevelIndexSupport {
val INDEX_NAME = "RECORD_LEVEL"
+ /**
+ * Upper bound on the number of candidate data-table partitions eligible for
a partitioned RLI lookup.
+ *
+ * Unlike the global RLI (a single lookup over all keys), the partitioned
variant performs one metadata-table read
+ * per candidate partition. When a query does not filter on the partition
column the candidate set can span many
+ * partitions, and fanning out a lookup to each one can add latency that
outweighs the skipping benefit. Once the
+ * candidate partition count exceeds this threshold, pruning is skipped.
+ */
+ private[hudi] val MAX_PARTITIONS = 10
+
+ /**
+ * Creates the [[RecordLevelIndexSupport]] implementation matching the
table's record level index:
+ * [[PartitionedRecordLevelIndexSupport]] when the RLI is partitioned,
otherwise
+ * [[GlobalRecordLevelIndexSupport]].
+ */
+ def create(spark: SparkSession,
+ metadataConfig: HoodieMetadataConfig,
+ metaClient: HoodieTableMetaClient): RecordLevelIndexSupport = {
+ val isPartitioned =
metaClient.getIndexForMetadataPartition(PARTITION_NAME_RECORD_INDEX)
+ .map[Boolean](indexDef => HoodieRecordIndex.isPartitioned(indexDef))
+ .orElse(false)
+ if (isPartitioned) {
+ new PartitionedRecordLevelIndexSupport(spark, metadataConfig, metaClient)
+ } else {
+ new GlobalRecordLevelIndexSupport(spark, metadataConfig, metaClient)
+ }
+ }
+
private def getDefaultAttributeFetcher(): Function1[Expression, Expression]
= {
expr => expr
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/SecondaryIndexSupport.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/SecondaryIndexSupport.scala
index c8d7fc6d595e..d67dc961a1e0 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/SecondaryIndexSupport.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/SecondaryIndexSupport.scala
@@ -39,7 +39,7 @@ import scala.collection.JavaConverters._
class SecondaryIndexSupport(spark: SparkSession,
metadataConfig: HoodieMetadataConfig,
- metaClient: HoodieTableMetaClient) extends
RecordLevelIndexSupport(spark, metadataConfig, metaClient) {
+ metaClient: HoodieTableMetaClient) extends
GlobalRecordLevelIndexSupport(spark, metadataConfig, metaClient) {
override def getIndexName: String = SecondaryIndexSupport.INDEX_NAME
override def computeCandidateFileNames(fileIndex: HoodieFileIndex,
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/RecordLevelIndexTestBase.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/RecordLevelIndexTestBase.scala
index 5c6919a28069..9734aa7b5f7a 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/RecordLevelIndexTestBase.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/RecordLevelIndexTestBase.scala
@@ -72,6 +72,7 @@ class RecordLevelIndexTestBase extends
HoodieStatsIndexTestBase {
saveMode: SaveMode,
validate: Boolean = true,
numUpdates: Int = 1,
+ numInserts: Int = 5,
onlyUpdates: Boolean =
false,
schemaStr: String =
HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA,
timestamp: Long =
System.currentTimeMillis()): DataFrame = {
@@ -87,7 +88,7 @@ class RecordLevelIndexTestBase extends
HoodieStatsIndexTestBase {
latestBatch =
recordsToStrings(dataGen.generateInsertsForPartitionPerSchema(
getInstantTime(), 5, dataGen.getPartitionPaths.last,
schemaStr)).asScala
} else {
- latestBatch =
recordsToStrings(dataGen.generateInsertsAsPerSchema(getInstantTime(), 5,
schemaStr, timestamp)).asScala
+ latestBatch =
recordsToStrings(dataGen.generateInsertsAsPerSchema(getInstantTime(),
numInserts, schemaStr, timestamp)).asScala
}
val latestBatchDf =
spark.read.json(spark.sparkContext.parallelize(latestBatch.toSeq, 2))
latestBatchDf.cache()
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestGlobalRecordLevelIndexWithSQL.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestGlobalRecordLevelIndexWithSQL.scala
index a3692dd7a187..e1df3d76ed4d 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestGlobalRecordLevelIndexWithSQL.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestGlobalRecordLevelIndexWithSQL.scala
@@ -46,6 +46,32 @@ import scala.util.Using
class TestGlobalRecordLevelIndexWithSQL extends RecordLevelIndexTestBase {
val sqlTempTable = "tbl"
+ /**
+ * Whether the record level index exercised by the tests is partitioned.
Overridden by
+ * [[TestRecordLevelIndexWithSQL]] to run the same coverage against a
partitioned RLI.
+ */
+ protected def isPartitionedRli: Boolean = false
+
+ /** Write options selecting a global vs partitioned record level index. */
+ protected def rliEnableOpts: Map[String, String] =
+ if (isPartitionedRli) {
+ Map(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key ->
"false",
+ HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key -> "true")
+ } else {
+ Map(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key ->
"true",
+ HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key -> "false")
+ }
+
+ /** The create-table DDL option that enables a global vs partitioned record
level index. */
+ protected def rliEnableDDLOpts: String =
+ if (isPartitionedRli) {
+ s"${HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key} = 'true'"
+ } else {
+ // "hoodie.metadata.record.index.enable" is the backward-compatible
alternative key for
+ // GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP; used here intentionally to
also exercise that alias.
+ "hoodie.metadata.record.index.enable = 'true'"
+ }
+
@ParameterizedTest
@ValueSource(booleans = Array(true, false))
def testRLICreationUsingSQL(isPartitioned: Boolean): Unit = {
@@ -73,7 +99,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
@ParameterizedTest
@ValueSource(strings = Array("COPY_ON_WRITE", "MERGE_ON_READ"))
def testRLIWithSQL(tableType: String): Unit = {
- val hudiOpts = commonOpts ++ Map(
+ val hudiOpts = commonOpts ++ rliEnableOpts ++ Map(
DataSourceWriteOptions.TABLE_TYPE.key -> tableType,
"hoodie.metadata.index.column.stats.enable" -> "false",
DataSourceReadOptions.ENABLE_DATA_SKIPPING.key -> "true")
@@ -166,24 +192,33 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
verifyPruningFileCount(hudiOpts, dataFilter, numFiles, shouldPrune)
}
- private def attribute(partition: String): AttributeReference = {
+ protected def attribute(partition: String): AttributeReference = {
AttributeReference(partition, StringType, true)()
}
- private def literal(value: String): Literal = {
+ protected def literal(value: String): Literal = {
Literal.create(value)
}
- private def verifyPruningFileCount(opts: Map[String, String], dataFilter:
Expression, numFiles: Int, shouldPrune: Boolean): Unit = {
- verifyPruningFileCount(opts, dataFilter, numFiles,
HoodieTableMetaClient.reload(metaClient), shouldPrune)
+ protected def verifyPruningFileCount(opts: Map[String, String], dataFilter:
Expression, numFiles: Int, shouldPrune: Boolean): Unit = {
+ verifyPruningFileCount(opts, Seq(dataFilter), Seq.empty, numFiles,
HoodieTableMetaClient.reload(metaClient), shouldPrune)
+ }
+
+ protected def verifyPruningFileCount(opts: Map[String, String], dataFilter:
Expression, numFiles: Int, metaClient: HoodieTableMetaClient, shouldPrune:
Boolean): Unit = {
+ verifyPruningFileCount(opts, Seq(dataFilter), Seq.empty, numFiles,
metaClient, shouldPrune)
}
- private def verifyPruningFileCount(opts: Map[String, String], dataFilter:
Expression, numFiles: Int, metaClient: HoodieTableMetaClient, shouldPrune:
Boolean): Unit = {
+ protected def verifyPruningFileCount(opts: Map[String, String],
+ dataFilters: Seq[Expression],
+ partitionFilters: Seq[Expression],
+ numFiles: Int,
+ metaClient: HoodieTableMetaClient,
+ shouldPrune: Boolean): Unit = {
// with data skipping
val commonOpts = opts + ("path" -> metaClient.getBasePath.toString)
this.metaClient = HoodieTableMetaClient.reload(metaClient)
var fileIndex = HoodieFileIndex(spark, metaClient, None, commonOpts,
includeLogFiles = true)
- val filteredPartitionDirectories = fileIndex.listFiles(Seq(),
Seq(dataFilter))
+ val filteredPartitionDirectories = fileIndex.listFiles(partitionFilters,
dataFilters)
val filteredFilesCount = filteredPartitionDirectories.flatMap(s =>
s.files).size
if (shouldPrune) {
assertEquals(numFiles, filteredFilesCount)
@@ -193,7 +228,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
// with no data skipping
fileIndex = HoodieFileIndex(spark, metaClient, None, commonOpts +
(DataSourceReadOptions.ENABLE_DATA_SKIPPING.key -> "false"), includeLogFiles =
true)
- val filesCountWithNoSkipping = fileIndex.listFiles(Seq(),
Seq(dataFilter)).flatMap(s => s.files).size
+ val filesCountWithNoSkipping = fileIndex.listFiles(partitionFilters,
dataFilters).flatMap(s => s.files).size
if (!shouldPrune) {
assertEquals(filteredFilesCount, filesCountWithNoSkipping)
}
@@ -222,7 +257,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
FileSystemViewManager.createInMemoryFileSystemView(new
HoodieSparkEngineContext(jsc), metaClient,
HoodieMetadataConfig.newBuilder().fromProperties(props).build())
}
- private def createTempTable(hudiOpts: Map[String, String]): Unit = {
+ protected def createTempTable(hudiOpts: Map[String, String]): Unit = {
val readDf = spark.read.format("hudi").options(hudiOpts).load(basePath)
readDf.registerTempTable(sqlTempTable)
}
@@ -246,7 +281,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
| options (
| primaryKey ='$recordKeyFields',
| hoodie.metadata.enable = 'true',
- | hoodie.metadata.record.index.enable = 'true',
+ | $rliEnableDDLOpts,
| hoodie.datasource.write.recordkey.field = '$recordKeyFields',
| hoodie.enable.data.skipping = 'true'
| )
@@ -263,7 +298,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
@ParameterizedTest
@ValueSource(booleans = Array(true, false))
def testPrunedStoragePaths(includeLogFiles: Boolean): Unit = {
- val hudiOpts = commonOpts ++ metadataOpts +
(DataSourceWriteOptions.TABLE_TYPE.key -> "MERGE_ON_READ")
+ val hudiOpts = commonOpts ++ metadataOpts ++ rliEnableOpts +
(DataSourceWriteOptions.TABLE_TYPE.key -> "MERGE_ON_READ")
val df = doWriteAndValidateDataAndRecordIndex(hudiOpts,
operation = DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL,
saveMode = SaveMode.Overwrite,
@@ -292,7 +327,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
val recordKey: String = df.filter("partition = '" + selectedPartition +
"'").limit(1).collect().apply(0).getAs("_row_key")
val dataFilter = EqualTo(attribute("_row_key"), Literal(recordKey))
- val rliIndexSupport = new RecordLevelIndexSupport(spark,
getConfig.getMetadataConfig, metaClient)
+ val rliIndexSupport = RecordLevelIndexSupport.create(spark,
getConfig.getMetadataConfig, metaClient)
val fileNames = rliIndexSupport.computeCandidateFileNames(fileIndex,
Seq(dataFilter), null, prunedPaths, false)
assertEquals(if (includeLogFiles) 2 else 1, fileNames.get.size)
}
@@ -323,7 +358,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
| options (
| primaryKey ='$recordKeyFields',
| hoodie.metadata.enable = 'true',
- | hoodie.metadata.record.index.enable = 'true',
+ | $rliEnableDDLOpts,
| hoodie.datasource.write.recordkey.field = '$recordKeyFields',
| hoodie.enable.data.skipping = 'true',
| hoodie.metadata.index.column.stats.enable = 'false'
@@ -371,7 +406,7 @@ class TestGlobalRecordLevelIndexWithSQL extends
RecordLevelIndexTestBase {
| options (
| primaryKey = '$recordKeyFields',
| hoodie.metadata.enable = 'true',
- | hoodie.metadata.record.index.enable = 'true',
+ | $rliEnableDDLOpts,
| hoodie.datasource.write.recordkey.field = '$recordKeyFields',
| hoodie.enable.data.skipping = 'true',
| hoodie.metadata.index.column.stats.enable = 'false'
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRecordLevelIndexWithSQL.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRecordLevelIndexWithSQL.scala
new file mode 100644
index 000000000000..064df7a719e1
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRecordLevelIndexWithSQL.scala
@@ -0,0 +1,84 @@
+/*
+ * 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.hudi.functional
+
+import org.apache.hudi.{DataSourceReadOptions, DataSourceWriteOptions,
RecordLevelIndexSupport}
+import org.apache.hudi.common.model.HoodieTableType
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.core.index.record.HoodieRecordIndex
+import org.apache.hudi.metadata.MetadataPartitionType
+
+import org.apache.spark.sql.SaveMode
+import org.apache.spark.sql.catalyst.expressions.{EqualTo, Expression, Literal}
+import org.junit.jupiter.api.{Tag, Test}
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Data skipping coverage for a partitioned Record Level Index (RLI). This
reuses the full coverage of
+ * [[TestGlobalRecordLevelIndexWithSQL]] by flipping [[isPartitionedRli]] to
true so that every inherited test
+ * exercises a partitioned RLI, and adds the partitioned-specific scenarios
(e.g. the max-candidate-partitions
+ * threshold fallback).
+ */
+@Tag("functional")
+class TestRecordLevelIndexWithSQL extends TestGlobalRecordLevelIndexWithSQL {
+
+ override protected def isPartitionedRli: Boolean = true
+
+ /**
+ * Verifies that the record level index is created as partitioned and that
pruning is skipped (without error)
+ * once the number of candidate data table partitions exceeds the hard-coded
threshold.
+ */
+ @Test
+ def testPartitionedRliPartitionsThreshold(): Unit = {
+ val hudiOpts = commonOpts ++ rliEnableOpts ++ Map(
+ DataSourceWriteOptions.TABLE_TYPE.key ->
HoodieTableType.COPY_ON_WRITE.name(),
+ "hoodie.metadata.index.column.stats.enable" -> "false",
+ DataSourceReadOptions.ENABLE_DATA_SKIPPING.key -> "true")
+
+ val partitionPaths = (0 to RecordLevelIndexSupport.MAX_PARTITIONS)
+ .map(i => f"2026/06/$i%02d")
+ .toArray
+ initTestDataGenerator(partitionPaths)
+ doWriteAndValidateDataAndRecordIndex(hudiOpts,
+ operation = DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL,
+ saveMode = SaveMode.Overwrite,
+ validate = false,
+ numInserts = partitionPaths.length)
+
+ // The record index should have been created as partitioned
+ metaClient = HoodieTableMetaClient.reload(metaClient)
+ assertTrue(HoodieRecordIndex.isPartitioned(
+
metaClient.getIndexMetadata.get().getIndex(MetadataPartitionType.RECORD_INDEX.getPartitionPath).get()))
+
+ createTempTable(hudiOpts)
+ val latestSnapshotDf =
spark.read.format("hudi").options(hudiOpts).load(basePath)
+ val recordKey =
latestSnapshotDf.limit(1).collect().head.getAs[String]("_row_key")
+ val dataFilter: Expression = EqualTo(attribute("_row_key"),
Literal(recordKey))
+
+ // The record-key-only query sees all candidate partitions and exceeds the
hard-coded threshold.
+ verifyPruningFileCount(hudiOpts, dataFilter, numFiles = -1, shouldPrune =
false)
+
+ val partitionValue =
latestSnapshotDf.limit(1).collect().head.getAs[String]("partition")
+ val partitionFilter: Expression = EqualTo(attribute("partition"),
Literal(partitionValue))
+
+ // Spark passes partition predicates separately, and HoodieFileIndex only
prunes partitions from that channel.
+ // With one candidate partition, the hard-coded threshold is not hit and
RLI pruning still applies.
+ verifyPruningFileCount(hudiOpts, Seq(dataFilter), Seq(partitionFilter),
numFiles = 1,
+ metaClient = HoodieTableMetaClient.reload(metaClient), shouldPrune =
true)
+ }
+}