Skip to content

[SPARK-58588][SQL] Return the broadcast hash join build side from JoinSelectionHelper instead of a Boolean - #57790

Open
LuciferYang wants to merge 2 commits into
apache:masterfrom
LuciferYang:SPARK-planned-broadcast-buildside
Open

[SPARK-58588][SQL] Return the broadcast hash join build side from JoinSelectionHelper instead of a Boolean#57790
LuciferYang wants to merge 2 commits into
apache:masterfrom
LuciferYang:SPARK-planned-broadcast-buildside

Conversation

@LuciferYang

@LuciferYang LuciferYang commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

canPlanAsBroadcastHashJoin already computed an Option[BuildSide] internally and then discarded the direction with .isDefined. A caller that needs the direction therefore had to reimplement the same composition: PushDownJoinThroughUnion (SPARK-58449) did exactly that, so one planner decision was modelled twice, with each model incomplete and the coupling invisible in the code.

This PR lifts the composition into JoinSelectionHelper.getBroadcastHashJoinBuildSide, which returns Option[BuildSide], and redefines canPlanAsBroadcastHashJoin as .isDefined on it. The truth table of canPlanAsBroadcastHashJoin is unchanged, including its two deliberate over-approximations of JoinSelection: join keys no hash join supports still reach the size branch, and the method does not model SHUFFLE_MERGE or SHUFFLE_REPLICATE_NL hints. Both are now stated in the scaladoc instead of being implicit.

PushDownJoinThroughUnion then drops its own canPlanAsBroadcastHashJoin conjunct, since None from the new method already carries the equi-key requirement, and shares a branchJoin helper between the rewrite and the guard's probe plan. The probe now rewrites the join condition to the branch output, which the previous guard did not need to do because it never extracted equi-join keys.

Making the helper fully faithful to JoinSelection would mean returning None under a SHUFFLE_MERGE or SHUFFLE_REPLICATE_NL hint. That flips canPlanAsBroadcastHashJoin from true to false for a hinted join whose size still qualifies, which changes what PushDownLeftSemiAntiJoin pushes down, so it is a behavior change rather than a refactor and is left for a separate ticket.

Why are the changes needed?

Two partial models of the same planner decision drift apart. Strengthening one of them, for example teaching canPlanAsBroadcastHashJoin about a hint it currently ignores, silently leaves the other stale, and nothing in the code says the two have to agree.

Does this PR introduce any user-facing change?

No. canPlanAsBroadcastHashJoin keeps its truth table, and spark.sql.optimizer.pushDownJoinThroughUnion.enabled remains false by default.

How was this patch tested?

JoinSuite.assertJoin now also asserts that getBroadcastHashJoinBuildSide agrees with the build side the planner picked, which covers every case in that suite that reaches a broadcast hash join. JoinSelectionHelperSuite gains five cases asserting the direction itself: a hint on either or both sides, the fall back to the smaller side, a shuffle hash hint, no equi-join keys, and a null-aware anti join.

The direction had no coverage before this PR, so both were checked by mutation: inverting getSmallerSide fails four cases, and returning BuildLeft for the null-aware anti join fails exactly the new case for it.

Existing suites pass: JoinSelectionHelperSuite (19), PushDownJoinThroughUnionSuite in catalyst (24) and core (9), and JoinSuite (58).

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code

…nSelectionHelper instead of a Boolean

### What changes were proposed in this pull request?

`canPlanAsBroadcastHashJoin` already computed an `Option[BuildSide]` internally and then discarded the
direction with `.isDefined`. A caller that needs the direction therefore had to reimplement the same
composition: `PushDownJoinThroughUnion` (SPARK-58449) did exactly that, so one planner decision was
modelled twice, with each model incomplete and the coupling invisible in the code.

This PR lifts the composition into `JoinSelectionHelper.getBroadcastHashJoinBuildSide`, which returns
`Option[BuildSide]`, and redefines `canPlanAsBroadcastHashJoin` as `.isDefined` on it. The truth table
of `canPlanAsBroadcastHashJoin` is unchanged, including its two deliberate over-approximations of
`JoinSelection`: join keys no hash join supports still reach the size branch, and the method does not
model `SHUFFLE_MERGE` or `SHUFFLE_REPLICATE_NL` hints. Both are now stated in the scaladoc instead of
being implicit.

`PushDownJoinThroughUnion` then drops its own `canPlanAsBroadcastHashJoin` conjunct, since `None` from
the new method already covers the equi-key and hash-joinable-key requirements, and shares a
`branchJoin` helper between the rewrite and the guard's probe plan. The probe now rewrites the join
condition to the branch output, which the previous guard did not need to do because it never extracted
equi-join keys.

### Why are the changes needed?

Two partial models of the same planner decision drift apart. Strengthening one of them, for example
teaching `canPlanAsBroadcastHashJoin` about a hint it currently ignores, silently leaves the other
stale, and nothing in the code says the two have to agree.

### Does this PR introduce _any_ user-facing change?

No. `canPlanAsBroadcastHashJoin` keeps its truth table, and
`spark.sql.optimizer.pushDownJoinThroughUnion.enabled` remains false by default.

### How was this patch tested?

`JoinSuite.assertJoin` now also asserts that `getBroadcastHashJoinBuildSide` agrees with the build side
the planner picked, which covers every case in that suite that reaches a broadcast hash join.
`JoinSelectionHelperSuite` gains five cases asserting the direction itself: a hint on either or both
sides, the fall back to the smaller side, a shuffle hash hint, no equi-join keys, and a null-aware anti
join.

The direction had no coverage before this PR, so both were checked by mutation: inverting
`getSmallerSide` fails four cases, and returning `BuildLeft` for the null-aware anti join fails exactly
the new case for it.

Existing suites pass: `JoinSelectionHelperSuite` (19), `PushDownJoinThroughUnionSuite` in catalyst (24)
and core (9), and `JoinSuite` (58).

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code
@LuciferYang

Copy link
Copy Markdown
Contributor Author

cc @cloud-fan @dongjoon-hyun

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

+1, LGTM.

@cloud-fan cloud-fan 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.

0 blocking, 1 non-blocking, 0 nits.
The implementation is coherent, but one new contract claim overstates what the deliberately approximate helper guarantees.

Suggestions (1)

  • sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/PushDownJoinThroughUnion.scala:112: The new comment says None subsumes hash-key eligibility even though the helper deliberately preserves an unsupported-key over-approximation. -- see inline

Verification

I traced hint precedence and the size fallback from getBroadcastHashJoinBuildSide into SparkStrategies.JoinSelection, then checked the per-branch condition remapping and right-build gate in PushDownJoinThroughUnion. The paths agree for supported broadcast hash joins; for non-binary-stable equi keys, the helper intentionally returns a prospective size-selected side while the physical planner rejects broadcast hash join creation.

* right side as a plain probe input, which is not reused, so the right side is read once per such
* branch instead of once in total.
*
* `getBroadcastHashJoinBuildSide` returning `None` also covers the joins where no broadcast hash

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.

None does not cover the hash-joinable-key requirement. With non-binary-stable equi keys, the helper can still return a size-selected BuildRight, while JoinSelection rejects the broadcast hash join on hashJoinSupport. Since preserving that over-approximation is intentional here, please remove this claim so callers do not treat the result as an actual planner choice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

529d10a address this

…e guard's scaladoc

`getBroadcastHashJoinBuildSide` does not carry the hash-joinable-key requirement: with equi keys no
hash join supports, `noShufflePlannedBefore` becomes true and the size branch still answers
`Some(...)`, while `JoinSelection` falls through to a sort merge join. Only the equi-key requirement
of the removed `canPlanAsBroadcastHashJoin` conjunct is carried over.

Tighten the method's own summary for the same reason: it returns `None` when a broadcast hash join is
ruled out by the join shape or by a hint, which is narrower than "`JoinSelection` could not plan one".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants