Skip to content

fix(spark): apply procedure filters before the limit, not after - #19974

Open
rangareddy wants to merge 2 commits into
apache:masterfrom
rangareddy:fix-19862-filter-before-limit
Open

rangareddy wants to merge 2 commits into
apache:masterfrom
rangareddy:fix-19862-filter-before-limit

Conversation

@rangareddy

@rangareddy rangareddy commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

Closes #19862.

Every show_* procedure that accepts both limit and filter truncated its rows to limit first and evaluated filter on the truncated list, so limit => 10, filter => ... returned the matching subset of the first 10 rows rather than the first 10 matching rows. With a selective filter the result is empty whenever the matches sit past the cut-off, and since every one of these procedures applies a default limit (10, 20 or 100), a plain filter => ... call with no explicit limit was affected too.

Introduced alongside the generic filter option in #13736 / #13790.

Summary and Changelog

BaseProcedure gains applyFilterAndLimit(results, filter, schema, limit), which filters and then truncates, plus hasFilter and resolveLimit helpers (the latter turning an optional limit argument into a bound, where "unset" means no bound rather than zero). The ten affected procedures now call it. They are not all the same shape, and the difference is the substance of the change:

Truncating an already-materialised list. ShowTablePropertiesProcedure, ShowFileSystemViewProcedure, ShowHoodieLogFileMetadataProcedure and ShowMetadataTableFilesProcedure just dropped the early stream().limit(...); the rows were already in memory, so nothing about the work done changes.

Bounding at the source. ShowBootstrapMappingProcedure and ShowFsPathDetailProcedure used df.orderBy(...).limit(n).collect(), and ShowInvalidParquetProcedure used rdd.take(n). The bound now moves after the filter. To keep the unfiltered path exactly as it was, the early bound is only skipped when a filter is actually present.

Bounding real work. In ShowCleansProcedure, ShowTimelineProcedure and ShowHoodieLogFileRecordsProcedure, limit is not display truncation: it caps how many clean instants get their metadata read and how many records are pulled out of log files. Filtering first without care would make those unbounded, turning a correctness fix into a performance regression.

ShowCleansProcedure and ShowTimelineProcedure use scanLimit = if (hasFilter(filter)) Int.MaxValue else limit, so a call with no filter does exactly the work it did before and only the filtered path reads further. limit is never used to size an allocation on those paths, so Int.MaxValue is only ever a take bound or a loop comparison.

ShowHoodieLogFileRecordsProcedure needed more than that, because lifting its bound would buffer every record of every matched log file into an on-heap list and could OOM the driver on a large file group. It instead filters incrementally: rows are collected into a small batch, the filter is evaluated per batch, only the matches are retained, and the scan stops as soon as limit matches have been found. Heap stays bounded by the batch plus the matches no matter how much the log files hold. With no filter applyFilter is a passthrough, so a batch of one reproduces the previous bound exactly.

ShowTimelineProcedure needs one extra condition. getTimelineEntries deliberately ignores limit when both startTime and endTime are given ("Apply limit only if time range is not fully specified"), so reapplying the bound unconditionally would have truncated a fully specified range to the default of 20. The reapplied bound honours that case.

The six other procedures that call applyFilter (ShowBootstrapPartitionsProcedure, ShowColumnStatsOverlapProcedure, ShowFileStatusProcedure, ShowMetadataTableColumnStatsProcedure, ShowMetadataTablePartitionsProcedure, ShowMetadataTableStatsProcedure) take no limit at all and are untouched, so the list of ten in the issue is complete.

Verification

Three regression tests, each confirmed to fail without the corresponding fix.

TestShowCleansProcedures: three cleans are created, then the oldest is requested with limit => 1. Cleans come back newest first, so the match sits past a limit of 1.

Test show_cleans applies the filter before the limit *** FAILED ***
  Array() had length 0 instead of expected length 1
  limit must bound the rows the filter matched, not the rows the filter was shown;
  got 0 rows for clean_time = 20260916090635697

TestHoodieLogFileProcedure: with two records in the log files, each is requested with limit => 1 and a filter naming it. Asserting both keeps this independent of scan order, since whichever record is not first is invisible to a filter applied after a limit of 1. Bounding the scan by rows seen rather than rows matched fails it:

Test Call show_logfile_records Procedure with merge and filter *** FAILED ***
  Expected 1, but got 0
  limit => 1 must return the row matching b2, wherever it sits in the scan

TestShowTimelineTableProcedure Test Case 13, which runs across all four existing variants (V1/V2 x COW/MOR): a fully specified time range with limit => 1 must not be truncated. Reintroducing the unconditional bound fails it everywhere:

Test show_timeline with various parameters - V2 MOR *** FAILED ***
  had length 1 instead of expected length 39
  Test 13: a fully specified range must not be truncated by limit, expected 39 got 1
Test show_timeline with various parameters - V2 COW *** FAILED ***
  had length 1 instead of expected length 34

Full procedure package on Spark 3.5 / Scala 2.12:

mvn test -Punit-tests -Dspark3.5 -Dscala-2.12 -pl hudi-spark-datasource/hudi-spark \
  -DwildcardSuites=org.apache.spark.sql.hudi.procedure
  -> Suites: completed 50, aborted 0
  -> Tests: succeeded 263, failed 0, canceled 0, ignored 2, pending 0

mvn scalastyle:check checkstyle:check -pl hudi-spark-datasource/hudi-spark
  -> Found 0 errors

Impact

limit => n, filter => ... now returns the first n matching rows instead of the matches among the first n rows. Calls that pass no filter are unaffected, including the work they do: the early bound is only lifted when a filter is present.

One behaviour change worth calling out: with a filter, show_cleans reads the metadata of every clean rather than only the newest limit, and show_timeline reads the whole timeline rather than the first limit instants. That is required for the filter to see the rows it is supposed to match, it is bounded by the size of the timeline, and the unfiltered path, which is the common one, is unchanged.

show_logfile_records is deliberately not in that list. An earlier revision of this PR did lift its bound the same way, and this description said so; that was wrong, because the records it reads are unbounded by anything the table caps, so a plain filter => ... on a large file group could have exhausted the driver heap where it previously returned quickly. It now filters incrementally and stops at limit matches, so it stays bounded.

Risk Level

low: the change is confined to how limit and filter compose inside these procedures. No API, config, or table format change, and no engine or table-type specific behaviour is involved.

Documentation Update

none: this restores the documented meaning of limit combined with filter rather than changing it.

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable
  • CI passes on my PR

Every show_* procedure that takes both limit and filter truncated to limit
first and evaluated the filter on the truncated list, so
`limit => 10, filter => ...` returned the matches among the first 10 rows
rather than the first 10 matching rows. Every one of these procedures has a
default limit, so a plain `filter => ...` call was affected too.

BaseProcedure gains applyFilterAndLimit, which filters and then truncates.
Where limit only truncated an already-materialised list, the early bound is
simply dropped. Where it bounded real work, capping how many clean instants
get their metadata read and how many records are pulled out of log files, it
is lifted only when a filter is present, so an unfiltered call does exactly
the work it did before.

show_timeline needs one extra condition: getTimelineEntries deliberately
ignores limit when both startTime and endTime are given, so reapplying the
bound unconditionally would truncate a fully specified range to the default.
@codecov-commenter

codecov-commenter commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.43137% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.33%. Comparing base (72b9271) to head (f1aa09a).
⚠️ Report is 14 commits behind head on master.

Files with missing lines Patch % Lines
...procedures/ShowHoodieLogFileRecordsProcedure.scala 80.00% 0 Missing and 4 partials ⚠️
...and/procedures/ShowBootstrapMappingProcedure.scala 33.33% 0 Missing and 2 partials ⚠️
...command/procedures/ShowFsPathDetailProcedure.scala 50.00% 0 Missing and 2 partials ⚠️
...rk/sql/hudi/command/procedures/BaseProcedure.scala 66.66% 0 Missing and 1 partial ⚠️
...mmand/procedures/ShowInvalidParquetProcedure.scala 50.00% 0 Missing and 1 partial ⚠️
...udi/command/procedures/ShowTimelineProcedure.scala 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master   #19974   +/-   ##
=========================================
  Coverage     80.32%   80.33%           
+ Complexity    34745    34741    -4     
=========================================
  Files          2545     2545           
  Lines        142491   142458   -33     
  Branches      17306    17392   +86     
=========================================
- Hits         114463   114440   -23     
+ Misses        20126    20117    -9     
+ Partials       7902     7901    -1     
Components Coverage Δ
hudi-common 83.85% <ø> (-0.01%) ⬇️
hudi-client 83.36% <ø> (+0.01%) ⬆️
hudi-flink 85.69% <ø> (+0.05%) ⬆️
hudi-spark-datasource 73.77% <78.43%> (+<0.01%) ⬆️
hudi-utilities 78.15% <ø> (-0.03%) ⬇️
hudi-cli 69.99% <ø> (ø)
hudi-hadoop 70.91% <ø> (ø)
hudi-sync 75.99% <ø> (ø)
hudi-io 81.61% <ø> (ø)
hudi-timeline-service 83.34% <ø> (ø)
hudi-cloud 81.00% <ø> (+<0.01%) ⬆️
hudi-kafka-connect 53.20% <ø> (-0.77%) ⬇️
Flag Coverage Δ
common-and-other-modules 52.09% <0.00%> (+0.02%) ⬆️
flink-integration-tests 49.14% <ø> (+0.03%) ⬆️
hadoop-mr-java-client 43.92% <ø> (+0.01%) ⬆️
integration-tests 13.46% <0.00%> (+<0.01%) ⬆️
spark-client-hadoop-common 38.56% <0.00%> (+0.01%) ⬆️
spark-java-tests 52.30% <0.00%> (+0.01%) ⬆️
spark-scala-tests 46.96% <78.43%> (+0.05%) ⬆️
utilities 36.84% <0.00%> (+0.02%) ⬆️

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

Files with missing lines Coverage Δ
.../hudi/command/procedures/ShowCleansProcedure.scala 94.28% <100.00%> (+0.05%) ⬆️
...mmand/procedures/ShowFileSystemViewProcedure.scala 85.88% <100.00%> (ø)
...rocedures/ShowHoodieLogFileMetadataProcedure.scala 82.27% <100.00%> (ø)
...d/procedures/ShowMetadataTableFilesProcedure.scala 90.47% <100.00%> (+1.83%) ⬆️
...mand/procedures/ShowTablePropertiesProcedure.scala 96.66% <100.00%> (ø)
...rk/sql/hudi/command/procedures/BaseProcedure.scala 65.57% <66.66%> (+0.05%) ⬆️
...mmand/procedures/ShowInvalidParquetProcedure.scala 91.66% <50.00%> (ø)
...udi/command/procedures/ShowTimelineProcedure.scala 86.73% <75.00%> (-0.38%) ⬇️
...and/procedures/ShowBootstrapMappingProcedure.scala 81.03% <33.33%> (-0.33%) ⬇️
...command/procedures/ShowFsPathDetailProcedure.scala 83.05% <50.00%> (+3.68%) ⬆️
... and 1 more

... and 30 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.

@rangareddy
rangareddy marked this pull request as ready for review September 17, 2026 04:01

@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! The PR moves filter evaluation ahead of limit in the ten show_* procedures so that limit bounds the matching rows rather than the rows the filter gets to see, and lifts the source-side bound (DataFrame limit, rdd.take, clean/timeline/log-record scan caps) only when a filter is present. The logic traces cleanly across all ten procedures and the Int.MaxValue scan limits are safe against the loader's >=-based checks. One thing worth double-checking in the inline comments: the log-file-records procedure now materialises every record on the driver when a filter is supplied, which may be a memory concern on large file groups. 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. Logic looks correct and well-commented; one recurring duplication worth a shared helper.

cc @yihua

Review feedback on the first revision: lifting the scan bound so a filter
could see every record made show_logfile_records buffer all records of every
matched log file into an on-heap list, so a plain `filter => ...` on a large
file group could exhaust the driver heap where it previously returned quickly.

Rows are now collected into a small batch, the filter is evaluated per batch,
only matches are retained, and the scan stops once `limit` matches have been
found. Heap is bounded by the batch plus the matches regardless of how much
the log files hold. applyFilter is a passthrough when no filter is set, so a
batch of one reproduces the previous bound exactly.

Also pull the repeated optional-limit resolution into BaseProcedure.resolveLimit.
@rangareddy
rangareddy marked this pull request as draft September 18, 2026 11:38
@hudi-bot

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

@rangareddy
rangareddy marked this pull request as ready for review September 21, 2026 04:38

@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! The PR makes the show_* procedures apply filter before limit so limit bounds the matching rows, and adds an incremental batch-filter path for show_logfile_records. The remaining question in the inline comment is about the unbounded archived-timeline load that a filter now triggers in show_timeline/show_cleans. 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. A couple of small simplification/dedup suggestions below; otherwise the refactor reads well.

applyFilter(timelineEntries, filter, outputType)
// getTimelineEntries applies `limit` while collecting, so with a filter it has to be lifted here and
// reapplied to the matching rows; otherwise the filter only ever sees the first `limit` instants.
val scanLimit = if (hasFilter(filter)) Int.MaxValue else limit

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.

🤖 With showArchived => true and any filter, scanLimit = Int.MaxValue now flows into loadCompletedInstantDetailsInMemory / loadCompactionDetailsInMemory, so the whole archived timeline (with commit metadata, in METADATA load mode) is materialised on the driver before the filter runs. For a long-lived table that's a lot more than the previous limit instants. Is that acceptable here, or would the same incremental approach you used for show_logfile_records (scan newest-first in chunks until limit matches are found) be worth applying? Same shape in ShowCleansProcedure where every archived clean's metadata is now read.

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

(activeResults ++ archivedResults)
.sortWith((a, b) => a.getString(0) > b.getString(0))
.take(limit)
.take(scanLimit)

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: this .take(scanLimit) looks redundant now — applyFilterAndLimit already applies limit below, and when scanLimit is Int.MaxValue this is a no-op. Could we drop it?

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

val limitValue = resolveLimit(limit)
// The bound has to come after the filter; limiting the DataFrame first would show the filter only
// the first `limit` rows of the ordering.
val results = if (limit.isDefined && !hasFilter(filter)) ordered.limit(limitValue).collect() else ordered.collect()

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: this collect-all-if-filtered else limit-then-collect branch plus its comment is duplicated verbatim in ShowBootstrapMappingProcedure — worth a small helper in BaseProcedure (e.g. collectForFilter(df, filter, limit)) next to applyFilterAndLimit so the reasoning lives in one place?

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

This branch has not been deployed

No deployments
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.

Spark show_* procedures apply limit before filter, so filter => ... only sees the first limit rows

4 participants