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 296f5d8283b9 fix(spark): fix incremental file index reporting 
sizeInBytes=0 (#19912)
296f5d8283b9 is described below

commit 296f5d8283b9d6c576308a2bb78ddb7847fd090a
Author: Prashant Wason <[email protected]>
AuthorDate: Tue Oct 6 17:03:36 2026 -0700

    fix(spark): fix incremental file index reporting sizeInBytes=0 (#19912)
---
 .../scala/org/apache/hudi/DataSourceOptions.scala  |  14 +++
 .../apache/hudi/HoodieIncrementalFileIndex.scala   |  16 ++-
 .../hudi/MergeOnReadIncrementalRelationV1.scala    |   5 +
 .../hudi/MergeOnReadIncrementalRelationV2.scala    |   6 +
 .../TestIncrementalFileIndexSizeInBytes.scala      | 127 +++++++++++++++++++++
 5 files changed, 167 insertions(+), 1 deletion(-)

diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala
index 6191331d4215..899b93a386f9 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala
@@ -181,6 +181,20 @@ object DataSourceReadOptions {
     .withDocumentation("Whether to skip clustering instants to avoid reading 
base files of clustering operations "
       + "for streaming read to improve read performance.")
 
+  val INCREMENTAL_SIZE_ESTIMATION_ENABLE: ConfigProperty[Boolean] = 
ConfigProperty
+    .key("hoodie.datasource.read.incr.size.estimation.enable")
+    .defaultValue(false)
+    .markAdvanced()
+    .sinceVersion("1.3.0")
+    .withDocumentation("When enabled, the incremental file index reports the 
total size of the files written by "
+      + "the commits in the queried range, taken from commit metadata, as its 
sizeInBytes to the Spark planner, "
+      + "which may allow Spark to broadcast small incremental reads. When 
disabled, it reports "
+      + "spark.sql.defaultSizeInBytes (the default of 
BaseRelation.sizeInBytes, Long.MaxValue unless configured), "
+      + "matching the pre-1.0 IncrementalRelation and preventing Spark from 
choosing BroadcastHashJoin for any "
+      + "subexpression that includes the incremental source. Spark's 
selectivity estimates on filtered "
+      + "subexpressions of the incremental source can otherwise drop below the 
broadcast threshold, causing "
+      + "broadcast timeouts on large tables.")
+
   val TIME_TRAVEL_AS_OF_INSTANT: ConfigProperty[String] = 
HoodieCommonConfig.TIMESTAMP_AS_OF
 
   val ENABLE_DATA_SKIPPING: ConfigProperty[Boolean] = ConfigProperty
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 0568af6e9409..5b5312643aac 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
@@ -20,7 +20,7 @@ package org.apache.hudi
 import org.apache.hudi.common.model.HoodieLogFile
 import org.apache.hudi.common.table.HoodieTableMetaClient
 import org.apache.hudi.storage.StoragePathInfo
-import org.apache.hudi.util.JFunction
+import org.apache.hudi.util.{JFunction, SparkConfigUtils}
 
 import org.apache.spark.sql.SparkSession
 import org.apache.spark.sql.catalyst.expressions.Expression
@@ -69,6 +69,20 @@ class HoodieIncrementalFileIndex(override val spark: 
SparkSession,
     }).map(fileStatus => fileStatus.getPath.toString).toArray
   }
 
+  private val sizeEstimationEnabled: Boolean = 
SparkConfigUtils.getStringWithAltKeys(
+    options, 
DataSourceReadOptions.INCREMENTAL_SIZE_ESTIMATION_ENABLE).toBoolean
+
+  override def sizeInBytes: Long = {
+    lazy val defaultSizeInBytes = spark.sessionState.conf.defaultSizeInBytes
+    if (sizeEstimationEnabled) {
+      // Long.MaxValue means the relation falls back to a full table scan, 
whose size is unknown here.
+      val actualSize = mergeOnReadIncrementalRelation.getIncrementalFilesSize
+      if (actualSize > 0 && actualSize < Long.MaxValue) actualSize else 
defaultSizeInBytes
+    } else {
+      defaultSizeInBytes
+    }
+  }
+
   def getRequiredFilters: Seq[Filter] = {
     mergeOnReadIncrementalRelation.getRequiredFilters
   }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV1.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV1.scala
index 0cc4be8df532..fe0996e49e63 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV1.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV1.scala
@@ -156,6 +156,11 @@ case class MergeOnReadIncrementalRelationV1(override val 
sqlContext: SQLContext,
     }
   }
 
+  override def getIncrementalFilesSize: Long = {
+    // A full table scan lists the whole table, so the affected files would 
underestimate its size.
+    if (fullTableScan) Long.MaxValue else 
affectedFilesInCommits.asScala.map(_.getLength).sum
+  }
+
   override def shouldIncludeLogFiles(): Boolean = fullTableScan
 
   private def filterFileSlices(fileSlices: Seq[FileSlice], pathGlobPattern: 
String): Seq[FileSlice] = {
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV2.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV2.scala
index fb4ef6c38342..796e43ae8c61 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV2.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadIncrementalRelationV2.scala
@@ -47,6 +47,7 @@ trait MergeOnReadIncrementalRelation {
   def listFileSplits(partitionFilters: Seq[Expression], dataFilters: 
Seq[Expression]): Map[InternalRow, Seq[FileSlice]]
   def getRequiredFilters: Seq[Filter]
   def getInstantRange: HOption[InstantRange] = HOption.empty()
+  def getIncrementalFilesSize: Long
 }
 
 case class MergeOnReadIncrementalRelationV2(override val sqlContext: 
SQLContext,
@@ -161,6 +162,11 @@ case class MergeOnReadIncrementalRelationV2(override val 
sqlContext: SQLContext,
   override def getInstantRange: HOption[InstantRange] =
     if (fullTableScan) queryContext.getInstantRange else HOption.empty()
 
+  override def getIncrementalFilesSize: Long = {
+    // A full table scan lists the whole table, so the affected files would 
underestimate its size.
+    if (fullTableScan) Long.MaxValue else 
affectedFilesInCommits.asScala.map(_.getLength).sum
+  }
+
   override def shouldIncludeLogFiles(): Boolean = fullTableScan
 
   private def filterFileSlices(fileSlices: Seq[FileSlice], pathGlobPattern: 
String): Seq[FileSlice] = {
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalFileIndexSizeInBytes.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalFileIndexSizeInBytes.scala
new file mode 100644
index 000000000000..1029eb089f48
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalFileIndexSizeInBytes.scala
@@ -0,0 +1,127 @@
+/*
+ * 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, 
HoodieIncrementalFileIndex}
+import org.apache.hudi.common.model.HoodieTableType
+import org.apache.hudi.common.table.HoodieTableConfig
+import 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.recordsToStrings
+import org.apache.hudi.common.testutils.HoodieTestUtils
+import org.apache.hudi.config.HoodieWriteConfig
+import org.apache.hudi.testutils.{DataSourceTestUtils, 
HoodieSparkClientTestBase}
+
+import org.apache.spark.sql.{DataFrame, SaveMode, SparkSession}
+import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, 
LogicalRelation}
+import org.junit.jupiter.api.{AfterEach, BeforeEach}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.EnumSource
+
+import scala.collection.JavaConverters._
+
+/**
+ * Verifies the size reported to the Spark planner by 
[[HoodieIncrementalFileIndex]], which must never be 0
+ * or Spark may pick a broadcast join against the incremental source.
+ */
+class TestIncrementalFileIndexSizeInBytes extends HoodieSparkClientTestBase {
+
+  var spark: SparkSession = null
+
+  val commonOpts = Map(
+    "hoodie.insert.shuffle.parallelism" -> "4",
+    "hoodie.upsert.shuffle.parallelism" -> "4",
+    DataSourceWriteOptions.RECORDKEY_FIELD.key -> "_row_key",
+    DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "partition",
+    HoodieTableConfig.ORDERING_FIELDS.key -> "timestamp",
+    HoodieWriteConfig.TBL_NAME.key -> "hoodie_test"
+  )
+
+  @BeforeEach
+  override def setUp(): Unit = {
+    setTableName("hoodie_test")
+    initPath()
+    initSparkContexts()
+    spark = sqlContext.sparkSession
+    initTestDataGenerator()
+    initHoodieStorage()
+  }
+
+  @AfterEach
+  override def tearDown(): Unit = {
+    cleanupSparkContexts()
+    cleanupTestDataGenerator()
+    cleanupFileSystem()
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = classOf[HoodieTableType])
+  def testIncrementalFileIndexSizeInBytes(tableType: HoodieTableType): Unit = {
+    writeRecords(recordsToStrings(dataGen.generateInserts("001", 
100)).asScala.toList, tableType,
+      DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL)
+    val commit1CompletionTime = 
DataSourceTestUtils.latestCommitCompletionTime(storage, basePath)
+    // The upsert writes log files on MOR and new base file versions on COW.
+    writeRecords(recordsToStrings(dataGen.generateUniqueUpdates("002", 
50)).asScala.toList, tableType,
+      DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
+    val commit2CompletionTime = 
DataSourceTestUtils.latestCommitCompletionTime(storage, basePath)
+    val commit2Range = Map(
+      DataSourceReadOptions.START_COMMIT.key -> commit1CompletionTime,
+      DataSourceReadOptions.END_COMMIT.key -> commit2CompletionTime)
+
+    // Reports spark.sql.defaultSizeInBytes by default, matching the pre-1.0 
IncrementalRelation.
+    assertEquals(spark.sessionState.conf.defaultSizeInBytes, 
incrementalFileIndexOf(commit2Range).sizeInBytes)
+
+    // Reports the size of the files written by the commits in range when size 
estimation is enabled.
+    val fileIndex = incrementalFileIndexOf(commit2Range ++ Map(
+      DataSourceReadOptions.INCREMENTAL_SIZE_ESTIMATION_ENABLE.key -> "true",
+      DataSourceReadOptions.INCREMENTAL_FALLBACK_TO_FULL_TABLE_SCAN.key -> 
"false"))
+    assertEquals(latestCommitWrittenFilesSize(), fileIndex.sizeInBytes)
+  }
+
+  private def writeRecords(records: List[String], tableType: HoodieTableType, 
operation: String): Unit = {
+    spark.read.json(spark.sparkContext.parallelize(records, 
2)).write.format("hudi")
+      .options(commonOpts)
+      .option(DataSourceWriteOptions.TABLE_TYPE.key, tableType.name())
+      .option(DataSourceWriteOptions.OPERATION.key, operation)
+      .mode(SaveMode.Append)
+      .save(basePath)
+  }
+
+  private def latestCommitWrittenFilesSize(): Long = {
+    val metaClient = HoodieTestUtils.createMetaClient(storage, basePath)
+    val timeline = metaClient.getActiveTimeline
+    val commitMetadata = timeline.readCommitMetadata(
+      
timeline.getCommitsTimeline.filterCompletedInstants().lastInstant().get())
+    val writeStats = 
commitMetadata.getPartitionToWriteStats.values().asScala.flatMap(_.asScala)
+    assertTrue(writeStats.nonEmpty, "Expected the latest commit to write 
files")
+    writeStats.map(stat => stat.getPath -> 
stat.getFileSizeInBytes).toMap.values.sum
+  }
+
+  private def incrementalFileIndexOf(opts: Map[String, String]): 
HoodieIncrementalFileIndex = {
+    val df: DataFrame = spark.read.format("hudi")
+      .option(DataSourceReadOptions.QUERY_TYPE.key, 
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
+      .options(opts)
+      .load(basePath)
+    df.queryExecution.analyzed.collectFirst {
+      case relation: LogicalRelation if 
relation.relation.isInstanceOf[HadoopFsRelation] =>
+        relation.relation.asInstanceOf[HadoopFsRelation].location
+    } match {
+      case Some(fileIndex: HoodieIncrementalFileIndex) => fileIndex
+      case other => throw new IllegalStateException(s"Expected a 
HoodieIncrementalFileIndex but got $other")
+    }
+  }
+}

Reply via email to