This is an automated email from the ASF dual-hosted git repository.
yihua 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 438c0d372569 fix(spark): group file slices by partition in the file
index listing (#20115)
438c0d372569 is described below
commit 438c0d372569bb40138cc3046069ed405f75bdbe
Author: Y Ethan Guo <[email protected]>
AuthorDate: Mon Oct 5 20:55:37 2026 -0700
fix(spark): group file slices by partition in the file index listing
(#20115)
---
.../scala/org/apache/hudi/HoodieFileIndex.scala | 35 +--
.../apache/hudi/HoodieIncrementalFileIndex.scala | 10 +-
.../apache/hudi/PartitionDirectoryConverter.scala | 55 +++--
.../org/apache/hudi/TestHoodieFileIndex.scala | 255 ++++++++++++++++++++-
.../hudi/TestPartitionDirectoryConverter.scala | 46 +++-
5 files changed, 356 insertions(+), 45 deletions(-)
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 cd10aeb9c1fe..003c571acf16 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
@@ -200,11 +200,10 @@ case class HoodieFileIndex(spark: SparkSession,
partitionFilters
}
- val slices = filterFileSlices(dataFilters,
effectivePartitionFilters).flatMap(
- { case (partitionOpt, fileSlices) =>
- fileSlices.filter(!_.isEmpty).map(fs =>
(InternalRow.fromSeq(partitionOpt.get.getValues), fs))
- }
- )
+ val slices = filterFileSlices(dataFilters, effectivePartitionFilters).map {
+ case (partitionOpt, fileSlices) =>
+ (InternalRow.fromSeq(partitionOpt.get.getValues),
fileSlices.filter(!_.isEmpty))
+ }
prepareFileSlices(slices)
}
@@ -213,24 +212,26 @@ case class HoodieFileIndex(spark: SparkSession,
HoodieFileIndex.extractNestedPartitionFilters(dataFilters,
getPartitionColumns.toSet)
}
- protected def prepareFileSlices(slices: Seq[(InternalRow, FileSlice)]):
Seq[PartitionDirectory] = {
+ protected def prepareFileSlices(slices: Seq[(InternalRow, Seq[FileSlice])]):
Seq[PartitionDirectory] = {
hasPushedDownPartitionPredicates = true
- val prunedPartitionsAndFilteredFileSlices = slices.map {
- case (partitionValues, fileSlice) =>
+ val prunedPartitionsAndFilteredFileSlices =
slices.filter(_._2.nonEmpty).flatMap {
+ case (partitionValues, fileSlices) =>
if (shouldEmbedFileSlices) {
- PartitionDirectoryConverter.convertFileSliceToPartitionDirectory(
+ PartitionDirectoryConverter.convertFileSlicesToPartitionDirectories(
partitionValues,
- fileSlice,
+ fileSlices,
hoodieConfig)
} else {
- val baseFileStatusOpt =
getBaseFileInfo(Option.apply(fileSlice.getBaseFile.orElse(null)))
- val logPathInfoStream =
fileSlice.getLogFiles.map[StoragePathInfo](JFunction.toJavaFunction[HoodieLogFile,
StoragePathInfo](lf => lf.getPathInfo))
- val files =
logPathInfoStream.collect(Collectors.toList[StoragePathInfo]).asScala
- baseFileStatusOpt.foreach(f => files.append(f))
- val allCandidateFiles = files.map(fileInfo => new
FileStatus(fileInfo.getLength, fileInfo.isDirectory, 0, fileInfo.getBlockSize,
- fileInfo.getModificationTime, new
Path(fileInfo.getPath.toUri))).toSeq
-
sparkAdapter.getSparkPartitionedFileUtils.newPartitionDirectory(partitionValues,
allCandidateFiles)
+ val allCandidateFiles = fileSlices.flatMap { fileSlice =>
+ val baseFileStatusOpt =
getBaseFileInfo(Option.apply(fileSlice.getBaseFile.orElse(null)))
+ val logPathInfoStream =
fileSlice.getLogFiles.map[StoragePathInfo](JFunction.toJavaFunction[HoodieLogFile,
StoragePathInfo](lf => lf.getPathInfo))
+ val files =
logPathInfoStream.collect(Collectors.toList[StoragePathInfo]).asScala
+ baseFileStatusOpt.foreach(f => files.append(f))
+ files.map(fileInfo => new FileStatus(fileInfo.getLength,
fileInfo.isDirectory, 0, fileInfo.getBlockSize,
+ fileInfo.getModificationTime, new Path(fileInfo.getPath.toUri)))
+ }
+
Seq(sparkAdapter.getSparkPartitionedFileUtils.newPartitionDirectory(partitionValues,
allCandidateFiles))
}
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieIncrementalFileIndex.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieIncrementalFileIndex.scala
index 4c2091e8feda..0568af6e9409 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieIncrementalFileIndex.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieIncrementalFileIndex.scala
@@ -44,12 +44,10 @@ class HoodieIncrementalFileIndex(override val spark:
SparkSession,
) with FileIndex {
override def listFiles(partitionFilters: Seq[Expression], dataFilters:
Seq[Expression]): Seq[PartitionDirectory] = {
- val fileSlices =
mergeOnReadIncrementalRelation.listFileSplits(partitionFilters,
dataFilters).toSeq.flatMap(
- {
- case (partitionValues, fileSlices) =>
- fileSlices.filter(!_.isEmpty).map(fs => ( partitionValues,
fs.withLogFiles(includeLogFiles)))
- }
- )
+ val fileSlices =
mergeOnReadIncrementalRelation.listFileSplits(partitionFilters,
dataFilters).toSeq.map {
+ case (partitionValues, fileSlices) =>
+ (partitionValues,
fileSlices.filter(!_.isEmpty).map(_.withLogFiles(includeLogFiles)))
+ }
prepareFileSlices(fileSlices)
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionDirectoryConverter.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionDirectoryConverter.scala
index 2565278ab3eb..b6518a8b3279 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionDirectoryConverter.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionDirectoryConverter.scala
@@ -26,23 +26,32 @@ import
org.apache.spark.sql.execution.datasources.PartitionDirectory
object PartitionDirectoryConverter extends SparkAdapterSupport {
- def convertFileSliceToPartitionDirectory(partitionValues: InternalRow,
- fileSlice: FileSlice,
- config: HoodieConfig):
PartitionDirectory = {
- // 1. Generate a delegate file for file slice, which spark uses to
optimize rdd partition parallelism based on data such as file size
- // - For file slice only has base file, we directly use the base file
size as delegate file size
- // - For file slice has log file, we estimate the delegate file size
based on the log file size and option(base file) size
- val estimationFileSize = fileSlice.getTotalFileSizeAsParquetFormat(config)
- val fileInfo = if (fileSlice.getBaseFile.isPresent) {
- fileSlice.getBaseFile.get().getPathInfo
+ /**
+ * Converts the file slices of one partition into partition directories.
+ *
+ * File slices that are read as a whole (with log files or a bootstrap base)
each get their own
+ * directory whose partition values carry only that slice, so that a task is
shipped just the slice
+ * it reads. Base-file-only slices need no such mapping and share a single
directory per partition,
+ * so that consumers of the file index see one entry per partition for the
common copy-on-write case.
+ */
+ def convertFileSlicesToPartitionDirectories(partitionValues: InternalRow,
+ fileSlices: Seq[FileSlice],
+ config: HoodieConfig):
Seq[PartitionDirectory] = {
+ val (slicesReadAsFileSlice, baseFileOnlySlices) =
fileSlices.partition(shouldReadAsFileSlice)
+ val baseFileOnlyDirectory = if (baseFileOnlySlices.isEmpty) {
+ Seq.empty
} else {
- fileSlice.getLogFiles.findAny().get().getPathInfo
+ Seq(sparkAdapter.getSparkPartitionedFileUtils.newPartitionDirectory(
+ partitionValues, baseFileOnlySlices.map(createDelegateFile(_,
config))))
}
- // create a delegate file status based on the file size estimation
- val delegateFile = new FileStatus(estimationFileSize,
fileInfo.isDirectory, 0, fileInfo.getBlockSize, fileInfo.getModificationTime,
new Path(fileInfo.getPath.toUri))
+ baseFileOnlyDirectory ++
slicesReadAsFileSlice.map(convertFileSliceToPartitionDirectory(partitionValues,
_, config))
+ }
- // 2. Generate a partition directory based on the delegate file and
partition values
- if (fileSlice.hasLogFiles || fileSlice.hasBootstrapBase) {
+ def convertFileSliceToPartitionDirectory(partitionValues: InternalRow,
+ fileSlice: FileSlice,
+ config: HoodieConfig):
PartitionDirectory = {
+ val delegateFile = createDelegateFile(fileSlice, config)
+ if (shouldReadAsFileSlice(fileSlice)) {
// should read as file slice, so we need to create a mapping from fileId
to file slice
sparkAdapter.getSparkPartitionedFileUtils.newPartitionDirectory(
sparkAdapter.createPartitionFileSliceMapping(partitionValues,
Map(fileSlice.getFileId -> fileSlice)), Seq(delegateFile))
@@ -51,4 +60,22 @@ object PartitionDirectoryConverter extends
SparkAdapterSupport {
}
}
+ private def shouldReadAsFileSlice(fileSlice: FileSlice): Boolean = {
+ fileSlice.hasLogFiles || fileSlice.hasBootstrapBase
+ }
+
+ /**
+ * Generates a delegate file for the file slice, which spark uses to
optimize rdd partition parallelism based on data such as file size
+ * - For file slice only has base file, we directly use the base file size
as delegate file size
+ * - For file slice has log file, we estimate the delegate file size based
on the log file size and option(base file) size
+ */
+ private def createDelegateFile(fileSlice: FileSlice, config: HoodieConfig):
FileStatus = {
+ val estimationFileSize = fileSlice.getTotalFileSizeAsParquetFormat(config)
+ val fileInfo = if (fileSlice.getBaseFile.isPresent) {
+ fileSlice.getBaseFile.get().getPathInfo
+ } else {
+ fileSlice.getLogFiles.findAny().get().getPathInfo
+ }
+ new FileStatus(estimationFileSize, fileInfo.isDirectory, 0,
fileInfo.getBlockSize, fileInfo.getModificationTime, new
Path(fileInfo.getPath.toUri))
+ }
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieFileIndex.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieFileIndex.scala
index 3dd498564f6d..7daee1636fa4 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieFileIndex.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieFileIndex.scala
@@ -23,18 +23,18 @@ import org.apache.hudi.HoodieConversionUtils.toJavaOption
import org.apache.hudi.HoodieFileIndex.DataSkippingFailureMode
import org.apache.hudi.client.HoodieJavaWriteClient
import org.apache.hudi.client.common.HoodieJavaEngineContext
-import org.apache.hudi.common.config.{HoodieMetadataConfig,
HoodieStorageConfig}
+import org.apache.hudi.common.config.{HoodieConfig, HoodieMetadataConfig,
HoodieStorageConfig, TypedProperties}
import
org.apache.hudi.common.config.TimestampKeyGeneratorConfig.{TIMESTAMP_INPUT_DATE_FORMAT,
TIMESTAMP_OUTPUT_DATE_FORMAT, TIMESTAMP_TYPE_FIELD}
import org.apache.hudi.common.engine.EngineType
import org.apache.hudi.common.fs.FSUtils
-import org.apache.hudi.common.model.{HoodieBaseFile, HoodieRecord,
HoodieTableType}
+import org.apache.hudi.common.model.{FileSlice, HoodieBaseFile, HoodieRecord,
HoodieTableType}
import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
import org.apache.hudi.common.table.view.HoodieTableFileSystemView
import org.apache.hudi.common.testutils.{HoodieTestDataGenerator,
HoodieTestUtils}
import
org.apache.hudi.common.testutils.HoodieTestDataGenerator.recordsToStrings
import org.apache.hudi.common.util.PartitionPathEncodeUtils
import org.apache.hudi.common.util.StringUtils.isNullOrEmpty
-import org.apache.hudi.config.HoodieWriteConfig
+import org.apache.hudi.config.{HoodieCompactionConfig, HoodieWriteConfig}
import org.apache.hudi.exception.HoodieException
import org.apache.hudi.keygen.TimestampBasedAvroKeyGenerator.TimestampType
import org.apache.hudi.keygen.constant.KeyGeneratorType
@@ -44,16 +44,17 @@ import org.apache.hudi.testutils.HoodieSparkClientTestBase
import org.apache.hudi.util.JFunction
import org.apache.spark.sql._
+import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions.{And, AttributeReference,
EqualTo, Expression, GetStructField, GreaterThanOrEqual, LessThan, Literal, Or}
-import org.apache.spark.sql.execution.datasources.{NoopCache,
PartitionDirectory}
+import org.apache.spark.sql.execution.datasources.{FileIndex,
HadoopFsRelation, LogicalRelation, NoopCache, PartitionDirectory,
PartitionedFile}
import org.apache.spark.sql.functions.{lit, struct}
import org.apache.spark.sql.hudi.HoodieSparkSessionExtension
import org.apache.spark.sql.types._
import org.apache.spark.unsafe.types.UTF8String
import org.junit.jupiter.api.{BeforeEach, Test}
-import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows,
assertTrue}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse,
assertThrows, assertTrue}
import org.junit.jupiter.params.ParameterizedTest
-import org.junit.jupiter.params.provider.{Arguments, CsvSource, MethodSource,
ValueSource}
+import org.junit.jupiter.params.provider.{Arguments, CsvSource, EnumSource,
MethodSource, ValueSource}
import java.util.Properties
import java.util.function.Consumer
@@ -61,7 +62,7 @@ import java.util.function.Consumer
import scala.collection.JavaConverters._
import scala.util.Random
-class TestHoodieFileIndex extends HoodieSparkClientTestBase with
ScalaAssertionSupport {
+class TestHoodieFileIndex extends HoodieSparkClientTestBase with
ScalaAssertionSupport with SparkAdapterSupport {
protected def withRDDPersistenceValidation(f: => Unit): Unit = {
org.apache.hudi.testutils.SparkRDDValidationUtils.withRDDPersistenceValidation(spark,
new org.apache.hudi.testutils.SparkRDDValidationUtils.ThrowingRunnable {
override def run(): Unit = f
@@ -890,6 +891,246 @@ class TestHoodieFileIndex extends
HoodieSparkClientTestBase with ScalaAssertionS
assertEquals(expected,
HoodieFileIndex.extractNestedPartitionFilters(filters, partitionColumns))
}
+
+ /**
+ * The file index returns one directory per partition for base-file-only
slices, so that consumers
+ * of the listing see partitions rather than file slices, while slices with
log files keep a directory
+ * of their own. Split planning stays the same as for one directory per file
slice.
+ */
+ @ParameterizedTest
+ @EnumSource(classOf[HoodieTableType])
+ def testListFilesGroupsFileSlicesByPartition(tableType: HoodieTableType):
Unit = {
+ val partitionCount = 5
+ val commitCount = 3
+ val table = writeFileGroups(tableType, partitionCount, commitCount)
+ val fileIndex = HoodieFileIndex(spark, metaClient, None, queryOpts,
+ includeLogFiles = tableType == HoodieTableType.MERGE_ON_READ,
shouldEmbedFileSlices = true)
+
+ val directories = fileIndex.listFiles(Nil, Nil)
+ assertDirectoryLayout(directories, partitionCount, partitionCount *
commitCount, fileSlicesWithLogFilesCount = 0)
+ directories.foreach(directory => assertEquals(commitCount,
directory.files.size))
+ assertSameSplitPlanning(fileIndex, directories, exactFileOrder = true)
+
+ val snapshot = DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL
+ val readOptimized = DataSourceReadOptions.QUERY_TYPE_READ_OPTIMIZED_OPT_VAL
+ val incremental = DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL
+ Seq(snapshot, readOptimized, incremental).foreach { queryType =>
+ assertDirectoryLayout(loadFileIndex(queryType).listFiles(Nil, Nil),
partitionCount, partitionCount * commitCount,
+ fileSlicesWithLogFilesCount = 0)
+ assertEquals(table.timestamps.toSeq.sorted, readTimestamps(queryType))
+ }
+
+ if (tableType == HoodieTableType.MERGE_ON_READ) {
+ // update one record in each of the first two partitions, which appends
log files to one file group of each
+ val updates = Seq(("key-0-0", 0, "ts-3"), ("key-0-1", 1, "ts-3"))
+ upsertToLogFiles(table, updates)
+ fileIndex.refresh()
+
+ val directoriesWithLogFiles = fileIndex.listFiles(Nil, Nil)
+ assertDirectoryLayout(directoriesWithLogFiles, partitionCount,
partitionCount * commitCount - 2, fileSlicesWithLogFilesCount = 2)
+ assertSameSplitPlanning(fileIndex, directoriesWithLogFiles,
exactFileOrder = false)
+
+ // snapshot and incremental queries merge the log files, while
read-optimized queries read the base files only
+ val updatedTimestamps = table.timestamps ++ updates.map { case (key, _,
timestamp) => key -> timestamp }
+ Seq(snapshot, incremental).foreach { queryType =>
+ assertDirectoryLayout(loadFileIndex(queryType).listFiles(Nil, Nil),
partitionCount, partitionCount * commitCount - 2,
+ fileSlicesWithLogFilesCount = 2)
+ assertEquals(updatedTimestamps.toSeq.sorted, readTimestamps(queryType))
+ }
+ assertDirectoryLayout(loadFileIndex(readOptimized).listFiles(Nil, Nil),
partitionCount, partitionCount * commitCount,
+ fileSlicesWithLogFilesCount = 0)
+ assertEquals(table.timestamps.toSeq.sorted,
readTimestamps(readOptimized))
+
+ // without embedded file slices, a partition directory lists the base
and log files of the partition
+ val legacyFileIndex = HoodieFileIndex(spark, metaClient, None,
queryOpts, includeLogFiles = true)
+ val legacyDirectories = legacyFileIndex.listFiles(Nil, Nil)
+ assertEquals(partitionCount, legacyDirectories.size)
+
assertFalse(legacyDirectories.exists(_.values.isInstanceOf[HoodiePartitionFileSliceMapping]))
+ assertEquals(partitionCount * commitCount + 2,
legacyDirectories.map(_.files.size).sum)
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(classOf[HoodieTableType])
+ def testListFilesGroupsFileSlicesOfNonPartitionedTable(tableType:
HoodieTableType): Unit = {
+ val commitCount = 3
+ val table = writeFileGroups(tableType, partitionCount = 1, commitCount,
partitioned = false)
+ val fileIndex = HoodieFileIndex(spark, metaClient, None, queryOpts,
+ includeLogFiles = tableType == HoodieTableType.MERGE_ON_READ,
shouldEmbedFileSlices = true)
+
+ val directories = fileIndex.listFiles(Nil, Nil)
+ assertEquals(1, directories.size)
+ assertEquals(commitCount, directories.head.files.size)
+
assertFalse(directories.head.values.isInstanceOf[HoodiePartitionFileSliceMapping])
+ assertSameSplitPlanning(fileIndex, directories, exactFileOrder = true)
+
+ if (tableType == HoodieTableType.MERGE_ON_READ) {
+ val updates = Seq(("key-0-0", 0, "ts-3"))
+ upsertToLogFiles(table, updates)
+ fileIndex.refresh()
+
+ val directoriesWithLogFiles = fileIndex.listFiles(Nil, Nil)
+ assertEquals(2, directoriesWithLogFiles.size)
+
assertFalse(directoriesWithLogFiles.head.values.isInstanceOf[HoodiePartitionFileSliceMapping])
+ assertEquals(commitCount - 1, directoriesWithLogFiles.head.files.size)
+ assertTrue(getMappedSlice(directoriesWithLogFiles.last).hasLogFiles)
+ assertEquals((table.timestamps ++ updates.map { case (key, _, timestamp)
=> key -> timestamp }).toSeq.sorted,
+ readTimestamps(DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL))
+ }
+ }
+
+ /**
+ * A table written by [[writeFileGroups]]: its write options, its partition
paths, and the timestamp written for each record key.
+ */
+ private case class FileGroupsTable(writeOpts: Map[String, String],
partitionPaths: Seq[String], timestamps: Map[String, String])
+
+ /**
+ * Writes `commitCount` inserts, each adding a new file group to every
partition, and checks on storage that
+ * every partition holds `commitCount` base-file-only slices, which the
small file limit of 0 is there to ensure.
+ */
+ private def writeFileGroups(tableType: HoodieTableType, partitionCount: Int,
commitCount: Int,
+ partitioned: Boolean = true): FileGroupsTable = {
+ val writeOpts = commonOpts ++ Map(
+ DataSourceWriteOptions.TABLE_TYPE.key -> tableType.name,
+ DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> (if (partitioned)
"partition" else ""),
+ HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key -> "0")
+ val partitionPaths = if (partitioned) (0 until
partitionCount).map(_.toString) else Seq("")
+ val _spark = spark
+ import _spark.implicits._
+ val records = (0 until commitCount).flatMap { commit =>
+ val tuples = for (i <- 0 until 20) yield (s"key-$commit-$i", i %
partitionCount, s"ts-$commit")
+ tuples.toDF("_row_key", "partition", "timestamp")
+ .write.format("hudi").options(writeOpts)
+ .option(DataSourceWriteOptions.OPERATION.key,
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL)
+ .mode(if (commit == 0) SaveMode.Overwrite else SaveMode.Append)
+ .save(basePath)
+ tuples
+ }
+ metaClient = HoodieTableMetaClient.reload(metaClient)
+
+ latestFileSlices(partitionPaths).foreach { case (partitionPath, slices) =>
+ assertEquals(commitCount, slices.size, s"File groups in partition
'$partitionPath'")
+ slices.foreach(slice => assertTrue(slice.getBaseFile.isPresent &&
!slice.hasLogFiles, s"Expected a base-file-only slice: $slice"))
+ }
+ FileGroupsTable(writeOpts, partitionPaths, records.map { case (key, _,
timestamp) => key -> timestamp }.toMap)
+ }
+
+ /**
+ * Upserts records with existing keys and checks on storage that each update
was appended as a log file to
+ * its file group, rather than merged into a new base file or written to a
new file group.
+ */
+ private def upsertToLogFiles(table: FileGroupsTable, updates: Seq[(String,
Int, String)]): Unit = {
+ def baseFiles(slices: Map[String, Seq[FileSlice]]): Set[String] =
+ slices.values.flatten.map(_.getBaseFile.get.getPath).toSet
+ val baseFilesBeforeUpdate =
baseFiles(latestFileSlices(table.partitionPaths))
+
+ val _spark = spark
+ import _spark.implicits._
+ updates.toDF("_row_key", "partition", "timestamp")
+ .write.format("hudi").options(table.writeOpts)
+ .option(DataSourceWriteOptions.OPERATION.key,
DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
+ .mode(SaveMode.Append)
+ .save(basePath)
+ metaClient = HoodieTableMetaClient.reload(metaClient)
+
+ val slices = latestFileSlices(table.partitionPaths)
+ assertEquals(baseFilesBeforeUpdate, baseFiles(slices))
+ val slicesWithLogFiles = slices.values.flatten.filter(_.hasLogFiles).toSeq
+ assertEquals(updates.size, slicesWithLogFiles.size)
+ slicesWithLogFiles.foreach(slice => assertEquals(1L,
slice.getLogFiles.count()))
+ }
+
+ /**
+ * Lists the latest file slices of the partitions from storage, independent
of the file index under test.
+ */
+ private def latestFileSlices(partitionPaths: Seq[String]): Map[String,
Seq[FileSlice]] = {
+ val fileSystemView =
HoodieTableFileSystemView.fileListingBasedFileSystemView(
+ new HoodieJavaEngineContext(new HadoopStorageConfiguration(false)),
metaClient,
+ metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants)
+ try {
+ partitionPaths.map(partitionPath =>
+ partitionPath ->
fileSystemView.getLatestFileSlices(partitionPath).iterator().asScala.toList).toMap
+ } finally {
+ fileSystemView.close()
+ }
+ }
+
+ private def readTable(queryType: String): DataFrame = {
+ val incrementalOpts = if (queryType ==
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL) {
+ Map(DataSourceReadOptions.START_COMMIT.key -> "000")
+ } else {
+ Map.empty[String, String]
+ }
+ spark.read.format("hudi").option(DataSourceReadOptions.QUERY_TYPE.key,
queryType).options(incrementalOpts).load(basePath)
+ }
+
+ private def loadFileIndex(queryType: String): FileIndex = {
+ readTable(queryType).queryExecution.analyzed.collectFirst {
+ case relation: LogicalRelation =>
relation.relation.asInstanceOf[HadoopFsRelation].location
+ }.get
+ }
+
+ private def readTimestamps(queryType: String): Seq[(String, String)] = {
+ readTable(queryType).select("_row_key", "timestamp").collect().map(row =>
(row.getString(0), row.getString(1))).toSeq.sorted
+ }
+
+ /**
+ * Asserts one directory per partition holding all base-file-only slices,
plus one directory per file slice with log files.
+ */
+ private def assertDirectoryLayout(directories: Seq[PartitionDirectory],
partitionCount: Int,
+ baseFileOnlyCount: Int,
fileSlicesWithLogFilesCount: Int): Unit = {
+ val (perSliceDirectories, perPartitionDirectories) =
+
directories.partition(_.values.isInstanceOf[HoodiePartitionFileSliceMapping])
+ assertEquals(partitionCount, perPartitionDirectories.size)
+ assertEquals(partitionCount,
perPartitionDirectories.map(_.values).distinct.size)
+ assertEquals(baseFileOnlyCount,
perPartitionDirectories.map(_.files.size).sum)
+ assertEquals(fileSlicesWithLogFilesCount, perSliceDirectories.size)
+ perSliceDirectories.foreach(directory =>
assertTrue(getMappedSlice(directory).hasLogFiles))
+ }
+
+ private def getMappedSlice(directory: PartitionDirectory): FileSlice = {
+ assertEquals(1, directory.files.size)
+ val fileId = FSUtils.getFileIdFromFilePath(new
StoragePath(directory.files.head.getPath.toString))
+
directory.values.asInstanceOf[HoodiePartitionFileSliceMapping].getSlice(fileId).get
+ }
+
+ /**
+ * Asserts that Spark plans the same file partitions from the listed
directories as from one directory
+ * per file slice. Equal-sized files may swap places between tasks when the
order is not exact.
+ */
+ private def assertSameSplitPlanning(fileIndex: HoodieFileIndex,
+ directories: Seq[PartitionDirectory],
+ exactFileOrder: Boolean): Unit = {
+ val config = new HoodieConfig(TypedProperties.fromMap(queryOpts.asJava))
+ val perSliceDirectories = fileIndex.filterFileSlices(Nil, Nil).flatMap {
case (partitionOpt, slices) =>
+ slices.map(slice =>
PartitionDirectoryConverter.convertFileSliceToPartitionDirectory(
+ InternalRow.fromSeq(partitionOpt.get.getValues), slice, config))
+ }
+ assertTrue(perSliceDirectories.size > directories.size)
+ val maxSplitBytes = 4 * 1024L
+ def plan(dirs: Seq[PartitionDirectory]): Seq[Seq[(String, Long, Long,
InternalRow)]] = {
+ val files = dirs.flatMap(dir => sparkAdapter.splitFiles(spark, dir,
isSplitable = true, maxSplitBytes))
+ .sortBy(_.length)(implicitly[Ordering[Long]].reverse)
+ sparkAdapter.getFilePartitions(spark, files,
maxSplitBytes).map(_.files.toSeq.map(describe))
+ }
+ val expected = plan(perSliceDirectories)
+ val actual = plan(directories)
+ assertEquals(expected.size, actual.size)
+ if (exactFileOrder) {
+ assertEquals(expected, actual)
+ } else {
+ assertEquals(expected.map(_.map(_._3)), actual.map(_.map(_._3)))
+ assertEquals(expected.flatten.sortBy(f => (f._1, f._2)),
actual.flatten.sortBy(f => (f._1, f._2)))
+ }
+ }
+
+ private def describe(file: PartitionedFile): (String, Long, Long,
InternalRow) = {
+ val partitionValues = file.partitionValues match {
+ case mapping: HoodiePartitionFileSliceMapping =>
mapping.getPartitionValues
+ case values => values
+ }
+
(sparkAdapter.getSparkPartitionedFileUtils.getPathFromPartitionedFile(file).toString,
file.start, file.length, partitionValues)
+ }
}
object TestHoodieFileIndex {
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestPartitionDirectoryConverter.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestPartitionDirectoryConverter.scala
index 204b2558960f..d36660b9d7d3 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestPartitionDirectoryConverter.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestPartitionDirectoryConverter.scala
@@ -19,7 +19,7 @@ package org.apache.hudi
import org.apache.hudi.common.config.{HoodieConfig, HoodieStorageConfig,
TypedProperties}
import org.apache.hudi.common.fs.FSUtils
-import org.apache.hudi.common.model.{FileSlice, HoodieBaseFile,
HoodieFileGroupId, HoodieLogFile}
+import org.apache.hudi.common.model.{BaseFile, FileSlice, HoodieBaseFile,
HoodieFileGroupId, HoodieLogFile}
import org.apache.hudi.storage.{StoragePath, StoragePathInfo}
import org.apache.hudi.testutils.HoodieClientTestUtils.getSparkConfForTest
@@ -28,6 +28,7 @@ import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.execution.PartitionedFileUtil
import org.apache.spark.sql.execution.datasources.FilePartition
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@@ -113,6 +114,49 @@ class TestPartitionDirectoryConverter extends
SparkAdapterSupport {
assert(directory.files.head.getLen == recordCount *
fixedSizePerRecordWithParquetFormat)
}
+ /**
+ * Base-file-only slices of a partition share one directory with plain
partition values, while slices
+ * with log files or a bootstrap base each keep a directory whose partition
values map only that slice.
+ */
+ @Test
+ def testConvertFileSlicesToPartitionDirectories(): Unit = {
+ val logFraction = 0.5
+ val config = new HoodieConfig(TypedProperties.fromMap(Map(
+ HoodieStorageConfig.LOGFILE_TO_PARQUET_COMPRESSION_RATIO_FRACTION.key()
-> logFraction.toString).asJava))
+ val partitionValues = InternalRow.fromSeq(Seq("2025-01-01"))
+ val baseOnlySlices = (0 until 3).map(i => buildFileSlice(s"base-$i", 100 +
i, Seq(), logFraction))
+ val baseAndLogSlice = buildFileSlice("base-and-log", 200, Seq(30, 40),
logFraction)
+ val logOnlySlice = buildFileSlice("log-only", 0, Seq(50), logFraction)
+ val bootstrapSlice = new FileSlice(new HoodieFileGroupId(partitionPath,
"bootstrap"), baseInstant)
+ bootstrapSlice.setBaseFile(new
HoodieBaseFile(buildHoodieBaseFile("bootstrap", 300).getPathInfo,
+ new BaseFile(buildHoodieBaseFile("bootstrap-source", 300).getPathInfo)))
+ val slices = Seq(baseOnlySlices(0), baseAndLogSlice, baseOnlySlices(1),
logOnlySlice, bootstrapSlice, baseOnlySlices(2))
+
+ val directories =
PartitionDirectoryConverter.convertFileSlicesToPartitionDirectories(partitionValues,
slices, config)
+
+ assertEquals(4, directories.size)
+ val baseOnlyDirectory = directories.head
+
assertFalse(baseOnlyDirectory.values.isInstanceOf[HoodiePartitionFileSliceMapping])
+ assertEquals(partitionValues, baseOnlyDirectory.values)
+ assertEquals(baseOnlySlices.map(_.getBaseFile.get.getPath),
baseOnlyDirectory.files.map(_.getPath.toString))
+ assertEquals(baseOnlySlices.map(_.getBaseFile.get.getFileSize),
baseOnlyDirectory.files.map(_.getLen))
+
+ val slicesReadAsFileSlice = Seq(baseAndLogSlice, logOnlySlice,
bootstrapSlice)
+ directories.tail.zip(slicesReadAsFileSlice).foreach { case (directory,
slice) =>
+ val perSliceDirectory =
PartitionDirectoryConverter.convertFileSliceToPartitionDirectory(partitionValues,
slice, config)
+ assertEquals(perSliceDirectory.files.map(f => (f.getPath, f.getLen)),
directory.files.map(f => (f.getPath, f.getLen)))
+ assertEquals(1, directory.files.size)
+ val mapping =
directory.values.asInstanceOf[HoodiePartitionFileSliceMapping]
+ assertEquals(partitionValues, mapping.getPartitionValues)
+ assertTrue(mapping.getSlice(slice.getFileId).contains(slice))
+ slices.filter(_ != slice).foreach(other =>
assertTrue(mapping.getSlice(other.getFileId).isEmpty))
+ }
+
+ assertEquals(1,
PartitionDirectoryConverter.convertFileSlicesToPartitionDirectories(partitionValues,
baseOnlySlices, config).size)
+ assertEquals(3,
PartitionDirectoryConverter.convertFileSlicesToPartitionDirectories(partitionValues,
slicesReadAsFileSlice, config).size)
+
assertTrue(PartitionDirectoryConverter.convertFileSlicesToPartitionDirectories(partitionValues,
Seq(), config).isEmpty)
+ }
+
private def verifyBalanceByNum(tasks: Seq[FilePartition], totalRecordNum:
Int, logFraction: Double): Unit = {
val expectedRecordNumPerTask = totalRecordNum / tasks.size
val expectedToleranceMin = expectedRecordNumPerTask * 0.9