nsivabalan commented on PR #18076:
URL: https://github.com/apache/hudi/pull/18076#issuecomment-4827339221

   Thanks for tackling this @lokeshj1703 — the driver-OOM problem is real and 
the config shape (advanced, sensible default, versioned, dot-separated) is well 
done. Sharing a principal-level review below. One blocking correctness concern, 
otherwise net-positive.
   
   ## Mergeability summary
   
   - Goal is sound; problem statement in #18075 is legitimate.
   - One **🚨 BLOCKING** correctness risk around `.limit(n)` on an unordered 
Dataset — described below. Fixable with a small targeted change.
   - No storage / public-API / config-semantics breakage. Additive config with 
backward-compatible default.
   - Test coverage is thin: one happy-path S3 test that only exercises a single 
Spark partition. Edge cases around multi-partition determinism, GCS path, 
commit boundaries, and limit=1 are missing.
   - Size is small (84/-4 across 5 files). No need to split.
   - No timeline/metadata/concurrency surfaces touched.
   
   ## 🚨 BLOCKING
   
   ### 1. `.limit(n)` on `collectedRows` is non-deterministic — risk of silent 
file skipping / data loss
   
   `CloudDataFetcher.java` (the new block after 
`filterAndGenerateCheckpointBasedOnSourceLimit`):
   
   \`\`\`java
   Dataset<Row> metadataRows = checkPointAndDataset.getRight().get()
       .limit((int) Math.min(numFilesLimit, Integer.MAX_VALUE));
   ...
   if (cloudObjectMetadata.size() >= numFilesLimit) {
     checkpoint = IncrSourceHelper.getCheckpointFromLastRow(metadataRows, 
queryInfo);
   }
   \`\`\`
   
   The dataset returned by \`filterAndGenerateCheckpointBasedOnSourceLimit\` is 
\`collectedRows\`, produced via 
\`aggregatedData.filter(col(CUMULATIVE_COLUMN_NAME).leq(sourceLimit))\` on top 
of a window-\`orderBy\`. Spark's logical plan does **not** guarantee that a 
subsequent \`.limit(n)\` will return the first \`n\` rows of the window order 
once the dataset spans multiple partitions — \`GlobalLimit\` operates over 
partitions whose internal ordering is no longer guaranteed after the 
window+filter. There is no explicit \`orderBy\` immediately preceding the 
\`.limit()\` call.
   
   Concrete failure mode:
   
   1. \`collectedRows\` is ordered as {f1, f2, f3, f4, f5}.
   2. \`.limit(3)\` returns an arbitrary subset, say {f1, f3, f5}.
   3. \`getObjectMetadata(...)\` materializes {f1, f3, f5} → these are the 
files actually ingested.
   4. \`getCheckpointFromLastRow\` reorders this subset desc and picks f5 as 
the new checkpoint.
   5. Next sync starts strictly *after* f5 → **f2 and f4 are never processed**. 
Silent data loss in an append-only ingest pipeline.
   
   The new unit test passes only because in a single-node test setup with one 
partition \`.limit()\` happens to be deterministic. This will not hold on real 
clusters with multiple partitions or skew.
   
   **Recommended fix**: push the files-limit into 
\`IncrSourceHelper.filterAndGenerateCheckpointBasedOnSourceLimit\`, alongside 
the existing byte-based cumulative computation, using \`row_number()\` over the 
same window:
   
   \`\`\`java
   WindowSpec windowSpec = Window.orderBy(col(orderColumn), col(keyColumn));
   Dataset<Row> aggregatedData = orderedDf
       .withColumn(CUMULATIVE_COLUMN_NAME, 
sum(col(limitColumn)).over(windowSpec))
       .withColumn(CUMULATIVE_COUNT_COLUMN, row_number().over(windowSpec));
   Dataset<Row> collectedRows = aggregatedData.filter(
       col(CUMULATIVE_COLUMN_NAME).leq(sourceLimit)
           .and(col(CUMULATIVE_COUNT_COLUMN).leq(filesLimit)));
   \`\`\`
   
   The existing \`row = collectedRows.select(...).orderBy(...desc).first()\` at 
the end of \`filterAndGenerateCheckpointBasedOnSourceLimit\` then produces the 
correct checkpoint with no second pass. This also:
   
   - piggybacks on the existing sorted-window infra (returns a contiguous 
prefix),
   - removes the second \`orderBy().first()\` Spark job in 
\`getCheckpointFromLastRow\`,
   - removes the duplicate \`persist\`/\`unpersist\` block in 
\`CloudDataFetcher\`,
   - removes the need for the \`cloudObjectMetadata.size() >= numFilesLimit\` 
heuristic (see ⚠️ #3),
   - makes \`getCheckpointFromLastRow\` unnecessary.
   
   ### 2. Resource leak: persisted dataset not unpersisted on exception
   
   In the new block in \`CloudDataFetcher\`:
   
   \`\`\`java
   metadataRows.persist(StorageLevel.MEMORY_AND_DISK());
   ...
   List<CloudObjectMetadata> cloudObjectMetadata = 
CloudObjectsSelectorCommon.getObjectMetadata(...);
   ...
   metadataRows.unpersist();
   \`\`\`
   
   If \`getObjectMetadata\` or \`getCheckpointFromLastRow\` throws (S3 list 
failure, executor OOM, schema mismatch), the cached block leaks for the 
lifetime of the Spark application. With the Streamer running in a long-lived 
loop this accumulates across syncs. Wrap in try/finally — or better, fold into 
the existing persist/unpersist scope inside 
\`filterAndGenerateCheckpointBasedOnSourceLimit\` (the fix in #1 does this for 
free).
   
   ## ⚠️ IMPORTANT
   
   ### 3. Size-based recalculation condition can miss the actual files-limit 
case
   
   \`cloudObjectMetadata.size() >= numFilesLimit\` — \`cloudObjectMetadata\` 
comes from \`.select(...).distinct().mapPartitions(...)\` inside 
\`getObjectMetadata\`, i.e. **deduplicated** by (bucket, key, size). If 
\`metadataRows\` contained duplicate rows (rare but possible if the same S3 
event was emitted twice), \`size()\` < \`numFilesLimit\` even though 
\`.limit(n)\` truncated the dataset → checkpoint is *not* recalculated → next 
sync skips the truncated tail. The fix in #1 removes this branch entirely. If 
keeping the current structure, compare against the row count of 
\`metadataRows\` (after limit), not the deduped size.
   
   ### 4. New test doesn't cover the multi-partition non-determinism
   
   \`testFilesLimitCheckpointConsistency\` runs on 5 rows in a single partition 
where \`.limit()\` is incidentally deterministic. Add a test that:
   
   - generates ≥ 50 rows,
   - repartitions the source dataset into ≥ 4 partitions,
   - sets \`SOURCE_MAX_FILES_PER_SYNC = 10\`,
   - asserts that the next sync resumes *immediately* after the last processed 
file, and that (batch1 ∪ batch2) equals the full ordered list with no gaps.
   
   This is the test that would have caught #1.
   
   ### 5. Missing GCS coverage
   
   The fix lives in \`CloudDataFetcher.fetchPartitionedSource\`, used by both 
\`S3EventsHoodieIncrSource\` and \`GcsEventsHoodieIncrSource\`. Only an S3 test 
was added. Add a mirror test in \`TestGcsEventsHoodieIncrSource\` so we don't 
regress the GCS path silently.
   
   ## 💬 SUGGESTIONS
   
   ### 6. Default of 10M files may be too lenient as a guardrail
   
   The motivating problem is driver OOM from per-file metadata held in the 
driver. 10M \`CloudObjectMetadata\` objects (path string + size) at ~200 bytes 
each is ~2 GB on the driver — already enough to OOM most default driver heaps. 
Consider defaulting to 1M or 500K and letting power users tune upward. 
Non-blocking; author's call.
   
   ### 7. Log message ordering
   
   \`log.info(\"Total number of files to process :{}\", ...)\` fires before any 
files-limit recalculation. Consider logging the *final* checkpoint and count 
together after the recalculation block so operators see a single coherent line.
   
   ## 💅 NITS
   
   ### 8. Unrelated cleanup mixed in
   
   The \`isNullOrEmpty → .isEmpty()\` swap in 
\`CloudObjectsSelectorCommon.loadAsDataset\` is unrelated to the files-limit. 
Fine as-is but ideally split.
   
   ### 9. Javadoc on \`getCheckpointFromLastRow\`
   
   Worth clarifying the dataset is expected to already be ordered by the same 
columns. Becomes moot if #1 is applied — the method is no longer needed.
   
   ## Suggested additional tests
   
   - **Unit**: \`testFilesLimit_singleFileExceedsBytesLimit\` — first file is 
larger than \`SOURCE_MAX_BYTES_PER_PARTITION\` AND \`SOURCE_MAX_FILES_PER_SYNC 
= 1\`; assert exactly one file processed and checkpoint points to it.
   - **Unit**: \`testFilesLimit_one\` — \`SOURCE_MAX_FILES_PER_SYNC = 1\` 
across 5 syncs; assert checkpoint advances exactly one file per sync with no 
duplicates/gaps.
   - **Unit**: \`testFilesLimit_crossesCommitBoundary\` — 3 files in c1, 3 in 
c2; \`SOURCE_MAX_FILES_PER_SYNC = 4\`; assert first checkpoint is 
\`c2#<2nd-file-of-c2>\` and second sync picks up the remaining file.
   - **Unit**: \`testFilesLimit_largerThanAvailable\` — limit = 1000, only 5 
files; assert no recalculation path runs and checkpoint matches the byte-based 
one.
   - **Functional**: GCS mirror of \`testFilesLimitCheckpointConsistency\`.
   - **Functional**: end-to-end Streamer with files-limit set, multi-batch 
progression, asserting `sum(ingested files) == produced files` with no 
duplicates.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to