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 a11dae45c7dc perf(spark): enable vectorized reading and file splitting
for COW incremental reads (#19914)
a11dae45c7dc is described below
commit a11dae45c7dc2615033694d31e325d240f24c6c9
Author: Prashant Wason <[email protected]>
AuthorDate: Thu Oct 8 12:20:12 2026 -0700
perf(spark): enable vectorized reading and file splitting for COW
incremental reads (#19914)
---
.../hudi/HoodieHadoopFsRelationFactory.scala | 8 +-
.../sql/FileFormatUtilsForFileGroupReader.scala | 1 +
.../HoodieFileGroupReaderBasedFileFormat.scala | 32 ++++-
.../parquet/HoodieFileGroupReaderFunction.scala | 47 ++++++-
.../TestIncrementalReadWithFileGroupReader.scala | 156 +++++++++++++++++++--
.../spark/sql/adapter/BaseSpark3Adapter.scala | 1 +
.../spark/sql/adapter/BaseSpark4Adapter.scala | 1 +
7 files changed, 221 insertions(+), 25 deletions(-)
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
index a4e8f18bf499..f6360e3e3ddd 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
@@ -241,13 +241,15 @@ abstract class HoodieBaseHadoopFsRelationFactory(val
sqlContext: SQLContext,
/** Requested-time range to apply before file-group record merging, when
required by the query. */
protected def getInstantRange: HOption[InstantRange] = HOption.empty()
+ protected def isCDC: Boolean = false
+
override def buildFileFormat(): FileFormat = {
val tableConfig = metaClient.getTableConfig
new HoodieFileGroupReaderBasedFileFormat(basePath.toString,
HoodieTableSchema(tableStructSchema, tableSchema, internalSchemaOpt),
tableConfig.getTableName, queryTimestamp.get, getMandatoryFields, isMOR,
isBootstrap,
isIncremental, validCommits, shouldUseRecordPosition, getRequiredFilters,
- tableConfig.isMultipleBaseFileFormatsEnabled,
tableConfig.getBaseFileFormat, getInstantRange, Some(metaClient))
+ tableConfig.isMultipleBaseFileFormatsEnabled,
tableConfig.getBaseFileFormat, getInstantRange, Some(metaClient), isCDC)
}
override def buildBucketSpec(): Option[BucketSpec] = None
@@ -362,6 +364,8 @@ class HoodieMergeOnReadCDCHadoopFsRelationFactory(override
val sqlContext: SQLCo
override def buildPartitionSchema(): StructType = StructType(Nil)
override protected def getRequiredFilters: Seq[Filter] = Seq.empty
+
+ override protected def isCDC: Boolean = true
}
class HoodieCopyOnWriteSnapshotHadoopFsRelationFactory(override val
sqlContext: SQLContext,
@@ -477,4 +481,6 @@ class HoodieCopyOnWriteCDCHadoopFsRelationFactory(override
val sqlContext: SQLCo
override def buildPartitionSchema(): StructType = StructType(Nil)
override protected def getRequiredFilters: Seq[Filter] = Seq.empty
+
+ override protected def isCDC: Boolean = true
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/FileFormatUtilsForFileGroupReader.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/FileFormatUtilsForFileGroupReader.scala
index ae6740b3017a..6ad50c8340a4 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/FileFormatUtilsForFileGroupReader.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/FileFormatUtilsForFileGroupReader.scala
@@ -34,6 +34,7 @@ object FileFormatUtilsForFileGroupReader extends
SparkAdapterSupport {
def applyNewFileFormatChanges(scanOperation: LogicalPlan, logicalRelation:
LogicalPlan, fs: HadoopFsRelation): LogicalPlan = {
val ff = fs.fileFormat.asInstanceOf[ParquetFileFormat with
HoodieFormatTrait]
ff.isProjected = true
+ ff.requiredFiltersAppliedInPlan = true
val tableSchema = fs.location match {
case _: HoodieCDCFileIndex => HoodieCDCFileIndex.FULL_CDC_SPARK_SCHEMA
case index: SparkHoodieTableFileIndex => index.schema
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
index 16f9a7554756..6849798d259e 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
@@ -60,6 +60,8 @@ trait HoodieFormatTrait {
// Used so that the planner only projects once and does not stack overflow
var isProjected: Boolean = false
+ // Set when the plan already wraps the scan in a Filter on the required
filters, so the scan need not enforce them
+ var requiredFiltersAppliedInPlan: Boolean = false
def getRequiredFilters: Seq[Filter]
}
@@ -70,6 +72,8 @@ trait HoodieFormatTrait {
* @param instantRangeOpt optional requested-time range applied before
file-group record merging;
* unlike Spark's required filters, this prevents a
later out-of-range log
* record from masking an earlier in-range version of
the same key
+ * @param isCDC whether this is a CDC query; CDC rows are produced
by the CDC iterator rather than read
+ * directly from base files, so CDC reads are neither
vectorized nor split
*/
class HoodieFileGroupReaderBasedFileFormat(tablePath: String,
tableSchema: HoodieTableSchema,
@@ -85,7 +89,8 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String,
isMultipleBaseFileFormatsEnabled:
Boolean,
hoodieFileFormat: HoodieFileFormat,
instantRangeOpt:
HOption[InstantRange] = HOption.empty(),
- @transient tableMetaClient:
Option[HoodieTableMetaClient] = None)
+ @transient tableMetaClient:
Option[HoodieTableMetaClient] = None,
+ isCDC: Boolean = false)
extends ParquetFileFormat with SparkAdapterSupport with HoodieFormatTrait
with Logging with Serializable {
private lazy val schema = tableSchema.schema
@@ -113,6 +118,8 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
private val sanitizedTableName =
HoodieSchemaUtils.getRecordQualifiedName(tableName)
+ private def evaluateRequiredFiltersPerRow: Boolean =
requiredFilters.nonEmpty && !requiredFiltersAppliedInPlan
+
/**
* Flag saying whether vectorized reading is supported.
*/
@@ -222,10 +229,18 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
} else {
throw new HoodieNotSupportedException("Unsupported file format: " +
hoodieFileFormat)
}
- supportVectorizedRead = !isIncremental && !isBootstrap && supportBatch
- supportReturningBatch = !isMOR && supportVectorizedRead
+ // MOR incremental embeds file slices that may contain log files
requiring row-level
+ // merging, and CDC rows are built by a row-based iterator that reads
base files through
+ // the same reader, so vectorized reading must be disabled for both. All
other combinations
+ // (COW snapshot, COW incremental, MOR snapshot) either have no log
merging or handle it via
+ // a separate non-vectorized fileGroupBaseFileReader while the base file
reader stays vectorized.
+ supportVectorizedRead = !(isIncremental && (isMOR || isCDC)) &&
!isBootstrap && supportBatch
+ // Required filters (the commit-time range of an incremental query) must
hold for every row, but the
+ // vectorized reader only uses them to prune row groups. Unless the plan
already filters on them, return
+ // rows rather than batches so that the base file read can evaluate them
row by row; decoding stays vectorized.
+ supportReturningBatch = !isMOR && supportVectorizedRead &&
!evaluateRequiredFiltersPerRow
logDebug(s"supportReturningBatch: $supportReturningBatch,
supportVectorizedRead: $supportVectorizedRead, isIncremental: $isIncremental, "
+
- s"isBootstrap: $isBootstrap, superSupportBatch: $supportBatch")
+ s"isCDC: $isCDC, isBootstrap: $isBootstrap, superSupportBatch:
$supportBatch")
supportReturningBatch
}
}
@@ -277,8 +292,11 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
// For overly large single files, we can use multiple concurrent tasks to
read them, thereby reducing the overall job reading time consumption
val superSplitable = super.isSplitable(sparkSession, options, path)
val isLance = hoodieFileFormat == HoodieFileFormat.LANCE
- val splitable = !isMOR && !isIncremental && !isBootstrap && !isLance &&
superSplitable
- logDebug(s"isSplitable: $splitable, super.isSplitable: $superSplitable,
isMOR: $isMOR, isIncremental: $isIncremental, isBootstrap: $isBootstrap")
+ // COW incremental reads have no log files to merge, so file splitting is
safe.
+ // MOR, CDC and bootstrap reads need to disable splitting.
+ val splitable = !isMOR && !isCDC && !isBootstrap && !isLance &&
superSplitable
+ logDebug(s"isSplitable: $splitable, super.isSplitable: $superSplitable,
isMOR: $isMOR, isIncremental: $isIncremental, " +
+ s"isCDC: $isCDC, isBootstrap: $isBootstrap")
splitable
}
@@ -385,7 +403,7 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
val state = new HoodieFileGroupReadState(tableState, tableSchema,
queryTimestamp, readerProps, cdcProps,
dataSchema, requestedSchema, internalSchemaOpt, instantRangeOpt,
shouldUseRecordPosition, isCount, filters,
requiredFilters, requiredSchema, partitionSchema,
remainingPartitionSchema, fixedPartitionIndexes, outputSchema,
- projectionInputSchema, baseFileReadSchemas)
+ projectionInputSchema, baseFileReadSchemas,
evaluateRequiredFiltersPerRow)
new HoodieFileGroupReaderFunction(baseFileReader, fileGroupBaseFileReader,
broadcastedStorageConf,
spark.sparkContext.broadcast(JavaSerializedValue(state)))
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderFunction.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderFunction.scala
index 370e35a5b961..6887f4848647 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderFunction.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderFunction.scala
@@ -30,6 +30,7 @@ import
org.apache.hudi.common.table.read.lsm.{HoodieLsmFileGroupReader, LsmReade
import org.apache.hudi.common.util.{ConfigUtils, Option => HOption}
import org.apache.hudi.common.util.collection.ClosableIterator
import org.apache.hudi.data.CloseableIteratorListener
+import org.apache.hudi.exception.HoodieException
import
org.apache.hudi.io.storage.HoodieSparkParquetReader.ENABLE_LOGICAL_TIMESTAMP_REPAIR
import org.apache.hudi.io.storage.VectorConversionUtils
import org.apache.hudi.storage.StorageConfiguration
@@ -39,9 +40,10 @@ import org.apache.hadoop.conf.Configuration
import org.apache.parquet.schema.MessageType
import org.apache.spark.SparkEnv
import org.apache.spark.broadcast.Broadcast
+import org.apache.spark.sql.HoodieCatalystExpressionUtils
import
org.apache.spark.sql.HoodieCatalystExpressionUtils.generateUnsafeProjection
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{JoinedRow, UnsafeProjection}
+import org.apache.spark.sql.catalyst.expressions.{And, AttributeReference, In,
InSet, JoinedRow, Literal, Predicate, UnsafeProjection}
import org.apache.spark.sql.execution.datasources.{PartitionedFile,
SparkColumnarFileReader, SparkSchemaTransformUtils}
import org.apache.spark.sql.sources.Filter
import org.apache.spark.sql.types.StructType
@@ -78,7 +80,8 @@ private[parquet] class HoodieFileGroupReadState(val
tableState: TableState,
val fixedPartitionIndexes:
Set[Int],
val outputSchema: StructType,
val projectionInputSchema:
StructType,
- val baseFileReadSchemas:
BaseFileReadSchemas) extends Serializable {
+ val baseFileReadSchemas:
BaseFileReadSchemas,
+ val
evaluateRequiredFiltersPerRow: Boolean) extends Serializable {
/**
* Parquet form of the table schema for base file reads. The conversion
reads Hadoop's default resources, so it is
@@ -233,6 +236,7 @@ private[parquet] class
HoodieFileGroupReaderFunction(baseFileReader: Broadcast[S
val hasVectors = schemas.readVectorColumns.nonEmpty
val parquetFileReader = baseFileReader.value
val filters = state.filters ++ state.requiredFilters
+ val requiredFilters = if (state.evaluateRequiredFiltersPerRow)
state.requiredFilters else Seq.empty
val partitionSchema = state.partitionSchema
val remainingPartitionSchema = state.remainingPartitionSchema
@@ -240,7 +244,8 @@ private[parquet] class
HoodieFileGroupReaderFunction(baseFileReader: Broadcast[S
//none of partition fields are read from the file, so the reader will do
the appending for us
val iter = parquetFileReader.read(file, schemas.readRequiredSchema,
partitionSchema, state.internalSchemaOpt, filters, conf,
state.tableSchemaAsMessageType)
- projectIfNeeded(iter, StructType(schemas.readRequiredSchema.fields ++
partitionSchema.fields), schemas.outputSchema)
+ val readSchema = StructType(schemas.readRequiredSchema.fields ++
partitionSchema.fields)
+ projectIfNeeded(applyRequiredFilters(iter, readSchema, requiredFilters),
readSchema, schemas.outputSchema)
} else if (remainingPartitionSchema.fields.length == 0) {
//we read all of the partition fields from the file
val pfileUtils = sparkAdapter.getSparkPartitionedFileUtils
@@ -250,7 +255,7 @@ private[parquet] class
HoodieFileGroupReaderFunction(baseFileReader: Broadcast[S
//and we pass an empty schema for the partition schema
val iter = parquetFileReader.read(modifiedFile, readSchema, new
StructType(), state.internalSchemaOpt, filters, conf,
state.tableSchemaAsMessageType)
- projectIfNeeded(iter, readSchema, schemas.outputSchema)
+ projectIfNeeded(applyRequiredFilters(iter, readSchema, requiredFilters),
readSchema, schemas.outputSchema)
} else {
//need to do an additional projection here. The case in mind is that
partition schema is "a,b,c" mandatoryFields is "a,c",
//then we will read (dataSchema + a + c) and append b. So the final
schema will be (data schema + a + c +b)
@@ -260,7 +265,8 @@ private[parquet] class
HoodieFileGroupReaderFunction(baseFileReader: Broadcast[S
val modifiedFile = pfileUtils.createPartitionedFile(partitionValues,
pfileUtils.getPathFromPartitionedFile(file), file.start, file.length)
val iter = parquetFileReader.read(modifiedFile, schemas.requestedSchema,
remainingPartitionSchema, state.internalSchemaOpt, filters, conf,
state.tableSchemaAsMessageType)
- projectIter(iter, StructType(schemas.requestedSchema.fields ++
remainingPartitionSchema.fields), schemas.outputSchema)
+ val readSchema = StructType(schemas.requestedSchema.fields ++
remainingPartitionSchema.fields)
+ projectIter(applyRequiredFilters(iter, readSchema, requiredFilters),
readSchema, schemas.outputSchema)
}
if (hasVectors) {
@@ -349,6 +355,37 @@ private[parquet] object HoodieFileGroupReaderFunction {
iter.map(mapper.apply(_))
}
+ /**
+ * Evaluates `requiredFilters` on every row. Pushed-down filters only prune
row groups in the vectorized reader,
+ * so when the plan does not filter on them (the Hudi session extension is
not registered), the commit-time
+ * range of an incremental query is enforced here. The scan returns rows
rather than batches in that case.
+ */
+ private def applyRequiredFilters(iter: Iterator[InternalRow],
+ schema: StructType,
+ requiredFilters: Seq[Filter]):
Iterator[InternalRow] = {
+ if (requiredFilters.isEmpty) {
+ iter
+ } else {
+ val attrs =
SparkAdapterSupport.sparkAdapter.getSchemaUtils.toAttributes(schema)
+ val condition = requiredFilters.map { filter =>
+ HoodieCatalystExpressionUtils.convertToCatalystExpression(filter,
schema).getOrElse(
+ throw new HoodieException(s"Cannot evaluate required filter:
$filter"))
+ }.reduce(And).transform {
+ case a: AttributeReference => attrs(schema.fieldIndex(a.name))
+ }.transform {
+ // A set lookup rather than a chain of comparisons for the commit
times of an incremental range
+ case In(child, list) if list.forall(_.isInstanceOf[Literal]) &&
list.nonEmpty =>
+ InSet(child, list.map(_.eval()).toSet)
+ }
+ val predicate = Predicate.create(condition, attrs)
+ predicate.initialize(0)
+ iter.asInstanceOf[Iterator[Any]].filter {
+ case _: ColumnarBatch => throw new IllegalStateException("Required
filters cannot be evaluated on columnar batches")
+ case row: InternalRow => predicate.eval(row)
+ }.asInstanceOf[Iterator[InternalRow]]
+ }
+ }
+
private def projectIter(iter: Iterator[Any], from: StructType, to:
StructType): Iterator[InternalRow] = {
val unsafeProjection = generateUnsafeProjection(from, to)
val batchProjection = ColumnarBatchUtils.generateProjection(from, to)
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
index 5b3c6581eede..b8c9e9652d1f 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
@@ -18,12 +18,13 @@
package org.apache.hudi.functional
import org.apache.hudi.{DataSourceReadOptions, DataSourceWriteOptions,
MergeOnReadIncrementalRelationV2}
-import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.config.{HoodieMetadataConfig,
HoodieStorageConfig, TimestampKeyGeneratorConfig}
import org.apache.hudi.common.fs.FSUtils
import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
import
org.apache.hudi.common.table.read.IncrementalQueryAnalyzer.START_COMMIT_EARLIEST
import org.apache.hudi.common.table.timeline.HoodieInstant
import org.apache.hudi.config.{HoodieArchivalConfig, HoodieCleanConfig,
HoodieCompactionConfig, HoodieWriteConfig}
+import org.apache.hudi.keygen.{CustomKeyGenerator, TimestampBasedKeyGenerator}
import org.apache.hudi.storage.StoragePath
import org.apache.hudi.testutils.SparkClientFunctionalTestHarness
@@ -31,7 +32,9 @@ import org.apache.hadoop.fs.Path
import org.apache.spark.sql.{DataFrame, SaveMode}
import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
import org.junit.jupiter.params.ParameterizedTest
-import org.junit.jupiter.params.provider.CsvSource
+import org.junit.jupiter.params.provider.{CsvSource, ValueSource}
+
+import java.util.UUID
import scala.collection.JavaConverters._
@@ -64,18 +67,20 @@ class TestIncrementalReadWithFileGroupReader extends
SparkClientFunctionalTestHa
@ParameterizedTest
@CsvSource(value = Array(
- "COPY_ON_WRITE,6,6",
- "COPY_ON_WRITE,8,6",
- "COPY_ON_WRITE,8,8",
- "MERGE_ON_READ,6,6",
- "MERGE_ON_READ,6,8",
- "MERGE_ON_READ,8,6",
- "MERGE_ON_READ,8,8"
+ "COPY_ON_WRITE,6,6,PARQUET",
+ "COPY_ON_WRITE,8,6,PARQUET",
+ "COPY_ON_WRITE,8,8,PARQUET",
+ "COPY_ON_WRITE,6,6,ORC",
+ "COPY_ON_WRITE,8,8,ORC",
+ "MERGE_ON_READ,6,6,PARQUET",
+ "MERGE_ON_READ,6,8,PARQUET",
+ "MERGE_ON_READ,8,6,PARQUET",
+ "MERGE_ON_READ,8,8,PARQUET"
))
- def testIncrementalReadRanges(tableType: String, sourceVersion: Int,
readVersion: Int): Unit = {
+ def testIncrementalReadRanges(tableType: String, sourceVersion: Int,
readVersion: Int, baseFileFormat: String): Unit = {
batches.zipWithIndex.foreach { case ((data, operation), i) =>
val mode = if (i == 0) SaveMode.Overwrite else SaveMode.Append
- write(data, tableType, sourceVersion, operation, mode)
+ write(data, tableType, sourceVersion, operation, mode,
Map(HoodieTableConfig.BASE_FILE_FORMAT.key -> baseFileFormat))
if (i == 2) {
// small file handling must have kept a single file group with base
files only
val (baseFiles, logFiles) = listDataFiles()
@@ -99,7 +104,8 @@ class TestIncrementalReadWithFileGroupReader extends
SparkClientFunctionalTestHa
}
// records merged into the latest base file keep their original commit
times
val latestBaseFile = baseFiles.maxBy(name => FSUtils.getCommitTime(name))
- val commitTimesInBaseFile = spark.read.parquet(new Path(new Path(basePath,
"pt1"), latestBaseFile).toString)
+ val commitTimesInBaseFile = spark.read.format(baseFileFormat.toLowerCase)
+ .load(new Path(new Path(basePath, "pt1"), latestBaseFile).toString)
.select("_hoodie_commit_time").distinct().count()
assertTrue(commitTimesInBaseFile > 1,
s"Expected multiple commit times in the latest base file, got
$commitTimesInBaseFile")
@@ -126,6 +132,132 @@ class TestIncrementalReadWithFileGroupReader extends
SparkClientFunctionalTestHa
assertIncrementalRange(readVersion, instants, 6, 6, Set.empty)
}
+ /**
+ * COW incremental reads are splittable: a range read whose base file is
split into several
+ * input partitions must return the same rows as the unsplit read, with the
commit-time range
+ * enforced in every split.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = Array("PARQUET", "ORC"))
+ def testCowIncrementalReadWithMultipleSplitsPerFile(baseFileFormat: String):
Unit = {
+ val options = Map(
+ HoodieTableConfig.BASE_FILE_FORMAT.key -> baseFileFormat,
+ HoodieStorageConfig.PARQUET_BLOCK_SIZE.key -> "65536",
+ HoodieStorageConfig.PARQUET_PAGE_SIZE.key -> "8192",
+ HoodieStorageConfig.ORC_STRIPE_SIZE.key -> "65536",
+ HoodieStorageConfig.ORC_BLOCK_SIZE.key -> "65536")
+ def rows(commit: Int, keys: Range): Seq[(Int, String, String, Double,
String)] = keys.map { i =>
+ (commit, f"k$i%05d",
UUID.nameUUIDFromBytes(s"$i-$commit".getBytes).toString * 3, commit.toDouble,
"pt1")
+ }
+ val c1Keys = 0 until 2000
+ val c2Keys = 2000 until 4000
+ val c3Keys = 4000 until 6000
+ // c1..c3 insert disjoint keys into one file group; c4 updates the keys of
c1, so every base file
+ // from c2 on carries rows of earlier commits that the range must filter
out
+ write(rows(1, c1Keys), "COPY_ON_WRITE", 8,
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL, SaveMode.Overwrite, options)
+ write(rows(2, c2Keys), "COPY_ON_WRITE", 8,
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL, SaveMode.Append, options)
+ write(rows(3, c3Keys), "COPY_ON_WRITE", 8,
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL, SaveMode.Append, options)
+ write(rows(4, c1Keys), "COPY_ON_WRITE", 8,
DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL, SaveMode.Append, options)
+ val (baseFiles, _) = listDataFiles()
+ assertEquals(1, baseFiles.map(FSUtils.getFileId).distinct.size, "Expected
a single file group")
+
+ val metaClient = HoodieTableMetaClient.builder()
+ .setConf(storageConf().newInstance()).setBasePath(basePath()).build()
+ val instants =
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants
+ .getInstants.asScala.toList
+ assertEquals(4, instants.size)
+
+ def keysAndTs(df: DataFrame): Set[(String, Int)] =
+ df.select("key", "ts").collect().map(r => (r.getString(0),
r.getInt(1))).toSet
+
+ // (c1, c3]: c2 and c3 rows, without the c1 rows carried over in the same
base file
+ val c1c3 = (instants(0).getCompletionTime, instants(2).getCompletionTime)
+ val expectedC1C3 = c2Keys.map(i => (f"k$i%05d", 2)).toSet ++ c3Keys.map(i
=> (f"k$i%05d", 3)).toSet
+ // (c3, c4]: only the c4 updates of the c1 keys
+ val c3c4 = (instants(2).getCompletionTime, instants(3).getCompletionTime)
+ val expectedC3C4 = c1Keys.map(i => (f"k$i%05d", 4)).toSet
+ assertEquals(expectedC1C3, keysAndTs(readIncremental(8, c1c3._1, c1c3._2)))
+ assertEquals(expectedC3C4, keysAndTs(readIncremental(8, c3c4._1, c3c4._2)))
+
+ spark.conf.set("spark.sql.files.maxPartitionBytes", "65536")
+ try {
+ Seq((c1c3, expectedC1C3), (c3c4, expectedC3C4)).foreach { case ((start,
end), expected) =>
+ val df = readIncremental(8, start, end)
+ assertTrue(df.rdd.getNumPartitions > df.inputFiles.length,
+ s"Expected more input partitions than files, got
${df.rdd.getNumPartitions} for ${df.inputFiles.length} files")
+ assertEquals(expected, keysAndTs(df))
+ assertEquals(expected.size.toLong, readIncremental(8, start,
end).count())
+ }
+ } finally {
+ spark.conf.unset("spark.sql.files.maxPartitionBytes")
+ }
+ }
+
+ /**
+ * COW incremental reads where a partition column is read from the base file
rather than the
+ * partition path (timestamp-based partitioning), so the commit-time range
is evaluated against a
+ * read schema that carries the mandatory partition fields. TIMESTAMP reads
every partition column
+ * from the file; CUSTOM reads one from the file and appends the other from
the path.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = Array("TIMESTAMP", "CUSTOM"))
+ def testCowIncrementalReadWithPartitionValuesReadFromFile(keyGenType:
String): Unit = {
+ val (keyGenClass, partitionFields) = keyGenType match {
+ case "TIMESTAMP" => (classOf[TimestampBasedKeyGenerator].getName,
"event_ts")
+ case "CUSTOM" => (classOf[CustomKeyGenerator].getName,
"pt:SIMPLE,event_ts:TIMESTAMP")
+ }
+ // all records fall on 2023-11-14 UTC so that they share one partition and
file group
+ def write(data: Seq[(Int, String, String, Long, String)], operation:
String, mode: SaveMode): Unit = {
+ spark.createDataFrame(data).toDF("ts", "key", "rider", "event_ts",
"pt").write.format("hudi")
+ .option(DataSourceWriteOptions.RECORDKEY_FIELD.key, "key")
+ .option(DataSourceWriteOptions.PARTITIONPATH_FIELD.key,
partitionFields)
+ .option(DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME.key,
keyGenClass)
+ .option(TimestampKeyGeneratorConfig.TIMESTAMP_TYPE_FIELD.key,
"EPOCHMILLISECONDS")
+ .option(TimestampKeyGeneratorConfig.TIMESTAMP_OUTPUT_DATE_FORMAT.key,
"yyyyMMdd")
+
.option(TimestampKeyGeneratorConfig.TIMESTAMP_OUTPUT_TIMEZONE_FORMAT.key, "UTC")
+ .option(HoodieTableConfig.ORDERING_FIELDS.key, "ts")
+ .option(DataSourceWriteOptions.TABLE_TYPE.key, "COPY_ON_WRITE")
+ .option(DataSourceWriteOptions.TABLE_NAME.key,
"test_incr_read_fgr_keygen")
+ .option(DataSourceWriteOptions.OPERATION.key, operation)
+ .option("hoodie.insert.shuffle.parallelism", "2")
+ .option("hoodie.upsert.shuffle.parallelism", "2")
+ .mode(mode)
+ .save(basePath)
+ }
+ def eventTs(ts: Int): Long = 1699920000000L + ts * 1000L
+ write(Seq((1, "k1", "rider-c1", eventTs(1), "pt1"), (1, "k2", "rider-c1",
eventTs(1), "pt1")),
+ DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL, SaveMode.Overwrite)
+ write(Seq((2, "k3", "rider-c2", eventTs(2), "pt1"), (2, "k4", "rider-c2",
eventTs(2), "pt1")),
+ DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL, SaveMode.Append)
+ // c3 rewrites the base file with k1 updated, carrying the c1 and c2 rows
over
+ write(Seq((3, "k1", "rider-c3", eventTs(1), "pt1")),
+ DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL, SaveMode.Append)
+
+ val metaClient = HoodieTableMetaClient.builder()
+ .setConf(storageConf().newInstance()).setBasePath(basePath()).build()
+ val instants =
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants
+ .getInstants.asScala.toList
+ assertEquals(3, instants.size)
+
+ // event_ts is compared as text: the timestamp key generator types it as a
string partition column
+ def assertRange(start: String, end: String, expected: Set[(String, Int,
Long, String)]): Unit = {
+ val rows = readIncremental(8, start, end).select("key", "ts",
"event_ts", "pt").collect()
+ .map(r => (r.getString(0), r.getInt(1), String.valueOf(r.get(2)),
r.getString(3))).toSet
+ assertEquals(expected.map(e => (e._1, e._2, e._3.toString, e._4)), rows)
+ assertEquals(expected.size.toLong, readIncremental(8, start,
end).count())
+ }
+ // (000, c2]: the rows as of c2, including the c1 version of k1 that c3
later updates
+ assertRange("000", instants(1).getCompletionTime,
+ Set(("k1", 1, eventTs(1), "pt1"), ("k2", 1, eventTs(1), "pt1"), ("k3",
2, eventTs(2), "pt1"),
+ ("k4", 2, eventTs(2), "pt1")))
+ // (c1, c2]: the c2 rows only
+ assertRange(instants(0).getCompletionTime, instants(1).getCompletionTime,
+ Set(("k3", 2, eventTs(2), "pt1"), ("k4", 2, eventTs(2), "pt1")))
+ // (c2, c3]: the c3 update of k1
+ assertRange(instants(1).getCompletionTime, instants(2).getCompletionTime,
+ Set(("k1", 3, eventTs(1), "pt1")))
+ }
+
@ParameterizedTest(name = "V2 MOR full-table scan applies instant range,
table version = {0}, legacy RDD = {1}")
@CsvSource(value = Array("6,false", "6,true", "8,false", "8,true"))
def testV2MorFullTableScanAppliesInstantRange(sourceVersion: Int,
useLegacyRdd: Boolean): Unit = {
diff --git
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
index 92010be7f336..0f79c8c5ec06 100644
---
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
+++
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
@@ -188,6 +188,7 @@ abstract class BaseSpark3Adapter extends SparkAdapter with
Logging {
def createStreamingDataFrame(sqlContext: SQLContext, relation:
HadoopFsRelation, requiredSchema: StructType): DataFrame = {
val logicalRelation = LogicalRelation(relation, isStreaming = true)
val resolvedSchema = logicalRelation.resolve(requiredSchema,
sqlContext.sparkSession.sessionState.analyzer.resolver)
+
relation.fileFormat.asInstanceOf[HoodieFormatTrait].requiredFiltersAppliedInPlan
= true
Dataset.ofRows(sqlContext.sparkSession,
applyFiltersToPlan(logicalRelation, requiredSchema, resolvedSchema,
relation.fileFormat.asInstanceOf[HoodieFormatTrait].getRequiredFilters))
}
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
index 2f049cbedce6..977a1f51d976 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
+++
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
@@ -180,6 +180,7 @@ abstract class BaseSpark4Adapter extends SparkAdapter with
Logging {
def createStreamingDataFrame(sqlContext: SQLContext, relation:
HadoopFsRelation, requiredSchema: StructType): DataFrame = {
val logicalRelation = LogicalRelation(relation, isStreaming = true)
val resolvedSchema = logicalRelation.resolve(requiredSchema,
sqlContext.sparkSession.sessionState.analyzer.resolver)
+
relation.fileFormat.asInstanceOf[HoodieFormatTrait].requiredFiltersAppliedInPlan
= true
org.apache.spark.sql.classic.Dataset.ofRows(sqlContext.sparkSession.asInstanceOf[org.apache.spark.sql.classic.SparkSession],
applyFiltersToPlan(logicalRelation, requiredSchema, resolvedSchema,
relation.fileFormat.asInstanceOf[HoodieFormatTrait].getRequiredFilters))