[SPARK-58093][SQL] Push down LIMIT through UNION ALL to Data Source V2 scan#57200
[SPARK-58093][SQL] Push down LIMIT through UNION ALL to Data Source V2 scan#57200j1wonpark wants to merge 2 commits into
Conversation
…2 scan `V2ScanRelationPushDown.pushDownLimit` only handled a scan directly under the limit (optionally through `Project`/`Sort`), so a `LIMIT` on top of a `UNION ALL` fell through to `case other` and was never delivered to the underlying Data Source V2 scans via `SupportsPushDownLimit`. `LimitPushDown` already pushes a `LocalLimit` into each union branch, so the operator-level limit reaches the branches while the source-level limit hint does not. Add `Union` and `LocalLimit` cases to `pushDownLimit` that recurse into each branch and push the limit as a partial limit, always keeping the outer limit operators. UNION ALL never de-duplicates rows, so the union of branches each capped at `limit` still contains the first `limit` rows of the whole union. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
d2b65ba to
c9e8a97
Compare
yadavay-amzn
left a comment
There was a problem hiding this comment.
Looks correct, a couple of non-blocking notes
I traced the per-branch limit values, both OFFSET plan shapes, the ORDER-BY-above-union case, and UNION DISTINCT against master, and I think the correctness story holds. The key guard, where the Union case always reports the push as not removable so the outer limit stays above the union, is right.
Left two non-blocking comments inline, neither is a correctness issue. Thanks for the clear write-up in the description, the plan-shape explanation made this much easier to follow.
What I verified:
- Each branch scan receives the full union-level limit, and branches with a smaller or larger explicit
LIMITstill don't under-produce, since the intermediate limits are preserved andGlobalLimit-wrapped branches aren't recursed into. LIMIT 4 OFFSET 2lands on theOffsetAndLimitpattern and pushes n+m=6, while.limit(4).offset(2)lands onLimitAndOffsetand pushes 4. Both match the tests.ORDER BYabove the union isn't pushed, since theSort's child is theUnionrather than a scan.UNION DISTINCTisDistinct(Union(...)), which stops the recursion, so nothing is pushed.
| // `limit` rows, so this is only a partial push. | ||
| val newChildren = u.children.map(child => pushDownLimit(child, limit)._1) | ||
| (u.withNewChildren(newChildren), false) | ||
| case l @ LocalLimit(IntegerLiteral(_), _) => |
There was a problem hiding this comment.
Non-blocking, and I don't think it's a correctness problem: this arm isn't scoped to the Union case, so it fires for any LocalLimit the recursion reaches. I tried to build a wrong-results case out of it and couldn't, since the LocalLimit node is preserved by withNewChildren and the value that gets pushed is always the tighter outer cap, so functionally it looks fine to me.
The one thing I'd mention is the comment says the per-branch limit has "the same value as the limit above the union," which I don't think is guaranteed if a branch carries its own smaller LIMIT. Might be worth either doing the look-through inside the Union case so the intent stays local, or softening the comment to say the pushed value is the outer cap and the branch's own LocalLimit still caps the result regardless. Happy to be wrong here if you've already convinced yourself the arm can only be reached under a union.
There was a problem hiding this comment.
Good catch, thanks. You're right that the arm wasn't scoped to the union and the comment overstated the invariant. I moved the look-through inside the Union case so it only applies to union branches, and it now pushes min(outer limit, branch limit) so the pushed value no longer relies on the two limits being equal. Also added a test where a branch carries its own tighter LIMIT.
| |""".stripMargin) | ||
| checkPushedLimits(df, None, None) | ||
| checkAnswer(df, Seq(Row("alex"), Row("alex"))) | ||
| } |
There was a problem hiding this comment.
Non-blocking suggestion: it might be worth a UNION DISTINCT case here asserting the limit isn't pushed (checkPushedLimits(df, None, None)). The behavior already looks correct to me since UNION DISTINCT shows up as Distinct(Union(...)) and the recursion stops at the Distinct, but there's nothing pinning that today, so a future change to the matched cases could regress it quietly. A mixed case (one branch on a source that supports limit pushdown, one that doesn't) would be a nice one to have too.
There was a problem hiding this comment.
Added both: a UNION DISTINCT test asserting nothing is pushed, and a mixed case using a second H2 catalog with pushDownLimit=false so only one branch pushes the limit. One note on the UNION DISTINCT test: with two identical branches RemoveNoopUnion collapses the union entirely, so the test uses two different filters to keep the Distinct(Union(...)) shape.
…ter cap Address review comments: the LocalLimit case was a top-level match arm, so it fired for any LocalLimit the recursion reached, and its comment assumed the per-branch limit always equals the limit above the union. Handle the look-through inside the Union case instead and push min(outer limit, branch limit). Also add tests pinning that UNION DISTINCT blocks the pushdown, that a branch's own tighter LIMIT caps the pushed value, and that mixed pushdown support across branches works. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
What changes were proposed in this pull request?
V2ScanRelationPushDown.pushDownLimithad no case forUnion, so aLIMITon top of aUNION ALLwas never pushed to the underlying Data Source V2 scans throughSupportsPushDownLimit; it fell through tocase other => (other, false).Note the plan shape:
LimitPushDownalready pushes aLocalLimitinto eachUnionbranch (operator level), but the source-level limit hint (pushDownLimit) stops at theUnion.This PR adds two cases to
pushDownLimit:case u: Union— recurse into each branch, pushing the limit as a partial limit so the limit operators above the union are kept.case l @ LocalLimit(IntegerLiteral(_), _)— look through the per-branchLocalLimitthatLimitPushDowninserts, so the limit reaches the scan.Results are unchanged.
UNION ALLdoes not de-duplicate rows, so the union of branches that each keep at mostlimitrows still contains the firstlimitrows of the whole union — the same reason the existingUnioncase inLimitPushDownis safe. The union may return more thanlimitrows, so the push is only partial and the limit operators stay in the plan.Two things worth calling out for review:
Sort(theSortcase already returns a plan without it). This is fine becauseUniondoes not attach meaning to the order in which it emits its children, and the limit above the union still caps the result.LIMITover anORDER BYthat sits above the union is not pushed at all, which a test covers.Unioncase always reports the limit as not removable, plans that combineLIMITwithOFFSETcan callpushLimiton the same builder twice with the same value. This already happens today for partially pushed scans and is idempotent for the sources in the repository.Why are the changes needed?
SELECT ... FROM a UNION ALL SELECT ... FROM b LIMIT nnever delivers theLIMITto the scans, so each source does more work than necessary. Any Data Source V2 connector implementingSupportsPushDownLimitbenefits from receiving the limit per branch — e.g. for JDBC, each branch's pushed-down SQL then carriesLIMIT n.Does this PR introduce any user-facing change?
No. Query results are identical. The only observable difference is that the pushed limit now appears on each
UNION ALLbranch's scan (e.g.PushedLimit: LIMIT nin the plan, or aLIMITclause in the JDBC SQL), reducing work at the source.How was this patch tested?
Added five tests to
JDBCV2Suite, each asserting the limit pushed to every branch scan:checkAnswer;Sortis dropped;OFFSET, covering bothLIMIT n OFFSET m(branches keepn + mrows) andlimit(n).offset(m)(branches keepnrows);LIMITover anORDER BYon top of the union is not pushed to the scans.Ran the full
JDBCV2Suite(86 tests) — no regression.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus)