Skip to content

fix(spark): report read.paths, a missing path and a glob path separately - #19569

Open
deepakpanda93 wants to merge 2 commits into
apache:masterfrom
deepakpanda93:fix/read-paths-error-message
Open

fix(spark): report read.paths, a missing path and a glob path separately#19569
deepakpanda93 wants to merge 2 commits into
apache:masterfrom
deepakpanda93:fix/read-paths-error-message

Conversation

@deepakpanda93

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

Closes #15174.

#14060 removed glob path support and deprecated hoodie.datasource.read.paths in 1.2.0, replacing
the globbing logic with an unconditional throw. The three rejections in DefaultSource's 3-arg
createRelation ended up sharing one condition and one message, so the message rarely describes
what the caller actually did.

A non-glob value is reported as an unsupported glob. The guard is:

if (path.exists(_.contains("*")) || readPaths.nonEmpty) {
  throw new HoodieException("Glob paths are not supported for read paths as of Hudi 1.2.0")
}

readPaths.nonEmpty has nothing to do with wildcards, so a caller passing plain partition paths:

spark.read.format("hudi")
  .option("hoodie.datasource.read.paths", "prefix/part1,prefix/part2")
  .load(basePath)

is told their non-glob paths are unsupported glob paths. There is no wildcard anywhere in that call
and nothing in the message to act on.

The missing-path message advertises an option that throws. The guard above it says:

'path' or 'hoodie.datasource.read.paths' or both must be specified.

A caller who follows that and sets hoodie.datasource.read.paths reaches the throw quoted above.

The config documentation still describes the option as usable. READ_PATHS carries
@Deprecated but no deprecatedAfter(...), and reads "Comma separated list of file paths to read
within a Hudi table", with nothing to say that setting it now fails the read.

Summary and Changelog

The rejections are unchanged. What changes is that each one now says what happened and what to do
instead.

  • Split the conflated guard into three independent checks: read.paths is set, no path was given,
    path contains a glob. Each throws its own message.
  • Every message names the supported replacement, so no rejection says only what does not work: load
    the table base path and filter on the partition columns. Those predicates are pushed down and
    prune partitions before any file is listed, via HoodieFileIndex#prunePartitionsAndGetFileSlices,
    which is what selecting paths by hand was for.
  • The missing-path message no longer offers read.paths as the alternative, since setting it throws.
  • All three checks run before HoodieStorageUtils.getStorage(...), so a call that cannot succeed no
    longer opens a storage handle first. The now-dead readPaths and allPaths locals are gone, and
    allPaths.head becomes path.get, which is safe because path.isEmpty is rejected above it.
  • READ_PATHS gains deprecatedAfter("1.2.0"), and its documentation states that the option now
    fails the read and what to use instead.

Two cases that needed a deliberate choice rather than falling out of the code:

When both read.paths is set and path contains a glob, read.paths is reported. It is the
more fundamental of the two: the option is gone outright, and the replacement it points at resolves
the glob case as well. Pinned by testReadPathsWinsOverGlobInPath.

An explicitly empty read.paths="" is still rejected. optParams.get yields Some(""), so the
key counts as set. 1.2.0 already rejected it, since Some("") made the old readPaths.nonEmpty
check true, and treating it as unset here would turn a throw into a successful read. Pinned by
testEmptyReadPathsIsStillRejected.

Impact

None on behaviour. read.paths and glob paths are rejected before this change and after it, an
empty read.paths is rejected in both, and every read that worked before still works. Only the
wording of the three exceptions and the READ_PATHS documentation change.

Users who hit these errors now get a message that matches their input and names the replacement.

Risk Level

low

Confined to three error paths in one method plus a config doc string. No change to relation
construction, planning or reads. Covered by a new suite of 13 tests, and reverting the change fails
exactly the 7 of them that assert the messages while the 6 no-regression tests keep passing.

Documentation Update

The hoodie.datasource.read.paths config description is updated in this PR to say the option now
fails the read and to point at loading the base path with partition predicates instead. It also
gains deprecatedAfter("1.2.0"), so it is rendered as deprecated since that release. No Hudi website
change is needed.

Contributor's checklist

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

Removing glob support in 1.2.0 left one condition covering three different
mistakes, so the message rarely matched what the caller had done.

- read.paths without any wildcard was answered with "Glob paths are not
  supported for read paths". A caller passing plain partition paths was told
  their non-glob paths were unsupported globs, which is neither true nor
  something they can act on. read.paths now reports itself.
- The missing-path check offered read.paths as the alternative, an option that
  throws a few lines further down. It now asks for 'path' alone.
- A glob in 'path' keeps its own message.

Each message names the supported replacement: load the table base path and
filter on the partition columns, which prunes partitions before files are
listed. The checks run before the storage handle is built, so a call that
cannot succeed no longer opens one first.

READ_PATHS gains deprecatedAfter("1.2.0"), and its documentation says the
option now fails the read rather than describing it as usable.

Behaviour is unchanged: read.paths and glob paths are still rejected, an empty
read.paths value is still rejected, and only the wording differs.
@github-actions github-actions Bot added the size:L PR with lines of changes in (300, 1000] label Aug 10, 2026

@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 splits a single conflated guard in DefaultSource's 3-arg createRelation into three independent rejections, each with its own accurate message, and marks READ_PATHS deprecated. I traced the logic and the success/failure condition is preserved exactly (original succeeded iff path set, non-glob, and read.paths unset — same as the new guards), so this reads as a behavior-preserving refactor with good test coverage. One interaction with internal callers is worth double-checking in the inline comment. 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 minor nit in the Javadoc; code is otherwise clean and readable.

// an option that no longer exists. 1.2.0 already rejected "" here, since Some("") made the
// old readPaths.nonEmpty check true, so this keeps the rejection and only reworks the wording.
throw new HoodieException(
s"'${READ_PATHS.key()}' is no longer supported as of Hudi 1.2.0. ${DefaultSource.LOAD_BASE_PATH_INSTEAD}")

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.

🤖 Heads up: Hudi's own IncrementalRelationV1/V2.buildScan (used by HoodieStreamSourceV1/V2) still sets READ_PATHS internally for meta-bootstrap files and re-enters here via format("hudi_v1").load() with no path, so a streaming read of a metadata-bootstrapped table hits this throw and now surfaces "'…read.paths' is no longer supported… load the base path and filter" — advice the user can't act on since Hudi set the option, not them. It's pre-existing (the old readPaths.nonEmpty glob check threw here too), so no regression, but since this PR cements read.paths as removed and the doc now says "setting it now fails the read", have you verified this streaming/bootstrap path is actually dead — or should those internal callers be migrated off READ_PATHS?

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified, and you are right about the streaming half — I had traced this to the incremental query
path only, and missed that streaming reaches the same relations. HoodieStreamSourceV1:186 and
HoodieStreamSourceV2:162 construct IncrementalRelationV1 / IncrementalRelationV2 directly, so a
streaming read reaches it too.

It is reachable, not dead. The bootstrap branch is entered when a commit in the scanned range is
the metadata bootstrap instant (IncrementalRelationV1:179-181):

if (HoodieTimeline.METADATA_BOOTSTRAP_INSTANT_TS == commit.requestedTime) {
  metaBootstrapFileIdToFullPath ++= metadata.getFileIdAndFullPaths(basePath)...
}
...
if (metaBootstrapFileIdToFullPath.nonEmpty) {
  df = sqlContext.sparkSession.read.format("hudi_v1").schema(prunedSchema)
    .option(DataSourceReadOptions.READ_PATHS.key, filteredMetaBootstrapFullPaths.mkString(","))
    ...
    .load()
}

METADATA_BOOTSTRAP_INSTANT_TS is "00000000000001", so this fires when an incremental or streaming
read spans the bootstrap instant of a metadata-bootstrapped table. Narrow, but a real scenario rather
than unreachable code. DefaultSource.shortName() is "hudi_v1" and .schema(...) routes through
SchemaRelationProvider, so it lands in the method this PR touches, with READ_PATHS set and no
path.

Not a regression, and not fixed here. It threw before this PR (readPaths.nonEmpty in the old
combined guard) and it throws after; only the wording moves from the glob message to the read.paths
message. BaseFileOnlyRelation:162 reads READ_PATHS too and has been equally unreachable since
1.2.0, for the same reason.

Your sharpest point is the one I had not put into words: on that path Hudi set the option, not the
user, so "load the base path and filter on the partition columns" is advice the caller cannot act on.
That was equally true of the old glob message, so no user is worse off, but it does mean this PR
documents the option as fatal while Hudi still sets it internally.

Migrating those callers is the right fix and it is more than a message change: they need rerouting to
the file group reader, which is the work tracked by #17327 / HUDI-8747 for removing read paths
properly. Keeping it out of this PR so that this one stays a behaviour-preserving message and docs
change, and filing it separately with the trace above.

private val log = LoggerFactory.getLogger(classOf[DefaultSource])

/**
* What to do instead, appended to every rejection in the 3-arg createRelation so the user is

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: "the 3-arg createRelation" is a bit opaque here — future readers have to know the overload structure to parse it. Something like "appended to every rejection in createRelation(sqlContext, parameters)" (or just "appended to every user-facing rejection") would be clearer without the internal counting.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fair, reworded — though not to the suggested text, because that names the wrong overload.

createRelation(sqlContext, parameters) is the 2-arg one. The constant is used in the 3-arg
createRelation(sqlContext, optParams, schema), so I have used that signature:

/**
 * What to do instead, appended to every rejection in
 * `createRelation(sqlContext, optParams, schema)` so the user is never told only what does not
 * work. ...
 */

Same intent as your suggestion, no overload counting, and it points at the method that actually
throws.

@codecov-commenter

codecov-commenter commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.93%. Comparing base (f3806cb) to head (9893a82).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff            @@
##             master   #19569   +/-   ##
=========================================
  Coverage     76.93%   76.93%           
+ Complexity    32423    32422    -1     
=========================================
  Files          2522     2522           
  Lines        139106   139111    +5     
  Branches      16713    16715    +2     
=========================================
+ Hits         107020   107025    +5     
- Misses        24510    24511    +1     
+ Partials       7576     7575    -1     
Components Coverage Δ
hudi-common 83.25% <ø> (-0.03%) ⬇️
hudi-client 81.98% <ø> (ø)
hudi-flink 85.35% <ø> (ø)
hudi-spark-datasource 70.63% <100.00%> (+0.02%) ⬆️
hudi-utilities 73.63% <ø> (-0.01%) ⬇️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.50% <ø> (ø)
hudi-sync 75.16% <ø> (+0.04%) ⬆️
hudi-io 79.46% <ø> (ø)
hudi-timeline-service 83.74% <ø> (+0.29%) ⬆️
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 50.38% <33.33%> (-0.01%) ⬇️
flink-integration-tests 49.08% <ø> (-0.01%) ⬇️
hadoop-mr-java-client 43.73% <ø> (-0.02%) ⬇️
integration-tests 13.57% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 49.63% <ø> (+<0.01%) ⬆️
spark-java-tests 51.58% <100.00%> (+<0.01%) ⬆️
spark-scala-tests 45.99% <38.46%> (+0.02%) ⬆️
utilities 36.58% <33.33%> (+<0.01%) ⬆️

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

Files with missing lines Coverage Δ
...main/scala/org/apache/hudi/DataSourceOptions.scala 95.21% <100.00%> (+<0.01%) ⬆️
...src/main/scala/org/apache/hudi/DefaultSource.scala 78.03% <100.00%> (+2.88%) ⬆️

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

"the 3-arg createRelation" asks the reader to know the overload structure.
Name the signature instead.
@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

@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 splits a conflated rejection guard in DefaultSource.createRelation into three independent checks so each failure names what actually happened, and updates the READ_PATHS config doc to match. I traced the refactor against the old behavior and it preserves the same rejections, including the empty-value case, while building storage only after the path check, and confirmed no code depends on the old message strings. The one substantive concern, internal READ_PATHS usage in IncrementalRelationV1/V2 for meta-bootstrap flowing into this throw, was already flagged in the prior round and is pre-existing, so this PR does not regress it. No issues flagged from this automated pass, a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

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

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spark.read.load API

4 participants