This is an automated email from the ASF dual-hosted git repository.
nsivabalan 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 014358a3e6c8 [HUDI-18075] Cap files per batch in cloud incremental
source (#18076)
014358a3e6c8 is described below
commit 014358a3e6c8a954e3d72eeaa7033e2aa18adc43
Author: Lokesh Jain <[email protected]>
AuthorDate: Mon Jul 13 18:09:20 2026 +0530
[HUDI-18075] Cap files per batch in cloud incremental source (#18076)
The cloud incremental source only limits bytes per batch, so with
small files the driver can hold unbounded file metadata and OOM.
Adds hoodie.streamer.source.cloud.data.num.files.per.batch to cap
files per sync. Applied deterministically via a window predicate so
the checkpoint advances consistently with the emitted batch.
---
.../hudi/utilities/config/CloudSourceConfig.java | 9 ++
.../sources/helpers/CloudDataFetcher.java | 10 +-
.../sources/helpers/IncrSourceHelper.java | 129 ++++++++++++--------
.../sources/TestS3EventsHoodieIncrSource.java | 36 ++++++
.../sources/helpers/TestIncrSourceHelper.java | 131 +++++++++++++++++++++
5 files changed, 267 insertions(+), 48 deletions(-)
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
index 6ffd5ad5c7ae..46393d64fa32 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
@@ -166,6 +166,15 @@ public class CloudSourceConfig extends HoodieConfig {
.sinceVersion("0.14.1")
.withDocumentation("specify this value in bytes, to coalesce partitions
of source dataset not greater than specified limit");
+ public static final ConfigProperty<Long> SOURCE_MAX_FILES_PER_SYNC =
ConfigProperty
+ .key(STREAMER_CONFIG_PREFIX + "source.cloud.data.max.files.per.sync")
+ .defaultValue(10_000L)
+ .markAdvanced()
+ .sinceVersion("1.4.0")
+ .withDocumentation("Maximum number of files to consume per sync round
when using the cloud incremental source. "
+ + "This limit is applied in addition to the byte-based source limit
to prevent driver OOM when processing "
+ + "many small files. The sync will process files up to whichever
limit is reached first.");
+
public static final ConfigProperty<Integer> MAX_FETCH_TIME_PER_SYNC_SECS =
ConfigProperty
.key(STREAMER_CONFIG_PREFIX +
"source.cloud.meta.max.fetch.time.per.sync.secs")
.defaultValue(60)
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudDataFetcher.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudDataFetcher.java
index 396b3f67dbce..e6a77eaabf81 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudDataFetcher.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudDataFetcher.java
@@ -23,6 +23,7 @@ import org.apache.hudi.common.table.checkpoint.Checkpoint;
import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.StringUtils;
+import org.apache.hudi.common.util.ValidationUtils;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics;
import org.apache.hudi.utilities.schema.SchemaProvider;
@@ -43,6 +44,7 @@ import static
org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys;
import static
org.apache.hudi.utilities.config.CloudSourceConfig.DATAFILE_FORMAT;
import static
org.apache.hudi.utilities.config.CloudSourceConfig.ENABLE_EXISTS_CHECK;
import static
org.apache.hudi.utilities.config.CloudSourceConfig.SOURCE_MAX_BYTES_PER_PARTITION;
+import static
org.apache.hudi.utilities.config.CloudSourceConfig.SOURCE_MAX_FILES_PER_SYNC;
import static
org.apache.hudi.utilities.config.HoodieIncrSourceConfig.SOURCE_FILE_FORMAT;
/**
@@ -101,10 +103,14 @@ public class CloudDataFetcher implements Serializable {
log.info("Adding filter string to Dataset: {}", filter);
Dataset<Row> filteredSourceData =
queryInfoDatasetPair.getRight().filter(filter);
- log.info("Adjusting end checkpoint:{} based on sourceLimit :{}",
queryInfo.getEndInstant(), sourceLimit);
+ long numFilesLimit = props.getLong(SOURCE_MAX_FILES_PER_SYNC.key(),
SOURCE_MAX_FILES_PER_SYNC.defaultValue());
+ ValidationUtils.checkArgument(numFilesLimit >= 1,
+ SOURCE_MAX_FILES_PER_SYNC.key() + " must be >= 1, got: " +
numFilesLimit);
+ log.info("Adjusting end checkpoint:{} based on sourceLimit:{} and
numFilesLimit:{}",
+ queryInfo.getEndInstant(), sourceLimit, numFilesLimit);
Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>> checkPointAndDataset
=
IncrSourceHelper.filterAndGenerateCheckpointBasedOnSourceLimit(
- filteredSourceData, sourceLimit, queryInfo,
cloudObjectIncrCheckpoint);
+ filteredSourceData, sourceLimit, numFilesLimit, queryInfo,
cloudObjectIncrCheckpoint);
if (!checkPointAndDataset.getRight().isPresent()) {
log.info("Empty source, returning endpoint:{}",
checkPointAndDataset.getLeft());
return Pair.of(Option.empty(), new
StreamerCheckpointV1(checkPointAndDataset.getLeft().toString()));
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/IncrSourceHelper.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/IncrSourceHelper.java
index 3428098f112d..148583cbd9cd 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/IncrSourceHelper.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/IncrSourceHelper.java
@@ -33,6 +33,7 @@ import org.apache.hudi.common.table.timeline.TimelineUtils;
import
org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.ValidationUtils;
+import org.apache.hudi.common.util.VisibleForTesting;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.hadoop.fs.HadoopFSUtils;
import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamer;
@@ -64,6 +65,7 @@ import static
org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys;
import static
org.apache.hudi.utilities.config.HoodieIncrSourceConfig.MISSING_CHECKPOINT_STRATEGY;
import static
org.apache.hudi.utilities.config.HoodieIncrSourceConfig.READ_LATEST_INSTANT_ON_MISSING_CKPT;
import static org.apache.spark.sql.functions.col;
+import static org.apache.spark.sql.functions.row_number;
import static org.apache.spark.sql.functions.sum;
@Slf4j
@@ -71,6 +73,7 @@ public class IncrSourceHelper {
public static final String DEFAULT_START_TIMESTAMP =
HoodieTimeline.INIT_INSTANT_TS;
private static final String CUMULATIVE_COLUMN_NAME = "cumulativeSize";
+ private static final String CUMULATIVE_COUNT_COLUMN_NAME = "cumulativeCount";
/**
* When hollow commits are found while using incremental source with {@link
HoodieDeltaStreamer},
@@ -259,9 +262,25 @@ public class IncrSourceHelper {
* @param queryInfo Query Info
* @return end instants along with filtered rows.
*/
- public static Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>>
filterAndGenerateCheckpointBasedOnSourceLimit(Dataset<Row> sourceData,
+ @VisibleForTesting
+ static Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>>
filterAndGenerateCheckpointBasedOnSourceLimit(Dataset<Row> sourceData,
long sourceLimit, QueryInfo queryInfo,
CloudObjectIncrCheckpoint
cloudObjectIncrCheckpoint) {
+ return filterAndGenerateCheckpointBasedOnSourceLimit(sourceData,
sourceLimit, Long.MAX_VALUE, queryInfo, cloudObjectIncrCheckpoint);
+ }
+
+ /**
+ * Adjust the source dataset to size based batch based on last checkpoint
key.
+ *
+ * @param sourceData Source dataset
+ * @param sourceLimit Max number of bytes to be read from source
+ * @param numFilesLimit Max number of files to be read from source in this
batch
+ * @param queryInfo Query Info
+ * @return end instants along with filtered rows.
+ */
+ public static Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>>
filterAndGenerateCheckpointBasedOnSourceLimit(Dataset<Row> sourceData,
+
long sourceLimit, long numFilesLimit,
QueryInfo queryInfo,
+
CloudObjectIncrCheckpoint
cloudObjectIncrCheckpoint) {
if (sourceData.isEmpty()) {
// There is no file matching the prefix.
CloudObjectIncrCheckpoint updatedCheckpoint =
@@ -272,54 +291,72 @@ public class IncrSourceHelper {
}
// Let's persist the dataset to avoid triggering the dag repeatedly
sourceData.persist(StorageLevel.MEMORY_AND_DISK());
- // Set ordering in query to enable batching
- Dataset<Row> orderedDf = QueryRunner.applyOrdering(sourceData,
queryInfo.getOrderByColumns());
- Option<String> lastCheckpoint =
Option.of(cloudObjectIncrCheckpoint.getCommit());
- Option<String> lastCheckpointKey =
Option.ofNullable(cloudObjectIncrCheckpoint.getKey());
- Option<String> concatenatedKey = lastCheckpoint.flatMap(checkpoint ->
lastCheckpointKey.map(key -> checkpoint + key));
-
- // Filter until last checkpoint key
- if (concatenatedKey.isPresent()) {
- orderedDf = orderedDf.withColumn("commit_key",
- functions.concat(functions.col(queryInfo.getOrderColumn()),
functions.col(queryInfo.getKeyColumn())));
- // Apply incremental filter
- orderedDf =
orderedDf.filter(functions.col("commit_key").gt(concatenatedKey.get())).drop("commit_key");
- // If there are no more files where commit_key is greater than
lastCheckpointCommit#lastCheckpointKey
- if (orderedDf.isEmpty()) {
- log.info("Empty ordered source, returning endpoint: {}",
queryInfo.getEndInstant());
- sourceData.unpersist();
- // queryInfo.getEndInstant() represents source table's last completed
instant
- // If current checkpoint is c1#abc and queryInfo.getEndInstant() is
c1, return c1#abc.
- // If current checkpoint is c1#abc and queryInfo.getEndInstant() is
c2, return c2.
- CloudObjectIncrCheckpoint updatedCheckpoint =
-
queryInfo.getEndInstant().equals(cloudObjectIncrCheckpoint.getCommit())
- ? cloudObjectIncrCheckpoint
- : new CloudObjectIncrCheckpoint(queryInfo.getEndInstant(),
null);
- return Pair.of(updatedCheckpoint, Option.empty());
+ try {
+ // Set ordering in query to enable batching
+ Dataset<Row> orderedDf = QueryRunner.applyOrdering(sourceData,
queryInfo.getOrderByColumns());
+ Option<String> lastCheckpoint =
Option.of(cloudObjectIncrCheckpoint.getCommit());
+ Option<String> lastCheckpointKey =
Option.ofNullable(cloudObjectIncrCheckpoint.getKey());
+ Option<String> concatenatedKey = lastCheckpoint.flatMap(checkpoint ->
lastCheckpointKey.map(key -> checkpoint + key));
+
+ // Filter until last checkpoint key
+ if (concatenatedKey.isPresent()) {
+ orderedDf = orderedDf.withColumn("commit_key",
+ functions.concat(functions.col(queryInfo.getOrderColumn()),
functions.col(queryInfo.getKeyColumn())));
+ // Apply incremental filter
+ orderedDf =
orderedDf.filter(functions.col("commit_key").gt(concatenatedKey.get())).drop("commit_key");
+ // If there are no more files where commit_key is greater than
lastCheckpointCommit#lastCheckpointKey
+ if (orderedDf.isEmpty()) {
+ log.info("Empty ordered source, returning endpoint: {}",
queryInfo.getEndInstant());
+ // queryInfo.getEndInstant() represents source table's last
completed instant
+ // If current checkpoint is c1#abc and queryInfo.getEndInstant() is
c1, return c1#abc.
+ // If current checkpoint is c1#abc and queryInfo.getEndInstant() is
c2, return c2.
+ CloudObjectIncrCheckpoint updatedCheckpoint =
+
queryInfo.getEndInstant().equals(cloudObjectIncrCheckpoint.getCommit())
+ ? cloudObjectIncrCheckpoint
+ : new CloudObjectIncrCheckpoint(queryInfo.getEndInstant(),
null);
+ return Pair.of(updatedCheckpoint, Option.empty());
+ }
}
- }
- // Limit based on sourceLimit
- WindowSpec windowSpec = Window.orderBy(col(queryInfo.getOrderColumn()),
col(queryInfo.getKeyColumn()));
- // Add the 'cumulativeSize' column with running sum of 'limitColumn'
- Dataset<Row> aggregatedData = orderedDf.withColumn(CUMULATIVE_COLUMN_NAME,
- sum(col(queryInfo.getLimitColumn())).over(windowSpec));
- Dataset<Row> collectedRows =
aggregatedData.filter(col(CUMULATIVE_COLUMN_NAME).leq(sourceLimit));
-
- Row row = null;
- if (collectedRows.isEmpty()) {
- // If the first element itself exceeds limits then return first element
- log.info("First object exceeding source limit: {} bytes", sourceLimit);
- row = aggregatedData.select(queryInfo.getOrderColumn(),
queryInfo.getKeyColumn(), CUMULATIVE_COLUMN_NAME).first();
- collectedRows = aggregatedData.limit(1);
- } else {
- // Get the last row and form composite key
- row = collectedRows.select(queryInfo.getOrderColumn(),
queryInfo.getKeyColumn(), CUMULATIVE_COLUMN_NAME).orderBy(
- col(queryInfo.getOrderColumn()).desc(),
col(queryInfo.getKeyColumn()).desc()).first();
+ // Compute cumulative size and cumulative row count over the same
ordered window so that
+ // both the byte-based and files-based limits select a contiguous prefix
of the ordered set.
+ // Applying both predicates in a single window pass keeps the result
deterministic across
+ // executors and avoids a post-hoc limit() on an unordered dataset.
+ // Note: Window.orderBy() without partitionBy() triggers Spark's
SinglePartition exchange —
+ // the entire filteredSourceData is held on one executor for the window
pass. This is
+ // intentional (running sum and row_number() require a total ordering),
but it bounds how
+ // large numFilesLimit can usefully go. A follow-up could compute the
prefix via
+ // mapPartitions + driver-merge to avoid the single-partition collapse.
+ WindowSpec windowSpec = Window.orderBy(col(queryInfo.getOrderColumn()),
col(queryInfo.getKeyColumn()));
+ Dataset<Row> aggregatedData = orderedDf
+ .withColumn(CUMULATIVE_COLUMN_NAME,
sum(col(queryInfo.getLimitColumn())).over(windowSpec))
+ .withColumn(CUMULATIVE_COUNT_COLUMN_NAME,
row_number().over(windowSpec));
+ Dataset<Row> collectedRows = aggregatedData
+ .filter(col(CUMULATIVE_COLUMN_NAME).leq(sourceLimit)
+ .and(col(CUMULATIVE_COUNT_COLUMN_NAME).leq(numFilesLimit)));
+
+ Row row;
+ if (collectedRows.isEmpty()) {
+ // The first file itself exceeds the byte limit. Since numFilesLimit
>= 1 is enforced at
+ // the call site and row_number() starts at 1, the files limit can
never exclude the first
+ // file. We deliberately take one file even when it exceeds the byte
limit, matching
+ // pre-existing behavior.
+ log.info("First object exceeds source limit: {} bytes", sourceLimit);
+ collectedRows =
aggregatedData.filter(col(CUMULATIVE_COUNT_COLUMN_NAME).equalTo(1));
+ row = collectedRows.select(queryInfo.getOrderColumn(),
queryInfo.getKeyColumn(), CUMULATIVE_COLUMN_NAME).first();
+ } else {
+ // Get the last row and form composite key
+ row = collectedRows.select(queryInfo.getOrderColumn(),
queryInfo.getKeyColumn(), CUMULATIVE_COLUMN_NAME).orderBy(
+ col(queryInfo.getOrderColumn()).desc(),
col(queryInfo.getKeyColumn()).desc()).first();
+ }
+ log.info("Processed batch size: {} bytes",
row.get(row.fieldIndex(CUMULATIVE_COLUMN_NAME)));
+ // Drop the helper count column so returned dataset shape matches
pre-existing contract
+ // (only the source columns plus cumulativeSize).
+ return Pair.of(new CloudObjectIncrCheckpoint(row.getString(0),
row.getString(1)),
+ Option.of(collectedRows.drop(CUMULATIVE_COUNT_COLUMN_NAME)));
+ } finally {
+ sourceData.unpersist();
}
- log.info("Processed batch size: {} bytes",
row.get(row.fieldIndex(CUMULATIVE_COLUMN_NAME)));
- sourceData.unpersist();
- return Pair.of(new CloudObjectIncrCheckpoint(row.getString(0),
row.getString(1)), Option.of(collectedRows));
}
/**
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 4218aaca26d9..42274c859717 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
@@ -347,6 +347,42 @@ public class TestS3EventsHoodieIncrSource extends
S3EventsHoodieIncrSourceHarnes
Assertions.assertEquals(numPartitions,
argumentCaptorForMetrics.getAllValues());
}
+ @Test
+ public void testFilesLimitCheckpointConsistency() throws IOException {
+ String commitTimeForWrites = "2";
+ String commitTimeForReads = "1";
+
+ writeS3MetadataRecords(commitTimeForReads);
+ writeS3MetadataRecords(commitTimeForWrites);
+
+ // 5 files all within the same commit with 100 bytes each
+ List<Triple<String, Long, String>> filePathSizeAndCommitTime = new
ArrayList<>();
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file1.json", 100L, "1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file2.json", 100L, "1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file3.json", 100L, "1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file4.json", 100L, "1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file5.json", 100L, "1"));
+
+ 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(sourceProfileSupplier.getSourceProfile()).thenReturn(null);
+
+ // With a large byte limit all 5 files fit, but
SOURCE_MAX_FILES_PER_SYNC=3 caps processing to
+ // the first 3 files. The checkpoint must be recalculated to reflect the
last file actually
+ // processed (file3), not the byte-limit checkpoint (file5).
+ TypedProperties propsWithFilesLimit = setProps(READ_UPTO_LATEST_COMMIT);
+
propsWithFilesLimit.setProperty(CloudSourceConfig.SOURCE_MAX_FILES_PER_SYNC.key(),
"3");
+ readAndAssert(READ_UPTO_LATEST_COMMIT, Option.of(commitTimeForReads),
10000L,
+ "1#path/to/file3.json", propsWithFilesLimit);
+
+ // Without a files limit, all 5 files are processed and the checkpoint
points to the last file.
+ TypedProperties propsWithoutFilesLimit = setProps(READ_UPTO_LATEST_COMMIT);
+ readAndAssert(READ_UPTO_LATEST_COMMIT, Option.of(commitTimeForReads),
10000L,
+ "1#path/to/file5.json", propsWithoutFilesLimit);
+ }
+
@Test
public void testUnsupportedCheckpoint() {
TypedProperties typedProperties = setProps(READ_UPTO_LATEST_COMMIT);
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestIncrSourceHelper.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestIncrSourceHelper.java
index 49c4ffcb0bfc..cb4e6a8708ed 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestIncrSourceHelper.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestIncrSourceHelper.java
@@ -286,6 +286,137 @@ class TestIncrSourceHelper extends
SparkClientFunctionalTestHarness {
assertTrue(!result.getRight().isPresent());
}
+ /**
+ * Files-limit must produce a contiguous prefix of the ordered set even when
the source dataset
+ * is spread across many Spark partitions. Spans 50 files across 8
partitions; iterates 5 syncs
+ * with numFilesLimit = 10 and asserts every file is consumed exactly once
in order.
+ */
+ @Test
+ void testFilesLimitContiguousAcrossManyPartitions() {
+ List<Triple<String, Long, String>> filePathSizeAndCommitTime = new
ArrayList<>();
+ for (int i = 0; i < 50; i++) {
+ // zero-pad so lexicographic key order matches insertion order
+
filePathSizeAndCommitTime.add(Triple.of(String.format("path/to/file%02d.json",
i), 100L, "commit1"));
+ }
+ Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime, 8);
+
+ QueryInfo queryInfo = new QueryInfo(
+ QUERY_TYPE_INCREMENTAL_OPT_VAL(), "commit0", "commit0",
+ "commit1", "_hoodie_commit_time",
+ "s3.object.key", "s3.object.size");
+
+ CloudObjectIncrCheckpoint cursor = new
CloudObjectIncrCheckpoint("commit0", null);
+ List<String> consumedKeys = new ArrayList<>();
+ long byteLimit = 1_000_000L;
+ long filesLimit = 10L;
+ for (int batch = 0; batch < 5; batch++) {
+ Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>> result =
+
IncrSourceHelper.filterAndGenerateCheckpointBasedOnSourceLimit(inputDs,
byteLimit, filesLimit, queryInfo, cursor);
+ assertTrue(result.getRight().isPresent(), "batch " + batch + "
unexpectedly empty");
+ List<Row> rows = result.getRight().get()
+ .select("s3.object.key")
+ .collectAsList();
+ assertEquals(filesLimit, rows.size(), "batch " + batch + " did not
respect filesLimit");
+ for (Row r : rows) {
+ consumedKeys.add(r.getString(0));
+ }
+ cursor = result.getKey();
+ }
+ assertEquals(50, consumedKeys.size());
+ // Verify keys are returned in the exact order produced; if .limit() on an
unordered dataset
+ // had been used, a multi-partition input would yield duplicates or gaps
under any
+ // non-deterministic order.
+ for (int i = 0; i < 50; i++) {
+ assertEquals(String.format("path/to/file%02d.json", i),
consumedKeys.get(i),
+ "file at position " + i + " is out of order");
+ }
+ }
+
+ /**
+ * Files-limit hits mid-commit. After the truncated batch, next sync must
resume from the
+ * exact next file in the same commit without skipping or re-reading.
+ */
+ @Test
+ void testFilesLimitCrossesCommitBoundary() {
+ List<Triple<String, Long, String>> filePathSizeAndCommitTime = new
ArrayList<>();
+ // commit1: 3 files; commit2: 3 files
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file1.json", 100L,
"commit1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file2.json", 100L,
"commit1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file3.json", 100L,
"commit1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file4.json", 100L,
"commit2"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file5.json", 100L,
"commit2"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file6.json", 100L,
"commit2"));
+ Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime, 4);
+
+ QueryInfo queryInfo = new QueryInfo(
+ QUERY_TYPE_INCREMENTAL_OPT_VAL(), "commit0", "commit0",
+ "commit2", "_hoodie_commit_time",
+ "s3.object.key", "s3.object.size");
+
+ // First batch: filesLimit = 4 truncates at file4 (first file of commit2)
+ Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>> first =
IncrSourceHelper.filterAndGenerateCheckpointBasedOnSourceLimit(
+ inputDs, 1_000_000L, 4L, queryInfo, new
CloudObjectIncrCheckpoint("commit0", null));
+ assertEquals("commit2#path/to/file4.json", first.getKey().toString());
+ assertEquals(4, first.getRight().get().count());
+
+ // Second batch: resume from the prior checkpoint; remaining files are
file5 and file6
+ Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>> second =
IncrSourceHelper.filterAndGenerateCheckpointBasedOnSourceLimit(
+ inputDs, 1_000_000L, 4L, queryInfo, first.getKey());
+ assertEquals("commit2#path/to/file6.json", second.getKey().toString());
+ assertEquals(2, second.getRight().get().count());
+ }
+
+ /**
+ * Files-limit alone (when bytes-limit is non-binding) still selects a
contiguous prefix.
+ */
+ @Test
+ void testFilesLimitBindingOverByteLimit() {
+ List<Triple<String, Long, String>> filePathSizeAndCommitTime = new
ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+
filePathSizeAndCommitTime.add(Triple.of(String.format("path/to/file%02d.json",
i), 100L, "commit1"));
+ }
+ Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime, 4);
+
+ QueryInfo queryInfo = new QueryInfo(
+ QUERY_TYPE_INCREMENTAL_OPT_VAL(), "commit0", "commit0",
+ "commit1", "_hoodie_commit_time",
+ "s3.object.key", "s3.object.size");
+
+ Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>> result =
IncrSourceHelper.filterAndGenerateCheckpointBasedOnSourceLimit(
+ inputDs, 1_000_000L, 3L, queryInfo, new
CloudObjectIncrCheckpoint("commit0", null));
+ assertEquals("commit1#path/to/file02.json", result.getKey().toString());
+ assertEquals(3, result.getRight().get().count());
+ }
+
+ /**
+ * Files-limit larger than dataset: byte-limit drives the checkpoint as if
no files-limit
+ * existed. Guards against the previous size-based recalculation branch
silently breaking
+ * the byte-only path.
+ */
+ @Test
+ void testFilesLimitLargerThanAvailable() {
+ List<Triple<String, Long, String>> filePathSizeAndCommitTime = new
ArrayList<>();
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file1.json", 100L,
"commit1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file2.json", 100L,
"commit1"));
+ filePathSizeAndCommitTime.add(Triple.of("path/to/file3.json", 100L,
"commit1"));
+ Dataset<Row> inputDs = generateDataset(filePathSizeAndCommitTime, 2);
+
+ QueryInfo queryInfo = new QueryInfo(
+ QUERY_TYPE_INCREMENTAL_OPT_VAL(), "commit0", "commit0",
+ "commit1", "_hoodie_commit_time",
+ "s3.object.key", "s3.object.size");
+
+ Pair<CloudObjectIncrCheckpoint, Option<Dataset<Row>>> result =
IncrSourceHelper.filterAndGenerateCheckpointBasedOnSourceLimit(
+ inputDs, 1_000_000L, 1000L, queryInfo, new
CloudObjectIncrCheckpoint("commit0", null));
+ assertEquals("commit1#path/to/file3.json", result.getKey().toString());
+ assertEquals(3, result.getRight().get().count());
+ }
+
+ private Dataset<Row> generateDataset(List<Triple<String, Long, String>>
filePathSizeAndCommitTime, int numPartitions) {
+ JavaRDD<String> testRdd =
jsc.parallelize(getSampleS3ObjectKeys(filePathSizeAndCommitTime),
numPartitions);
+ return spark().read().json(testRdd);
+ }
+
private HoodieRecord generateS3EventMetadata(String commitTime, String
bucketName, String objectKey, Long objectSize) {
String partitionPath = bucketName;
HoodieSchema schema = S3_METADATA_SCHEMA;