Skip to content

fix(spark): incremental query returns no rows when projection excludes _hoodie_commit_time - #19568

Open
nsivabalan wants to merge 1 commit into
apache:masterfrom
nsivabalan:fix_cow_incremental_count
Open

fix(spark): incremental query returns no rows when projection excludes _hoodie_commit_time#19568
nsivabalan wants to merge 1 commit into
apache:masterfrom
nsivabalan:fix_cow_incremental_count

Conversation

@nsivabalan

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

A CoW incremental query returns zero rows whenever its projection excludes _hoodie_commit_time. count() is the most visible instance, and it fails silently — a wrong answer that reads as "no new data" rather than an error.

Reproduced on unmodified master (65cf7e8ede7d), table version 10, on a plain CoW table with no special configuration:

collect()               -> 2 rows   (correct)
count()                 -> 0 rows   (WRONG)
select("k").collect()   -> 0 rows   (WRONG)
select("k").count()     -> 0 rows   (WRONG)

snapshot count()        -> 4 rows   (correct — snapshot reads are unaffected)

Not specific to a table version, a table type beyond CoW, or any meta-fields configuration.

Root cause

An incremental query filters on _hoodie_commit_timeIsNotNull plus an In over the instants in range — and those filters are pushed into the file reader as requiredFilters. The reader evaluates a pushed predicate against the schema it was asked to read, which contains only the columns the query projects.

When the projection omits _hoodie_commit_time, the predicate cannot be satisfied and every row is dropped.

collect() works only incidentally: it happens to project every column, including the filtered one. It is not a working case so much as a case that has not failed yet. count() projects nothing at all, and select(subset) projects only what the user asked for — both hit it.

Verified by instrumenting readBaseFile rather than by inspection. The filters are identical in all three cases; only the schema differs:

Query schema passed to the reader filters rows
collect() all 7 columns, incl. _hoodie_commit_time IsNotNull(_hoodie_commit_time), In(...) 2 ✅
count() [] identical 0 ❌
select("k") [k] identical 0 ❌

Worth noting what is not the bug, since both look suspicious and both are correct:

  • isCount excludes incremental deliberately (HoodieFileGroupReaderBasedFileFormat:254). You cannot answer an incremental count() from row counts, because only some rows fall in the commit range.
  • The fallthrough to the plain Parquet reader for column-less CoW reads is intentional — see HUDI-8079 (31eea3b9a851), which only restructured an inner if into the guard.

Both mechanisms are right in isolation. The defect is the seam between them: a required filter references a column, and nothing guarantees that column is in the schema handed to the reader.

Summary and Changelog

readBaseFile now reads any column a required filter references but the projection omits, then projects it back out before returning so the caller's schema stays exact.

Two details worth review:

  • Partition columns are excluded. The caller appends those from partition values rather than reading them from the file, so adding one here would duplicate the field and shift every ordinal after it. Not reachable by the repro above — it needs a filter over a partition column — but wrong regardless.
  • Candidate columns come from the full table schema, not the requested schema. On the failing path the requested schema is itself empty, so it cannot supply the column the filter needs. (My first attempt sourced them from the requested schema; it compiled, read correctly, and did nothing.)

Impact

Behavior: incremental queries that previously returned zero rows now return the correct rows. No other query shape changes.

Strict no-op for every non-incremental read. Snapshot, CDC and bootstrap relations all supply Seq.empty for required filters, so there is nothing to reference and nothing to add. No extra column is read on the ordinary count() path — which matters, since that is the most common query in Hudi.

Performance: on an incremental read whose projection omits the filtered column, one extra column is read and then projected away. That is the minimum needed to evaluate the predicate correctly.

API / storage: unchanged.

Risk Level

medium

The change is small and its no-op property is structural rather than incidental. Raised above "low" only because readBaseFile is on the shared read path for every Spark query — snapshot, incremental, CDC, bootstrap and MoR all route through it — so the blast radius of a mistake here is wide even though this change is narrow.

Tests

TestIncrementalQueryProjection, four cases:

  1. count() agrees with collect()
  2. projected reads agree with unprojected ones
  3. a projected read returns the right rows, not merely the right number — a filter that silently matched everything would pass a count-only assertion
  4. snapshot reads are unaffected, guarding the no-op property

Verified not vacuous: with the fix reverted, exactly the three incremental cases fail and the snapshot guard still passes. That check mattered here — the first version of this fix compiled cleanly and did nothing, and a test run only against the fixed build would have looked identical.

Contributor's checklist

  • Read through contributor's guide
  • Change Logs and Impact were stated clearly
  • Adequate tests were added if applicable
  • CI passed

🤖 Generated with Claude Code

An incremental query filters on _hoodie_commit_time and that filter is pushed into
the file reader, but the reader is only given the columns the query projects. When
the projection omits the filtered column the predicate cannot be satisfied and every
row is dropped -- silently, with no error.

So on a CoW incremental query:

    collect()                 -> 2 rows   (correct)
    count()                   -> 0 rows   (wrong)
    select("k").collect()     -> 0 rows   (wrong)
    select("k").count()       -> 0 rows   (wrong)

collect() worked only incidentally: it happens to project every column, including
the filtered one. The general statement is that any incremental query whose
projection excludes _hoodie_commit_time returns nothing, and count() is simply its
most visible instance -- a silent wrong answer that reads as "no new data".

readBaseFile now reads any column a required filter references but the projection
omits, then projects it back out before returning so the caller's schema is exact.
Partition columns are excluded: the caller appends those from partition values, so
adding one here would duplicate the field and shift every ordinal after it.

This is a strict no-op for every non-incremental read. Snapshot, CDC and bootstrap
relations all supply Seq.empty for required filters, so there is nothing to
reference and nothing to add -- no extra column is read by the ordinary count()
path, which matters because that is the most common query in Hudi.

Reproduced on unmodified master (65cf7e8) at table version 10, and confirmed
not to be specific to a table version or to any meta-fields configuration.

Tests: TestIncrementalQueryProjection covers count(), projected reads, the actual
row values rather than just counts, and a snapshot no-op guard. Verified not
vacuous -- with the fix reverted, exactly the three incremental cases fail and the
snapshot guard still passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR fixes a silent data-loss bug where a CoW incremental query returns zero rows whenever its projection omits _hoodie_commit_time, by reading any filter-referenced column that the projection excludes and then projecting it back out before returning rows. I traced the projection logic across all three read branches (by-name projections in both generateUnsafeProjectionInternal and ColumnarBatchUtils.generateProjection make the append-and-drop robust), confirmed the presence check against requiredSchemaIn is equivalent for non-partition columns given how outputSchema/requestedStructType are built at the call site, and verified the change is a strict no-op for snapshot/CDC/bootstrap reads (which supply Seq.empty required filters). One completeness question on MOR coverage is worth a look in the inline comments. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. One small naming nit on the helper's candidates parameter — otherwise the fix is clear and well-commented.

return DataTypes.createStructType(new StructField[] {
DataTypes.createStructField("record_key", DataTypes.StringType, true),
DataTypes.createStructField("partition_path", DataTypes.StringType, true),
DataTypes.createStructField("payload", DataTypes.StringType, true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 The fix and all four tests are CoW-only. For MOR incremental, file slices that have log files route through the HoodieFileGroupReader path (the case Some(fileSlice) if !isCount && (requiredSchema.nonEmpty || logFiles present) branch) rather than readBaseFile, and there requiredFilters are handed to SparkFileFormatInternalRowReaderContext and applied against getAppliedRequiredSchema(...). Have you checked whether a MOR incremental count()/projected read hits the same missing-_hoodie_commit_time predicate on the base-file read there? If so it may warrant either the same augmentation or at least a MOR test case; if it's already covered by the merge schema, a one-line note here would help.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

* Partition columns are excluded by the caller, which appends them separately.
*/
private def filterColumnsMissingFrom(target: StructType,
candidates: StructType,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: candidates is a bit abstract here — the Javadoc calls it "the full table schema", so could you name the parameter fullSchema (or tableSchema) to make the call-site self-documenting?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Aug 8, 2026
@codecov-commenter

codecov-commenter commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.84%. Comparing base (65cf7e8) to head (8fd4657).

Files with missing lines Patch % Lines
...parquet/HoodieFileGroupReaderBasedFileFormat.scala 88.88% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19568      +/-   ##
============================================
- Coverage     76.84%   76.84%   -0.01%     
- Complexity    32379    32391      +12     
============================================
  Files          2522     2522              
  Lines        139106   139125      +19     
  Branches      16713    16719       +6     
============================================
+ Hits         106892   106906      +14     
- Misses        24621    24628       +7     
+ Partials       7593     7591       -2     
Components Coverage Δ
hudi-common 83.26% <ø> (-0.01%) ⬇️
hudi-client 81.98% <ø> (-0.01%) ⬇️
hudi-flink 84.69% <ø> (-0.02%) ⬇️
hudi-spark-datasource 70.62% <88.88%> (+0.02%) ⬆️
hudi-utilities 73.66% <ø> (+0.03%) ⬆️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.49% <ø> (-0.02%) ⬇️
hudi-sync 75.11% <ø> (ø)
hudi-io 79.36% <ø> (-0.10%) ⬇️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 50.14% <40.74%> (-0.01%) ⬇️
flink-integration-tests 48.94% <ø> (-0.01%) ⬇️
hadoop-mr-java-client 43.73% <ø> (+<0.01%) ⬆️
integration-tests 13.57% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 49.63% <ø> (ø)
spark-java-tests 51.58% <85.18%> (+<0.01%) ⬆️
spark-scala-tests 45.97% <70.37%> (-0.01%) ⬇️
utilities 36.59% <66.66%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...parquet/HoodieFileGroupReaderBasedFileFormat.scala 83.62% <88.88%> (+1.18%) ⬆️

... and 17 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hudi-bot

hudi-bot commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M PR with lines of changes in (100, 300]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants