Skip to content

fix: keep volatile semi joins in place during semi-join pushdown - #2793

Merged
andygrove merged 1 commit into
apache:mainfrom
andygrove:perf-semi-join-pushdown
Nov 10, 2026
Merged

andygrove merged 1 commit into
apache:mainfrom
andygrove:perf-semi-join-pushdown

Conversation

@andygrove

@andygrove andygrove commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

No single issue. A follow-up to #2799, which landed the SemiJoinPushdown rule (ballista.optimizer.semi_join_pushdown) originally proposed here. Related: #2792, #2520 (performance review tracking).

Rationale for this change

SemiJoinPushdown moves a semi or anti join below inner joins on the assumption that its result depends only on the left row. That does not hold when a join key or the join filter is volatile. For example, EXISTS (... WHERE s.x = a.v AND random() < 0.5) pushed onto a would draw one random number per a row instead of one per joined row, which changes which rows the query returns.

The rule's existing tests also only checked plan shapes, not that results stay the same with the rule on and off.

What changes are included in this PR?

  • is_pushable_semi_join leaves a semi or anti join in place when any of its on keys or its filter is volatile, with a unit test for the volatile filter case.
  • A new test, semi_join_pushdown_preserves_results, runs IN, EXISTS and NOT EXISTS queries over joins through every Ballista optimizer rule with semi_join_pushdown on and off and checks the sorted results match. The tables have NULL keys on both the outer and subquery sides plus duplicate join matches. Each case also asserts whether the rule is expected to move the semi join, covering the cases where it must stay, such as an anti join that reads the null-supplying side or a correlated filter that reads both join sides.

Are there any user-facing changes?

No new config. Queries whose semi or anti join has a volatile key or filter are no longer rewritten by SemiJoinPushdown.

// An uncorrelated EXISTS keeps all or none of the rows; leave it.
return Ok(None);
}
match semi.left.as_ref() {

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.

should semi.filter expression be checked for volatile expressions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, good call. It now leaves the semi join in place when any key or the filter is volatile. Otherwise something like EXISTS (... WHERE s.x = a.v AND random() < 0.5) would draw one number per a row instead of one per joined row. Added keeps_semi_join_with_a_volatile_filter.

Comment thread ballista/core/src/optimizer.rs Outdated
.position(|l| l.contains(needle))
.unwrap_or_else(|| panic!("{needle:?} not found in:\n{display}"))
};
assert!(

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.

could we also compare plans string as well, it helps with visibility later

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, the session test now compares the whole optimized plan.

}

#[cfg(test)]
mod tests {

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.

Consider a small SQL test that compares the result with the rule on and off, using IN, EXISTS and NOT EXISTS over a join, with NULL keys on the anti side. (not sure if we will catch such discrepancies in TPCH test)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added semi_join_pushdown_preserves_results. It runs IN, EXISTS and NOT EXISTS over inner and left joins, with NULL keys on both the outer and subquery sides, with the rule on and off. It compares the rows and checks where the semi join ended up. I tested it by letting the rule push into a left join's null-supplying side, and the test catches the extra null-extended row.

@milenkovicm milenkovicm 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.

thanks @andygrove few minor comments

@andygrove
andygrove added this pull request to the merge queue Nov 10, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 10, 2026
@github-actions github-actions Bot removed the documentation Improvements or additions to documentation label Nov 10, 2026
@andygrove
andygrove force-pushed the perf-semi-join-pushdown branch from 0612af5 to 1f2a6bf Compare November 10, 2026 15:07
@andygrove andygrove changed the title perf: push semi and anti joins below inner joins fix: keep volatile semi joins in place during semi-join pushdown Nov 10, 2026
@andygrove
andygrove enabled auto-merge November 10, 2026 15:09
@andygrove
andygrove added this pull request to the merge queue Nov 10, 2026
Merged via the queue into apache:main with commit 9ed1cae Nov 10, 2026
24 checks passed
@andygrove
andygrove deleted the perf-semi-join-pushdown branch November 10, 2026 16:17
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.

2 participants