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 87778deec6f6 fix(streamer): Include start commit in S3/GCS IncrSource 
incremental query (#18949)
87778deec6f6 is described below

commit 87778deec6f65d3d47d361e4ab57d8e3a7258a2e
Author: Y Ethan Guo <[email protected]>
AuthorDate: Thu Aug 27 10:59:35 2026 -0700

    fix(streamer): Include start commit in S3/GCS IncrSource incremental query 
(#18949)
---
 .../HoodieFileGroupReaderBasedFileFormat.scala     |  52 ++++--
 .../TestIncrementalReadWithFileGroupReader.scala   | 189 +++++++++++++++++++++
 .../utilities/sources/helpers/QueryRunner.java     |  13 +-
 .../sources/S3EventsHoodieIncrSourceHarness.java   |  20 ++-
 .../sources/TestGcsEventsHoodieIncrSource.java     | 118 +++++++++++--
 .../sources/TestS3EventsHoodieIncrSource.java      | 106 ++++++++++--
 6 files changed, 446 insertions(+), 52 deletions(-)

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 bad60712e125..1ebfdf379a82 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
@@ -257,6 +257,16 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
                                               hadoopConf: Configuration): 
PartitionedFile => Iterator[InternalRow] = {
     val outputSchema = StructType(requiredSchema.fields ++ 
partitionSchema.fields)
     val isCount = requiredSchema.isEmpty && !isMOR && !isIncremental
+    // Spark planner only adds the user-provided predicates (from `WHERE` 
clause or `.filter()`)
+    // to `filters`; the `requiredFilters` from 
`HoodieBaseHadoopFsRelationFactory#getRequiredFilters`
+    // are not visible to the planner, thus the `requiredSchema` passed by 
Spark can miss the
+    // columns in `requiredFilters`.  This happens for incremental query where 
`requiredFilters`
+    // is present.  To allow correct projection and filtering, the columns 
from `requiredFilters`
+    // are added back to the `readRequiredSchema` for reading the file.
+    val filterOnlyFields = requiredFilters.flatMap(_.references).distinct
+      .filterNot(name => requiredSchema.fieldNames.contains(name) || 
partitionSchema.fieldNames.contains(name))
+      .flatMap(name => dataStructType.fields.find(_.name == name))
+    val readRequiredSchema = StructType(requiredSchema.fields ++ 
filterOnlyFields)
     val augmentedStorageConf = new 
HadoopStorageConfiguration(hadoopConf).getInline
     setSchemaEvolutionConfigs(augmentedStorageConf)
     augmentedStorageConf.set(ENABLE_LOGICAL_TIMESTAMP_REPAIR, 
hasTimestampMillisFieldInTableSchema.toString)
@@ -276,7 +286,7 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
     val exclusionFields = new java.util.HashSet[String]()
     exclusionFields.add("op")
     partitionSchema.fields.foreach(f => exclusionFields.add(f.name))
-    val requestedStructType = StructType(requiredSchema.fields ++ 
partitionSchema.fields.filter(f => mandatoryFields.contains(f.name) && 
!isNestedPartitionField(f.name)))
+    val requestedStructType = StructType(readRequiredSchema.fields ++ 
partitionSchema.fields.filter(f => mandatoryFields.contains(f.name) && 
!isNestedPartitionField(f.name)))
     val requestedSchema = HoodieSchemaUtils.pruneDataSchema(schema, 
HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(requestedStructType,
 sanitizedTableName), exclusionFields)
     val dataStructTypeWithMandatoryPartitionFields = 
StructType(dataStructType.fields ++ partitionSchema.fields.filter(f => 
mandatoryFields.contains(f.name) && !isNestedPartitionField(f.name)))
     val dataSchema = HoodieSchemaUtils.pruneDataSchema(schema, 
HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(dataStructTypeWithMandatoryPartitionFields,
 sanitizedTableName), exclusionFields)
@@ -370,7 +380,7 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
 
             case _ =>
               readBaseFile(file, baseFileReader.value, requestedStructType, 
remainingPartitionSchema, fixedPartitionIndexes,
-                requiredSchema, partitionSchema, outputSchema, filters ++ 
requiredFilters, storageConf)
+                readRequiredSchema, partitionSchema, outputSchema, filters ++ 
requiredFilters, storageConf)
           }
         // CDC queries.
         case hoodiePartitionCDCFileGroupSliceMapping: 
HoodiePartitionCDCFileGroupMapping =>
@@ -378,7 +388,7 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
 
         case _ =>
           readBaseFile(file, baseFileReader.value, requestedStructType, 
remainingPartitionSchema, fixedPartitionIndexes,
-            requiredSchema, partitionSchema, outputSchema, filters ++ 
requiredFilters, storageConf)
+            readRequiredSchema, partitionSchema, outputSchema, filters ++ 
requiredFilters, storageConf)
       }
       CloseableIteratorListener.addListener(iter)
     }
@@ -527,28 +537,31 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
 
   // executor
   private def readBaseFile(file: PartitionedFile, parquetFileReader: 
SparkColumnarFileReader, requestedSchema: StructType,
-                           remainingPartitionSchema: StructType, 
fixedPartitionIndexes: Set[Int], requiredSchema: StructType,
+                           remainingPartitionSchema: StructType, 
fixedPartitionIndexes: Set[Int], readRequiredSchema: StructType,
                            partitionSchema: StructType, outputSchema: 
StructType, filters: Seq[Filter],
                            storageConf: StorageConfiguration[Configuration]): 
Iterator[InternalRow] = {
     // Detect vector columns and create modified schemas with BinaryType.
     // Each schema is detected independently because ordinals are relative to 
the schema being
     // modified — outputSchema and requestedSchema may have vector columns at 
different positions
-    // than requiredSchema (e.g. when partition columns are interleaved).
-    val (modifiedRequiredSchema, vectorCols) = 
withVectorRewrite(requiredSchema)
+    // than readRequiredSchema (e.g. when partition columns are interleaved).
+    val (modifiedReadRequiredSchema, vectorCols) = 
withVectorRewrite(readRequiredSchema)
     val hasVectors = vectorCols.nonEmpty
     val (modifiedOutputSchema, outputVectorCols) = if (hasVectors) 
withVectorRewrite(outputSchema) else (outputSchema, Map.empty[Int, 
HoodieSchema.Vector])
     val (modifiedRequestedSchema, _) = if (hasVectors) 
withVectorRewrite(requestedSchema) else (requestedSchema, Map.empty[Int, 
HoodieSchema.Vector])
 
     val rawIter = if (remainingPartitionSchema.fields.length == 
partitionSchema.fields.length) {
       //none of partition fields are read from the file, so the reader will do 
the appending for us
-      parquetFileReader.read(file, modifiedRequiredSchema, partitionSchema, 
internalSchemaOpt, filters, storageConf, tableSchemaAsMessageType)
+      val iter = parquetFileReader.read(file, modifiedReadRequiredSchema, 
partitionSchema, internalSchemaOpt, filters, storageConf, 
tableSchemaAsMessageType)
+      projectIfNeeded(iter, StructType(modifiedReadRequiredSchema.fields ++ 
partitionSchema.fields), modifiedOutputSchema)
     } else if (remainingPartitionSchema.fields.length == 0) {
       //we read all of the partition fields from the file
       val pfileUtils = sparkAdapter.getSparkPartitionedFileUtils
       //we need to modify the partitioned file so that the partition values 
are empty
       val modifiedFile = pfileUtils.createPartitionedFile(InternalRow.empty, 
pfileUtils.getPathFromPartitionedFile(file), file.start, file.length)
+      val readSchema = StructType(modifiedReadRequiredSchema.fields ++ 
partitionSchema.fields)
       //and we pass an empty schema for the partition schema
-      parquetFileReader.read(modifiedFile, modifiedOutputSchema, new 
StructType(), internalSchemaOpt, filters, storageConf, tableSchemaAsMessageType)
+      val iter = parquetFileReader.read(modifiedFile, readSchema, new 
StructType(), internalSchemaOpt, filters, storageConf, tableSchemaAsMessageType)
+      projectIfNeeded(iter, readSchema, modifiedOutputSchema)
     } 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)
@@ -561,13 +574,10 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
     }
 
     if (hasVectors) {
-      // The raw iterator has BinaryType for vector columns; convert back to 
ArrayType
-      val readSchema = if (remainingPartitionSchema.fields.length == 
partitionSchema.fields.length) {
-        StructType(modifiedRequiredSchema.fields ++ partitionSchema.fields)
-      } else {
-        modifiedOutputSchema
-      }
-      wrapWithVectorConversion(rawIter, readSchema, outputSchema, 
outputVectorCols)
+      // The raw iterator has BinaryType for vector columns; convert back to 
ArrayType.
+      // All branches above produce rows in modifiedOutputSchema: filter-only 
columns from
+      // readRequiredSchema are projected away by projectIfNeeded/projectIter.
+      wrapWithVectorConversion(rawIter, modifiedOutputSchema, outputSchema, 
outputVectorCols)
     } else {
       rawIter
     }
@@ -590,6 +600,18 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
    */
   private def isNestedPartitionField(name: String): Boolean = 
name.contains(".")
 
+  /**
+   * Projects to `to` only when the read schema was augmented with filter-only 
columns;
+   * otherwise returns the iterator as is, preserving columnar batches.
+   */
+  private def projectIfNeeded(iter: Iterator[InternalRow], from: StructType, 
to: StructType): Iterator[InternalRow] = {
+    if (from.fieldNames.sameElements(to.fieldNames)) {
+      iter
+    } else {
+      projectIter(iter, from, to)
+    }
+  }
+
   private def getFixedPartitionValues(allPartitionValues: InternalRow, 
partitionSchema: StructType, fixedPartitionIndexes: Set[Int]): InternalRow = {
     
InternalRow.fromSeq(allPartitionValues.toSeq(partitionSchema).zipWithIndex.filter(p
 => fixedPartitionIndexes.contains(p._2)).map(p => p._1))
   }
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
new file mode 100644
index 000000000000..ccb23158ad41
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
@@ -0,0 +1,189 @@
+/*
+ * 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}
+import org.apache.hudi.common.fs.FSUtils
+import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
+import org.apache.hudi.common.table.timeline.HoodieInstant
+import org.apache.hudi.config.{HoodieCompactionConfig, HoodieWriteConfig}
+import org.apache.hudi.storage.StoragePath
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness
+
+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 scala.collection.JavaConverters._
+
+/**
+ * Incremental query correctness with the file group reader across COW/MOR, 
source table
+ * versions, read versions, and query shapes that prune `_hoodie_commit_time` 
from the scan
+ * schema (count(), isEmpty(), narrow projections). Runs without 
HoodieSparkSessionExtension,
+ * so the file format alone must keep the span-filter columns readable.
+ */
+class TestIncrementalReadWithFileGroupReader extends 
SparkClientFunctionalTestHarness {
+
+  val columns: Seq[String] = Seq("ts", "key", "rider", "fare", "pt")
+
+  // c1..c3 insert disjoint key pairs (one file group, base files only via 
small file handling);
+  // c4..c6 are update commits (log files on MOR), each updating k1 with a 
different value so a
+  // range must surface only the targeted update of k1
+  val batches: Seq[(Seq[(Int, String, String, Double, String)], String)] = Seq(
+    (Seq((1, "k1", "rider-c1", 10.0, "pt1"), (1, "k2", "rider-c1", 10.0, 
"pt1")),
+      DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL),
+    (Seq((2, "k3", "rider-c2", 20.0, "pt1"), (2, "k4", "rider-c2", 20.0, 
"pt1")),
+      DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL),
+    (Seq((3, "k5", "rider-c3", 30.0, "pt1"), (3, "k6", "rider-c3", 30.0, 
"pt1")),
+      DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL),
+    (Seq((4, "k1", "rider-c4", 40.0, "pt1"), (4, "k2", "rider-c4", 40.0, 
"pt1")),
+      DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL),
+    (Seq((5, "k1", "rider-c5", 50.0, "pt1"), (5, "k3", "rider-c5", 50.0, 
"pt1"), (5, "k4", "rider-c5", 50.0, "pt1")),
+      DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL),
+    (Seq((6, "k1", "rider-c6", 60.0, "pt1"), (6, "k5", "rider-c6", 60.0, 
"pt1"), (6, "k6", "rider-c6", 60.0, "pt1")),
+      DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL))
+
+  @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,8,6",
+    "MERGE_ON_READ,8,8"
+  ))
+  def testIncrementalReadRanges(tableType: String, sourceVersion: Int, 
readVersion: Int): Unit = {
+    batches.zipWithIndex.foreach { case ((data, operation), i) =>
+      val mode = if (i == 0) SaveMode.Overwrite else SaveMode.Append
+      write(data, tableType, sourceVersion, operation, mode)
+      if (i == 2) {
+        // small file handling must have kept a single file group with base 
files only
+        val (baseFiles, logFiles) = listDataFiles()
+        assertEquals(3, baseFiles.size, "Expected one base file per insert 
commit")
+        assertEquals(1, baseFiles.map(FSUtils.getFileId).distinct.size, 
"Expected a single file group")
+        assertTrue(logFiles.isEmpty, "Expected no log files after insert-only 
commits")
+      }
+    }
+
+    val metaClient = HoodieTableMetaClient.builder()
+      .setConf(storageConf().newInstance()).setBasePath(basePath()).build()
+    assertEquals(sourceVersion, 
metaClient.getTableConfig.getTableVersion.versionCode())
+    val (baseFiles, logFiles) = listDataFiles()
+    assertEquals(1, baseFiles.map(FSUtils.getFileId).distinct.size, "Expected 
a single file group")
+    if (tableType == "MERGE_ON_READ") {
+      assertEquals(3, baseFiles.size, "Update commits must not rewrite MOR 
base files")
+      assertEquals(3, logFiles.size, "Expected one log file per update commit")
+    } else {
+      assertEquals(6, baseFiles.size, "Expected one base file per commit")
+      assertTrue(logFiles.isEmpty, "Expected no log files on COW")
+    }
+    // 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)
+      .select("_hoodie_commit_time").distinct().count()
+    assertTrue(commitTimesInBaseFile > 1,
+      s"Expected multiple commit times in the latest base file, got 
$commitTimesInBaseFile")
+
+    // c1..c6 ordered by requested time
+    val instants = 
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants
+      .getInstants.asScala.toList
+    assertEquals(6, instants.size)
+
+    // (000, c2]: base files only
+    assertIncrementalRange(readVersion, instants, 0, 2,
+      Set(("k1", 1), ("k2", 1), ("k3", 2), ("k4", 2)))
+    // (c1, c2]: single base file in range
+    assertIncrementalRange(readVersion, instants, 1, 2,
+      Set(("k3", 2), ("k4", 2)))
+    // (c2, c4]: base file of c3 plus c4's log file on MOR; carried-over c1/c2 
rows filtered out
+    assertIncrementalRange(readVersion, instants, 2, 4,
+      Set(("k5", 3), ("k6", 3), ("k1", 4), ("k2", 4)))
+    // (c3, c5]: log files of c4/c5 only on MOR; k1 updated in both c4 and c5 
must surface once
+    // with the latest in-range value
+    assertIncrementalRange(readVersion, instants, 3, 5,
+      Set(("k1", 5), ("k2", 4), ("k3", 5), ("k4", 5)))
+    // (c6, c6]: empty range
+    assertIncrementalRange(readVersion, instants, 6, 6, Set.empty)
+  }
+
+  private def assertIncrementalRange(readVersion: Int,
+                                     instants: List[HoodieInstant],
+                                     startIdx: Int, endIdx: Int,
+                                     expected: Set[(String, Int)]): Unit = {
+    def boundary(idx: Int): String = {
+      if (idx == 0) {
+        "000"
+      } else if (readVersion == 6) {
+        instants(idx - 1).requestedTime
+      } else {
+        instants(idx - 1).getCompletionTime
+      }
+    }
+    val start = boundary(startIdx)
+    val end = boundary(endIdx)
+
+    // select *
+    val rows = readIncremental(readVersion, start, end).collect()
+      .map(r => (r.getAs[String]("key"), r.getAs[Int]("ts"))).toSet
+    assertEquals(expected, rows)
+    // projection without _hoodie_commit_time
+    val keys = readIncremental(readVersion, start, 
end).select("key").collect().map(_.getString(0)).toSet
+    assertEquals(expected.map(_._1), keys)
+    // these query shapes prune `_hoodie_commit_time` out of the scan schema
+    assertEquals(expected.size.toLong, readIncremental(readVersion, start, 
end).count())
+    assertEquals(expected.isEmpty, readIncremental(readVersion, start, 
end).isEmpty)
+  }
+
+  private def write(data: Seq[(Int, String, String, Double, String)], 
tableType: String,
+                    sourceVersion: Int, operation: String, mode: SaveMode): 
Unit = {
+    spark.createDataFrame(data).toDF(columns: _*).write.format("hudi")
+      .option(DataSourceWriteOptions.RECORDKEY_FIELD.key, "key")
+      .option(DataSourceWriteOptions.PARTITIONPATH_FIELD.key, "pt")
+      .option(HoodieTableConfig.ORDERING_FIELDS.key, "ts")
+      .option(DataSourceWriteOptions.TABLE_TYPE.key, tableType)
+      .option(DataSourceWriteOptions.TABLE_NAME.key, "test_incr_read_fgr")
+      .option(HoodieWriteConfig.WRITE_TABLE_VERSION.key, 
sourceVersion.toString)
+      .option(HoodieCompactionConfig.INLINE_COMPACT.key, "false")
+      .option(DataSourceWriteOptions.OPERATION.key, operation)
+      .option("hoodie.insert.shuffle.parallelism", "2")
+      .option("hoodie.upsert.shuffle.parallelism", "2")
+      .mode(mode)
+      .save(basePath)
+  }
+
+  private def readIncremental(readVersion: Int, start: String, end: String): 
DataFrame = {
+    val reader = spark.read.format("hudi")
+      .option(DataSourceReadOptions.QUERY_TYPE.key(), 
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
+      .option(DataSourceReadOptions.START_COMMIT.key(), start)
+      .option(DataSourceReadOptions.END_COMMIT.key(), end)
+    val readerWithVersion = if (readVersion == 6) {
+      // same access pattern as the S3/GCS event incremental sources
+      
reader.option(DataSourceReadOptions.INCREMENTAL_READ_TABLE_VERSION.key(), "6")
+    } else {
+      reader
+    }
+    readerWithVersion.load(basePath)
+  }
+
+  private def listDataFiles(): (Seq[String], Seq[String]) = {
+    val names = fs.listStatus(new Path(basePath, 
"pt1")).map(_.getPath.getName).toSeq
+    (names.filter(n => FSUtils.isBaseFile(new StoragePath(n))), names.filter(n 
=> FSUtils.isLogFile(n)))
+  }
+}
diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/QueryRunner.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/QueryRunner.java
index 792c1e26b98b..9c9be2e0cce7 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/QueryRunner.java
+++ 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/QueryRunner.java
@@ -21,7 +21,6 @@ package org.apache.hudi.utilities.sources.helpers;
 import org.apache.hudi.DataSourceReadOptions;
 import org.apache.hudi.common.config.TypedProperties;
 import org.apache.hudi.common.model.HoodieRecord;
-import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.HoodieTableVersion;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.collection.Pair;
@@ -42,7 +41,6 @@ import java.util.List;
 import static 
org.apache.hudi.DataSourceReadOptions.INCREMENTAL_READ_TABLE_VERSION;
 import static 
org.apache.hudi.common.util.ConfigUtils.checkRequiredConfigProperties;
 import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys;
-import static org.apache.hudi.hadoop.fs.HadoopFSUtils.getStorageConf;
 
 /**
  * This class is currently used only by s3 and gcs incr sources that supports 
size based batching
@@ -89,15 +87,16 @@ public class QueryRunner {
   public Pair<QueryInfo, Dataset<Row>> runIncrementalQuery(QueryInfo 
queryInfo) {
     log.info("Running incremental query");
 
-    HoodieTableVersion tableVersion = 
HoodieTableMetaClient.builder().setConf(getStorageConf()).setBasePath(sourcePath).build().getTableConfig().getTableVersion();
+    // S3/GCS event incremental sources operate with V1 checkpoint 
(commit#fileKey, requested-time based),
+    // so force INCREMENTAL_READ_TABLE_VERSION to 6. Use previousInstant so 
the start-exclusive incremental
+    // scan still includes the commit (startInstant), required to resume from 
checkpoint commit#fileKey.
     return Pair.of(queryInfo, sparkSession.read().format("hudi")
         .option(DataSourceReadOptions.QUERY_TYPE().key(), 
queryInfo.getQueryType())
-        .option(INCREMENTAL_READ_TABLE_VERSION().key(), 
tableVersion.versionCode())
-        .option(DataSourceReadOptions.START_COMMIT().key(), 
queryInfo.getStartInstant())
+        .option(INCREMENTAL_READ_TABLE_VERSION().key(), 
HoodieTableVersion.SIX.versionCode())
+        .option(DataSourceReadOptions.START_COMMIT().key(), 
queryInfo.getPreviousInstant())
         .option(DataSourceReadOptions.END_COMMIT().key(), 
queryInfo.getEndInstant())
         
.option(DataSourceReadOptions.INCREMENTAL_FALLBACK_TO_FULL_TABLE_SCAN().key(),
-            
props.getString(DataSourceReadOptions.INCREMENTAL_FALLBACK_TO_FULL_TABLE_SCAN().key(),
-                tableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT) ? 
DataSourceReadOptions.INCREMENTAL_FALLBACK_TO_FULL_TABLE_SCAN().defaultValue() 
: "false"))
+            
props.getString(DataSourceReadOptions.INCREMENTAL_FALLBACK_TO_FULL_TABLE_SCAN().key(),
 "false"))
         .load(sourcePath));
   }
 
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java
index ecf34920218d..acaa7eefba55 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java
@@ -73,14 +73,12 @@ import org.mockito.Mockito;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.io.IOException;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
-import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.COMMIT_ACTION;
 import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors;
 import static org.mockito.Mockito.when;
 
@@ -163,7 +161,8 @@ public class S3EventsHoodieIncrSourceHarness extends 
SparkClientFunctionalTestHa
   }
 
   protected HoodieRecord generateS3EventMetadata(String commitTime, String 
bucketName, String objectKey, Long objectSize) {
-    String partitionPath = bucketName;
+    // partition path must match the table config, or the incremental read 
lists no partitions
+    String partitionPath = metaClient.getTableConfig().isTablePartitioned() ? 
bucketName : "";
     HoodieSchema schema = S3_METADATA_SCHEMA;
     GenericRecord rec = new GenericData.Record(schema.toAvroSchema());
     HoodieSchemaField s3Field = schema.getField("s3").get();
@@ -222,15 +221,20 @@ public class S3EventsHoodieIncrSourceHarness extends 
SparkClientFunctionalTestHa
   }
 
   protected Pair<String, List<HoodieRecord>> writeS3MetadataRecords(String 
commitTime) throws IOException {
+    return writeS3MetadataRecords(commitTime, 
Collections.singletonList(Pair.of("data-file-1.json", 1L)));
+  }
+
+  /** Writes one commit with one S3 event record per (objectKey, objectSize) 
entry. */
+  protected Pair<String, List<HoodieRecord>> writeS3MetadataRecords(String 
commitTime,
+                                                                    
List<Pair<String, Long>> keysAndSizes) throws IOException {
     HoodieWriteConfig writeConfig = getWriteConfig();
     try (SparkRDDWriteClient writeClient = getHoodieWriteClient(writeConfig)) {
-
       WriteClientTestUtils.startCommitWithTime(writeClient, commitTime);
-      List<HoodieRecord> s3MetadataRecords = Arrays.asList(
-          generateS3EventMetadata(commitTime, "bucket-1", "data-file-1.json", 
1L)
-      );
+      List<HoodieRecord> s3MetadataRecords = keysAndSizes.stream()
+          .map(p -> generateS3EventMetadata(commitTime, "bucket-1", 
p.getLeft(), p.getRight()))
+          .collect(Collectors.toList());
       List<WriteStatus> statusList = 
writeClient.upsert(jsc().parallelize(s3MetadataRecords, 1), 
commitTime).collect();
-      writeClient.commit(commitTime, jsc.parallelize(statusList), 
Option.empty(), COMMIT_ACTION, Collections.emptyMap(), Option.empty());
+      writeClient.commit(commitTime, jsc.parallelize(statusList), 
Option.empty(), metaClient.getCommitActionType(), Collections.emptyMap(), 
Option.empty());
       assertNoWriteErrors(statusList);
       return Pair.of(commitTime, s3MetadataRecords);
     }
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java
index 746760a86307..6bfb70515729 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java
@@ -27,7 +27,9 @@ import org.apache.hudi.common.model.HoodieAvroPayload;
 import org.apache.hudi.common.model.HoodieAvroRecord;
 import org.apache.hudi.common.model.HoodieKey;
 import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.checkpoint.Checkpoint;
 import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1;
@@ -48,6 +50,7 @@ import 
org.apache.hudi.utilities.schema.FilebasedSchemaProvider;
 import org.apache.hudi.utilities.schema.SchemaProvider;
 import 
org.apache.hudi.utilities.sources.S3EventsHoodieIncrSourceHarness.TestSourceProfile;
 import org.apache.hudi.utilities.sources.helpers.CloudDataFetcher;
+import org.apache.hudi.utilities.sources.helpers.CloudObjectMetadata;
 import org.apache.hudi.utilities.sources.helpers.CloudObjectsSelectorCommon;
 import org.apache.hudi.utilities.sources.helpers.IncrSourceHelper;
 import org.apache.hudi.utilities.sources.helpers.QueryInfo;
@@ -60,11 +63,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.avro.generic.GenericData;
 import org.apache.avro.generic.GenericRecord;
+import org.apache.hadoop.fs.Path;
 import org.apache.spark.api.java.JavaRDD;
 import org.apache.spark.api.java.JavaSparkContext;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
-import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
@@ -82,14 +85,18 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Properties;
 import java.util.stream.Collectors;
 
-import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.COMMIT_ACTION;
+import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION;
 import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors;
 import static 
org.apache.hudi.utilities.sources.helpers.IncrSourceHelper.MissingCheckpointStrategy.READ_UPTO_LATEST_COMMIT;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.verify;
@@ -251,7 +258,7 @@ public class TestGcsEventsHoodieIncrSource extends 
SparkClientFunctionalTestHarn
     List<Integer> numPartitions = Arrays.asList(12, 2, 1);
     ArgumentCaptor<Integer> argumentCaptor = 
ArgumentCaptor.forClass(Integer.class);
     verify(cloudObjectsSelectorCommon, atLeastOnce()).loadAsDataset(any(), 
any(), any(), eq(schemaProvider), argumentCaptor.capture());
-    Assertions.assertEquals(numPartitions, argumentCaptor.getAllValues());
+    assertEquals(numPartitions, argumentCaptor.getAllValues());
   }
 
   @ParameterizedTest
@@ -309,8 +316,78 @@ public class TestGcsEventsHoodieIncrSource extends 
SparkClientFunctionalTestHarn
     } else {
       numPartitions = Arrays.asList(23, sourcePartitions);
     }
-    Assertions.assertEquals(numPartitions, argumentCaptor.getAllValues());
-    Assertions.assertEquals(numPartitions, 
argumentCaptorForMetrics.getAllValues());
+    assertEquals(numPartitions, argumentCaptor.getAllValues());
+    assertEquals(numPartitions, argumentCaptorForMetrics.getAllValues());
+  }
+
+  /**
+   * Resume from `commit#fileKey` must re-include the start commit; runs on v6 
and v8, COW and MOR
+   * source meta-tables since cloud event sources always use V1/requested-time 
regardless of version.
+   */
+  @ParameterizedTest
+  @CsvSource({"6,COPY_ON_WRITE", "8,COPY_ON_WRITE", "6,MERGE_ON_READ", 
"8,MERGE_ON_READ"})
+  void testRealQueryRunnerResumesMidCommitPagination(String 
sourceTableVersion, HoodieTableType tableType) throws IOException {
+    Properties tableProps = new Properties();
+    tableProps.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), 
String.valueOf(true));
+    tableProps.put("hoodie.datasource.write.recordkey.field", "_row_key");
+    tableProps.put("hoodie.datasource.write.partitionpath.field", "");
+    tableProps.put(HoodieTableConfig.RECORDKEY_FIELDS.key(), "_row_key");
+    tableProps.put(HoodieTableConfig.PARTITION_FIELDS.key(), "");
+    tableProps.put(WRITE_TABLE_VERSION.key(), sourceTableVersion);
+    metaClient = getHoodieMetaClient(storageConf(), basePath(), tableProps, 
tableType);
+
+    // timestamp-format instants: the incremental read normalizes 
START_COMMIT/END_COMMIT
+    // through HoodieSqlCommonUtils.formatIncrementalInstant, which rejects 
other formats
+    String startCommit = "20260601000001";
+    String laterCommit = "20260601000002";
+    writeGcsMetadataRecords(startCommit, Arrays.asList(
+        Pair.of("name/file-01.json", 100L),
+        Pair.of("name/file-02.json", 100L),
+        Pair.of("name/file-03.json", 100L),
+        Pair.of("name/file-04.json", 100L),
+        Pair.of("name/file-05.json", 100L)));
+    // the second commit re-writes an existing key (a re-uploaded object), 
landing in a log file on MOR
+    writeGcsMetadataRecords(laterCommit, 
Arrays.asList(Pair.of("name/file-05.json", 100L)));
+    if (tableType == HoodieTableType.MERGE_ON_READ) {
+      boolean hasLogFiles = Arrays.stream(fs().listStatus(new 
Path(basePath())))
+          .anyMatch(f -> f.getPath().getName().contains(".log."));
+      assertTrue(hasLogFiles, "Expected log files in the MOR source 
meta-table");
+    }
+
+    TypedProperties props = setProps(READ_UPTO_LATEST_COMMIT);
+    props.setProperty(CloudSourceConfig.ENABLE_EXISTS_CHECK.key(), "false");
+    when(cloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt()))
+        .thenReturn(Option.empty());
+    when(sourceProfileSupplier.getSourceProfile()).thenReturn(null);
+
+    // Real QueryRunner so the actual Spark incremental read against the 
on-disk meta-table runs.
+    GcsEventsHoodieIncrSource incrSource = new GcsEventsHoodieIncrSource(
+        props, jsc(), spark(),
+        new CloudDataFetcher(props, jsc(), spark(), metrics, 
cloudObjectsSelectorCommon),
+        new QueryRunner(spark(), props),
+        new DefaultStreamContext(schemaProvider.orElse(null), 
Option.of(sourceProfileSupplier)));
+
+    // Resume mid-commit at file-02; sourceLimit=250B fits file-03+file-04, 
file-05 would exceed.
+    Checkpoint resumeFrom = new StreamerCheckpointV1(startCommit + 
"#name/file-02.json");
+    Pair<Option<Dataset<Row>>, Checkpoint> result = 
incrSource.fetchNextBatch(Option.of(resumeFrom), 250L);
+
+    assertEquals(
+        new StreamerCheckpointV1(startCommit + "#name/file-04.json"),
+        result.getRight(),
+        "Next batch must continue within the start commit, not advance to a 
bare instant.");
+
+    // Filter must pass exactly file-03 and file-04 to downstream loading.
+    @SuppressWarnings("unchecked")
+    ArgumentCaptor<List<CloudObjectMetadata>> captor = 
ArgumentCaptor.forClass((Class) List.class);
+    verify(cloudObjectsSelectorCommon).loadAsDataset(
+        any(), captor.capture(), any(), eq(schemaProvider), anyInt());
+    List<String> selectedPaths = captor.getValue().stream()
+        .map(CloudObjectMetadata::getPath)
+        .sorted()
+        .collect(Collectors.toList());
+    assertEquals(2, selectedPaths.size(), "Expected file-03 and file-04, got: 
" + selectedPaths);
+    assertTrue(selectedPaths.get(0).endsWith("/name/file-03.json"), 
selectedPaths.get(0));
+    assertTrue(selectedPaths.get(1).endsWith("/name/file-04.json"), 
selectedPaths.get(1));
   }
 
   @Test
@@ -375,8 +452,8 @@ public class TestGcsEventsHoodieIncrSource extends 
SparkClientFunctionalTestHarn
     Option<Dataset<Row>> datasetOpt = dataAndCheckpoint.getLeft();
     Checkpoint nextCheckPoint = dataAndCheckpoint.getRight();
 
-    Assertions.assertNotNull(nextCheckPoint);
-    Assertions.assertEquals(new StreamerCheckpointV1(expectedCheckpoint), 
nextCheckPoint);
+    assertNotNull(nextCheckPoint);
+    assertEquals(new StreamerCheckpointV1(expectedCheckpoint), nextCheckPoint);
   }
 
   private void readAndAssert(IncrSourceHelper.MissingCheckpointStrategy 
missingCheckpointStrategy,
@@ -387,7 +464,12 @@ public class TestGcsEventsHoodieIncrSource extends 
SparkClientFunctionalTestHarn
   }
 
   private HoodieRecord getGcsMetadataRecord(String commitTime, String 
filename, String bucketName, String generation) {
-    String partitionPath = bucketName;
+    return getGcsMetadataRecord(commitTime, filename, bucketName, generation, 
370L);
+  }
+
+  private HoodieRecord getGcsMetadataRecord(String commitTime, String 
filename, String bucketName, String generation, long size) {
+    // partition path must match the table config, or the incremental read 
lists no partitions
+    String partitionPath = metaClient.getTableConfig().isTablePartitioned() ? 
bucketName : "";
 
     String id = "id:" + bucketName + "/" + filename + "/" + generation;
     String mediaLink = 
String.format("https://storage.googleapis.com/download/storage/v1/b/%s/o/%s";
@@ -412,7 +494,7 @@ public class TestGcsEventsHoodieIncrSource extends 
SparkClientFunctionalTestHarn
     rec.put("metageneration", "1");
     rec.put("name", filename);
     rec.put("selfLink", selfLink);
-    rec.put("size", "370");
+    rec.put("size", Long.toString(size));
     rec.put("storageClass", "STANDARD");
     rec.put("timeCreated", "2022-08-29T05:52:55.869Z");
     rec.put("timeStorageClassUpdated", "2022-08-29T05:52:55.869Z");
@@ -443,7 +525,23 @@ public class TestGcsEventsHoodieIncrSource extends 
SparkClientFunctionalTestHarn
           getGcsMetadataRecord(commitTime, "data-file-4.json", "bucket-1", "1")
       );
       List<WriteStatus> statusList = 
writeClient.upsert(jsc().parallelize(gcsMetadataRecords, 1), 
commitTime).collect();
-      writeClient.commit(commitTime, jsc.parallelize(statusList), 
Option.empty(), COMMIT_ACTION, Collections.emptyMap(), Option.empty());
+      writeClient.commit(commitTime, jsc.parallelize(statusList), 
Option.empty(), metaClient.getCommitActionType(), Collections.emptyMap(), 
Option.empty());
+      assertNoWriteErrors(statusList);
+      return Pair.of(commitTime, gcsMetadataRecords);
+    }
+  }
+
+  /** Writes one commit with one GCS event record per (objectKey, objectSize) 
entry. */
+  private Pair<String, List<HoodieRecord>> writeGcsMetadataRecords(String 
commitTime,
+                                                                   
List<Pair<String, Long>> keysAndSizes) throws IOException {
+    HoodieWriteConfig writeConfig = getWriteConfig();
+    try (SparkRDDWriteClient writeClient = getHoodieWriteClient(writeConfig)) {
+      WriteClientTestUtils.startCommitWithTime(writeClient, commitTime);
+      List<HoodieRecord> gcsMetadataRecords = keysAndSizes.stream()
+          .map(p -> getGcsMetadataRecord(commitTime, p.getLeft(), "bucket-1", 
"1", p.getRight()))
+          .collect(Collectors.toList());
+      List<WriteStatus> statusList = 
writeClient.upsert(jsc().parallelize(gcsMetadataRecords, 1), 
commitTime).collect();
+      writeClient.commit(commitTime, jsc.parallelize(statusList), 
Option.empty(), metaClient.getCommitActionType(), Collections.emptyMap(), 
Option.empty());
       assertNoWriteErrors(statusList);
       return Pair.of(commitTime, gcsMetadataRecords);
     }
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestS3EventsHoodieIncrSource.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestS3EventsHoodieIncrSource.java
index 42274c859717..95f753084010 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestS3EventsHoodieIncrSource.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestS3EventsHoodieIncrSource.java
@@ -20,6 +20,10 @@ package org.apache.hudi.utilities.sources;
 
 import org.apache.hudi.common.config.TypedProperties;
 import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.checkpoint.Checkpoint;
+import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1;
 import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.collection.Pair;
@@ -27,12 +31,14 @@ import org.apache.hudi.common.util.collection.Triple;
 import org.apache.hudi.utilities.UtilHelpers;
 import org.apache.hudi.utilities.config.CloudSourceConfig;
 import org.apache.hudi.utilities.sources.helpers.CloudDataFetcher;
+import org.apache.hudi.utilities.sources.helpers.CloudObjectMetadata;
+import org.apache.hudi.utilities.sources.helpers.QueryRunner;
 import org.apache.hudi.utilities.streamer.DefaultStreamContext;
 import org.apache.hudi.utilities.streamer.SourceProfile;
 
+import org.apache.hadoop.fs.Path;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
-import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -40,18 +46,23 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
-import org.mockito.Mockito;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Properties;
 
+import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION;
 import static 
org.apache.hudi.utilities.sources.helpers.IncrSourceHelper.MissingCheckpointStrategy.READ_UPTO_LATEST_COMMIT;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -91,7 +102,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime);
 
     setMockQueryRunner(inputDs);
-    when(mockCloudObjectsSelectorCommon.loadAsDataset(Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.eq(schemaProvider), 
Mockito.anyInt())).thenReturn(Option.empty());
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt())).thenReturn(Option.empty());
     when(sourceProfileSupplier.getSourceProfile()).thenReturn(null);
 
     readAndAssert(READ_UPTO_LATEST_COMMIT, Option.of(commitTimeForReads), 
100L, "1#path/to/file1.json");
@@ -116,7 +127,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime);
 
     setMockQueryRunner(inputDs);
-    when(mockCloudObjectsSelectorCommon.loadAsDataset(Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.eq(schemaProvider), 
Mockito.anyInt())).thenReturn(Option.empty());
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt())).thenReturn(Option.empty());
     when(sourceProfileSupplier.getSourceProfile()).thenReturn(null);
 
     readAndAssert(READ_UPTO_LATEST_COMMIT, Option.of(commitTimeForReads), 
250L, "1#path/to/file2.json");
@@ -156,7 +167,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime);
 
     setMockQueryRunner(inputDs);
-    when(mockCloudObjectsSelectorCommon.loadAsDataset(Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.eq(schemaProvider), 
Mockito.anyInt())).thenReturn(Option.empty());
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt())).thenReturn(Option.empty());
     when(sourceProfileSupplier.getSourceProfile()).thenReturn(null);
 
     readAndAssert(READ_UPTO_LATEST_COMMIT, Option.of("1"), 100L,
@@ -221,7 +232,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
 
     setMockQueryRunner(inputDs);
     SourceProfile<Long> sourceProfile = new TestSourceProfile(50L, 0, 10L);
-    when(mockCloudObjectsSelectorCommon.loadAsDataset(Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.eq(schemaProvider), 
Mockito.anyInt())).thenReturn(Option.empty());
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt())).thenReturn(Option.empty());
     if (useSourceProfile) {
       when(sourceProfileSupplier.getSourceProfile()).thenReturn(sourceProfile);
     } else {
@@ -259,7 +270,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime);
 
     setMockQueryRunner(inputDs);
-    when(mockCloudObjectsSelectorCommon.loadAsDataset(Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.eq(schemaProvider), 
Mockito.anyInt())).thenReturn(Option.empty());
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt())).thenReturn(Option.empty());
     SourceProfile<Long> sourceProfile = new TestSourceProfile(50L, 0, 10L);
     if (useSourceProfile) {
       when(sourceProfileSupplier.getSourceProfile()).thenReturn(sourceProfile);
@@ -305,7 +316,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime);
 
     setMockQueryRunner(inputDs, Option.of(snapshotCheckPoint));
-    when(mockCloudObjectsSelectorCommon.loadAsDataset(Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.eq(schemaProvider), 
Mockito.anyInt())).thenReturn(Option.empty());
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt())).thenReturn(Option.empty());
     TypedProperties typedProperties = setProps(READ_UPTO_LATEST_COMMIT);
     
typedProperties.setProperty("hoodie.streamer.source.cloud.data.ignore.relpath.prefix",
 "path/to/skip");
     
typedProperties.setProperty("hoodie.streamer.source.cloud.data.select.relative.path.regex",
 "path/to/file[0-9]+");
@@ -335,7 +346,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     // Verify the partitions being passed in getCloudObjectDataDF are correct.
     ArgumentCaptor<Integer> argumentCaptor = 
ArgumentCaptor.forClass(Integer.class);
     ArgumentCaptor<Integer> argumentCaptorForMetrics = 
ArgumentCaptor.forClass(Integer.class);
-    verify(mockCloudObjectsSelectorCommon, 
atLeastOnce()).loadAsDataset(Mockito.any(), Mockito.any(), Mockito.any(), 
Mockito.eq(schemaProvider), argumentCaptor.capture());
+    verify(mockCloudObjectsSelectorCommon, atLeastOnce()).loadAsDataset(any(), 
any(), any(), eq(schemaProvider), argumentCaptor.capture());
     verify(metrics, 
atLeastOnce()).updateStreamerSourceParallelism(argumentCaptorForMetrics.capture());
     List<Integer> numPartitions;
     if (snapshotCheckPoint.equals("1") || snapshotCheckPoint.equals("2")) {
@@ -343,8 +354,79 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     } else {
       numPartitions = Arrays.asList(23, sourcePartitions);
     }
-    Assertions.assertEquals(numPartitions, argumentCaptor.getAllValues());
-    Assertions.assertEquals(numPartitions, 
argumentCaptorForMetrics.getAllValues());
+    assertEquals(numPartitions, argumentCaptor.getAllValues());
+    assertEquals(numPartitions, argumentCaptorForMetrics.getAllValues());
+  }
+
+  /**
+   * Resume from `commit#fileKey` must re-include the start commit; runs on v6 
and v8, COW and MOR
+   * source meta-tables since cloud event sources always use V1/requested-time 
regardless of version.
+   */
+  @ParameterizedTest
+  @CsvSource({"6,COPY_ON_WRITE", "8,COPY_ON_WRITE", "6,MERGE_ON_READ", 
"8,MERGE_ON_READ"})
+  void testRealQueryRunnerResumesMidCommitPagination(String 
sourceTableVersion, HoodieTableType tableType) throws IOException {
+    Properties tableProps = new Properties();
+    tableProps.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), 
String.valueOf(true));
+    tableProps.put("hoodie.datasource.write.recordkey.field", "_row_key");
+    tableProps.put("hoodie.datasource.write.partitionpath.field", "");
+    tableProps.put(HoodieTableConfig.RECORDKEY_FIELDS.key(), "_row_key");
+    tableProps.put(HoodieTableConfig.PARTITION_FIELDS.key(), "");
+    tableProps.put(WRITE_TABLE_VERSION.key(), sourceTableVersion);
+    metaClient = getHoodieMetaClient(storageConf(), basePath(), tableProps, 
tableType);
+
+    // timestamp-format instants: the incremental read normalizes 
START_COMMIT/END_COMMIT
+    // through HoodieSqlCommonUtils.formatIncrementalInstant, which rejects 
other formats
+    String startCommit = "20260601000001";
+    String laterCommit = "20260601000002";
+    writeS3MetadataRecords(startCommit, Arrays.asList(
+        Pair.of("path/to/file-01.json", 100L),
+        Pair.of("path/to/file-02.json", 100L),
+        Pair.of("path/to/file-03.json", 100L),
+        Pair.of("path/to/file-04.json", 100L),
+        Pair.of("path/to/file-05.json", 100L)));
+    // the second commit re-writes an existing key (an S3 re-upload), landing 
in a log file on MOR
+    writeS3MetadataRecords(laterCommit, 
Arrays.asList(Pair.of("path/to/file-05.json", 100L)));
+    if (tableType == HoodieTableType.MERGE_ON_READ) {
+      boolean hasLogFiles = Arrays.stream(fs().listStatus(new 
Path(basePath())))
+          .anyMatch(f -> f.getPath().getName().contains(".log."));
+      assertTrue(hasLogFiles, "Expected log files in the MOR source 
meta-table");
+    }
+
+    TypedProperties props = setProps(READ_UPTO_LATEST_COMMIT);
+    props.setProperty(CloudSourceConfig.ENABLE_EXISTS_CHECK.key(), "false");
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(
+            any(), any(), any(), eq(schemaProvider), anyInt()))
+        .thenReturn(Option.empty());
+    when(sourceProfileSupplier.getSourceProfile()).thenReturn(null);
+
+    // Real QueryRunner so the actual Spark incremental read against the 
on-disk meta-table runs.
+    S3EventsHoodieIncrSource incrSource = new S3EventsHoodieIncrSource(
+        props, jsc(), spark(),
+        new QueryRunner(spark(), props),
+        new CloudDataFetcher(props, jsc(), spark(), metrics, 
mockCloudObjectsSelectorCommon),
+        new DefaultStreamContext(schemaProvider.orElse(null), 
Option.of(sourceProfileSupplier)));
+
+    // Resume mid-commit at file-02; sourceLimit=250B fits file-03+file-04, 
file-05 would exceed.
+    Checkpoint resumeFrom = new StreamerCheckpointV1(startCommit + 
"#path/to/file-02.json");
+    Pair<Option<Dataset<Row>>, Checkpoint> result = 
incrSource.fetchNextBatch(Option.of(resumeFrom), 250L);
+
+    assertEquals(
+        new StreamerCheckpointV1(startCommit + "#path/to/file-04.json"),
+        result.getRight(),
+        "Next batch must continue within the start commit, not advance to a 
bare instant.");
+
+    // Filter must pass exactly file-03 and file-04 to downstream loading.
+    @SuppressWarnings("unchecked")
+    ArgumentCaptor<List<CloudObjectMetadata>> captor = 
ArgumentCaptor.forClass((Class) List.class);
+    verify(mockCloudObjectsSelectorCommon).loadAsDataset(
+        any(), captor.capture(), any(), eq(schemaProvider), anyInt());
+    List<String> selectedPaths = captor.getValue().stream()
+        .map(CloudObjectMetadata::getPath)
+        .sorted()
+        .collect(java.util.stream.Collectors.toList());
+    assertEquals(2, selectedPaths.size(), "Expected file-03 and file-04, got: 
" + selectedPaths);
+    assertTrue(selectedPaths.get(0).endsWith("/path/to/file-03.json"), 
selectedPaths.get(0));
+    assertTrue(selectedPaths.get(1).endsWith("/path/to/file-04.json"), 
selectedPaths.get(1));
   }
 
   @Test
@@ -366,7 +448,7 @@ public class TestS3EventsHoodieIncrSource extends 
S3EventsHoodieIncrSourceHarnes
     Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime);
 
     setMockQueryRunner(inputDs);
-    when(mockCloudObjectsSelectorCommon.loadAsDataset(Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.eq(schemaProvider), 
Mockito.anyInt())).thenReturn(Option.empty());
+    when(mockCloudObjectsSelectorCommon.loadAsDataset(any(), any(), any(), 
eq(schemaProvider), anyInt())).thenReturn(Option.empty());
     when(sourceProfileSupplier.getSourceProfile()).thenReturn(null);
 
     // With a large byte limit all 5 files fit, but 
SOURCE_MAX_FILES_PER_SYNC=3 caps processing to

Reply via email to