fix(proto): preserve HashJoinExec fetch across serialization - #24165
Conversation
`protobuf::HashJoinExecNode` had no `fetch` field, so `HashJoinExec`'s
`try_to_proto` never wrote it and `try_from_proto` never restored it: a
plan with `fetch = Some(n)` round-tripped to `fetch = None`.
This is user-visible because the `limit_pushdown` physical optimizer rule
pushes a limit into the join via `ExecutionPlan::with_fetch` and then
drops the enclosing `GlobalLimitExec`. After a proto round-trip the plan
therefore carried no limit at all, and a distributed executor returned
more rows than the query asked for.
Add `optional uint64 fetch = 12` and wire it through both hooks. The
field is presence-tracked on purpose: messages written before it existed
carry no `fetch`, and a plain proto3 scalar would decode that absence as
`0` -- "fetch 0 rows" -- silently producing empty results. `optional`
gives `None` for absent, which is the correct reading of an older
message.
The existing `roundtrip_test` helper cannot catch this class of bug: it
compares `format!("{plan:?}")`, and `HashJoinExec`'s `Debug` output does
not include `fetch`. The new regression test asserts on `fetch()`
directly and covers both `Some(7)` and `None`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
There was a problem hiding this comment.
Should we use a builder the whole way through instead of a try_new and then a builder later?
| hash_join = hash_join | ||
| .builder() |
There was a problem hiding this comment.
Let's avoid going from instance -> builder and back.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #24165 +/- ##
========================================
Coverage 81.05% 81.05%
========================================
Files 1107 1107
Lines 381407 381574 +167
Branches 381407 381574 +167
========================================
+ Hits 309139 309275 +136
- Misses 54013 54036 +23
- Partials 18255 18263 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…roto Address review feedback: the decoder called `HashJoinExec::try_new` and then round-tripped through `.builder().with_fetch(..).build()` just to apply `fetch`. `try_new` is itself a thin wrapper over `HashJoinExecBuilder`, so construct through the builder directly and set `fetch` alongside the other options. No behavior change: `try_new` delegates to the same `HashJoinExecBuilder::new(..).with_filter(..).with_projection(..) .with_partition_mode(..).with_null_equality(..).with_null_aware(..) .build()` chain, so validation, `column_indices`, `join_schema` and the computed `PlanProperties` are identical. `with_dynamic_filter_expr` is a method on `HashJoinExec` (not the builder), so it remains a post-build step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@kosiew @kumarUjjawal would one of you be able to review this change please? |
| // the join. The field is presence-tracked, so a message written | ||
| // before it existed decodes to `None` (no limit) rather than to | ||
| // `Some(0)`. | ||
| .with_fetch(hashjoin.fetch.map(|f| f as usize)) |
There was a problem hiding this comment.
u64 as usize will silently truncates on 32-bit targets. A fetch of 1 << 32 will becomes 0.
|
Thanks @adriangb Left few comments |
Review feedback: `u64 as usize` silently truncates on a 32-bit target, where `usize` is 32 bits. A fetch of `1 << 32` decodes to `0` -- not merely a wrong limit but the worst possible one, since "fetch 0 rows" turns the query into an empty result instead of erroring. Use `usize::try_from` and surface an out-of-range value as an error. Truncating and saturating both misrepresent the plan; an explicit decode failure is the honest outcome, and it is only reachable on a 32-bit target with an absurd fetch. `plan_datafusion_err!` rather than `internal_datafusion_err!`: this decode path reserves the internal-error macros for genuinely malformed nodes (an unknown `PartitionMode` discriminant, a dynamic filter that does not downcast), which really do indicate a bug. A well-formed but unrepresentable `fetch` is not a DataFusion bug -- it is a plan that cannot be expressed on this target -- and `plan_err!` is already this file's idiom for invalid plan configuration. The encode side (`self.fetch.map(|f| f as u64)`) needs no change: `usize` is at most 64 bits on every supported target, so widening to `u64` is lossless. `roundtrip_hash_join_fetch` now also covers `u32::MAX as usize` and `usize::MAX`, pinning that a large fetch round-trips exactly. Both are representable on every target, so the test stays portable. The truncation path itself is only reachable on a 32-bit target and is therefore not covered on a 64-bit CI host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch is stacked on the `fetch` fix and, after the rebase onto main, carries its own copy of the `HashJoinExec` decode path. Apply the same correction here so the defect does not survive on this branch and reappear once apache#24165 merges. Review feedback on apache#24165: `u64 as usize` silently truncates on a 32-bit target, where `usize` is 32 bits. A fetch of `1 << 32` decodes to `0` -- not merely a wrong limit but the worst possible one, since "fetch 0 rows" turns the query into an empty result instead of erroring. Use `usize::try_from` and surface an out-of-range value as an error. Truncating and saturating both misrepresent the plan; an explicit decode failure is the honest outcome, and it is only reachable on a 32-bit target with an absurd fetch. `plan_datafusion_err!` rather than `internal_datafusion_err!`: this decode path reserves the internal-error macros for genuinely malformed nodes (an unknown `PartitionMode` discriminant, a dynamic filter that does not downcast), which really do indicate a bug. A well-formed but unrepresentable `fetch` is not a DataFusion bug -- it is a plan that cannot be expressed on this target -- and `plan_err!` is already this file's idiom for invalid plan configuration. The encode side (`fetch.map(|f| f as u64)`) needs no change: `usize` is at most 64 bits on every supported target, so widening to `u64` is lossless. `roundtrip_hash_join_fetch` now also covers `u32::MAX as usize` and `usize::MAX`, pinning that a large fetch round-trips exactly. Both are representable on every target, so the test stays portable. The truncation path itself is only reachable on a 32-bit target and is therefore not covered on a 64-bit CI host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks @kumarUjjawal, replied! |
kumarUjjawal
left a comment
There was a problem hiding this comment.
Thanks for the response. Looks good 👍
Which issue does this PR close?
fields). This is a bug fix, not part of the migration checklist.
Rationale for this change
HashJoinExec.fetchwas silently dropped by protobuf serialization.protobuf::HashJoinExecNodehad nofetchfield, soHashJoinExec'stry_to_protonever wrote it andtry_from_protonever restored it: a planwith
fetch = Some(n)round-tripped tofetch = None.This is user-visible. The
limit_pushdownphysical optimizer rule pushes alimit into the join via
ExecutionPlan::with_fetch, then marks the globalstate satisfied and drops the enclosing
GlobalLimitExec. So after a protoround-trip the plan carried no limit at all, and a distributed executor
(Ballista/Comet-style, anything that ships physical plans over the wire)
returned more rows than the query asked for.
What changes are included in this PR?
datafusion.proto: addoptional uint64 fetch = 12toHashJoinExecNode.The field is presence-tracked on purpose, and this is the load-bearing
detail for wire compatibility. Messages written by versions predating this
field carry no
fetchat all, and a plain proto3 scalar decodes that absenceas
0. With the negative-sentinel convention used bySortExecNode'sint64 fetch,0would mean "fetch 0 rows" and would silently turn everyolder plan into an empty result.
optionalgives prost anOption<u64>where absent decodes to
None, which is the correct reading of an oldermessage. A comment in the
.protorecords this.Regenerated
prost.rs/pbjson.rsviadatafusion/proto-models/regen.sh(no hand edits).
hash_join/exec.rs: writeself.fetchin thetry_to_protohook andrestore it in
try_from_protovia the builder'swith_fetch, matching howthe plan is normally constructed.
New regression test
roundtrip_hash_join_fetch.The deprecated
PhysicalPlanNodeExtshims (try_from_hash_join_exec/try_into_hash_join_physical_plan) delegate straight to these two hooks, sothey pick the fix up with no separate change. Verified by reading them rather
than assumed.
Are these changes tested?
Yes.
roundtrip_hash_join_fetchindatafusion/proto/tests/cases/roundtrip_physical_plan.rsbuilds aHashJoinExec, applieswith_fetch(Some(7))the waylimit_pushdowndoes,round-trips it through
physical_plan_to_bytes_with_proto_converter/physical_plan_from_bytes_with_proto_converter, and assertsfetch()isstill
Some(7). It also coversfetch = None.The assertion deliberately inspects
fetch()rather than the plan's stringform. The existing
roundtrip_testhelper comparesformat!("{plan:?}"), andHashJoinExec'sDebugoutput does not includefetch— which is exactly whythis went unnoticed. I confirmed this empirically: with the encode side
reverted, the Debug comparison inside the helper still passes and only the
fetch()assertion fails (left: None, right: Some(7)).Ran locally:
cargo fmt --allcargo test -p datafusion-proto --test proto_integration— 215 passed, 0 failedcargo test -p datafusion-physical-plan— 1640 + 9 passed, 0 failedcargo clippy --all-targets --all-featureson the touched packages. Thechanged code is clean; the only two errors reported are pre-existing on an
unmodified
mainwith my newer local clippy (uninlined_format_argsindatafusion/proto-common/src/generated/pbjson.rsandneedless_pass_by_valueindatafusion/proto/src/bytes/mod.rs), in filesthis PR does not touch.
Are there any user-facing changes?
Yes, a bug fix: a limit pushed into a hash join now survives physical-plan
serialization, so distributed executors no longer over-return rows. No API
changes. The new proto field is backward and forward compatible in both
directions — old readers ignore tag 12, and new readers treat its absence as
"no limit".