Skip to content

fix: re-enable null-equal join dynamic filters with an IS NULL predicate - #23106

Open
mdashti wants to merge 3 commits into
apache:mainfrom
paradedb:moe/null-equal-dynamic-filter
Open

fix: re-enable null-equal join dynamic filters with an IS NULL predicate#23106
mdashti wants to merge 3 commits into
apache:mainfrom
paradedb:moe/null-equal-dynamic-filter

Conversation

@mdashti

@mdashti mdashti commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Which issue does this close?

Re-enables the dynamic filter that #22965 disabled (#22964), with the proper null-equal semantics.

Rationale for this change

#22965 disabled hash-join dynamic filter pushdown for null-equal joins: the build-side bounds and membership predicates evaluate to NULL for a probe-side NULL key, so they prune rows that should null-match a build-side NULL. Its description already named the better fix, "generate a predicate with OR IS NULL". #23104 does that for null-aware anti joins; this re-enables the null-equal case the same way.

What changes are included in this PR?

  • Revert the null-equal return false in allow_join_dynamic_filter_pushdown.
  • Generalize the shared probe-NULL helper to cover both null-aware (single-key) and null-equal (multi-key) joins: OR key IS NULL for every nullable probe key. A NOT NULL key never widens the filter, so an all-NOT-NULL join keeps full selectivity.

Are these changes tested?

Yes. #22965's SLT now asserts the filter is back on the probe with the result unchanged, plus a multi-key null-equal case. The reject unit test flips to assert pushdown is allowed, and preserve_probe_nulls unit tests cover both the mixed nullable/NOT NULL case (only the nullable key widens) and the all-NOT-NULL case (no widening).

Are there any user-facing changes?

Null-equal joins regain dynamic filter pushdown, so they prune the probe scan again while returning correct results.

@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Jun 23, 2026
@mdashti
mdashti force-pushed the moe/null-equal-dynamic-filter branch 2 times, most recently from 64e1820 to 3721aa9 Compare June 23, 2026 04:04
@mdashti

mdashti commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@adriangb Can you please take a look?

@mdashti mdashti changed the title Re-enabled null-equal join dynamic filters with an IS NULL predicate. fix: re-enabled null-equal join dynamic filters with an IS NULL predicate. Jun 23, 2026
@mdashti
mdashti force-pushed the moe/null-equal-dynamic-filter branch from 3721aa9 to 9f4e40c Compare June 23, 2026 04:23
let any_key_is_null = self
.on_right
.iter()
.filter(|key| key.nullable(&self.probe_schema).unwrap_or(true))

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 we widen when we are unable to check column nullability ? i.e. unwrap_or(true).
From what I see this can only happen when on_right and schema are out of sync which seems to be an invalid state ?

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.

This is a should-never-happen (as you said: keys out of sync with the probe schema), so I kept unwrap_or(true) as the safe degradation: over-widening only loses a little selectivity, while false could drop a NULL the join needs. Documented it in 9620b97.

@RatulDawar RatulDawar Jun 25, 2026

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.

I was thinking how an invalid state if achieved somehow should be handled, instead of silently handling it shouldn't we propagate the error further.
The fail safe check was added here #3238
Though I am not sure what's the consensus for things like these, so a commiter's input would be helpful here.

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.

That's a fair point. The call sites already return Result, so propagation costs nothing. Done in d090cf5, with a test to check the error path.

@mdashti mdashti changed the title fix: re-enabled null-equal join dynamic filters with an IS NULL predicate. fix: re-enable null-equal join dynamic filters with an IS NULL predicate Jun 24, 2026
@mdashti
mdashti force-pushed the moe/null-equal-dynamic-filter branch from 9f4e40c to 9620b97 Compare June 24, 2026 20:45
@mdashti
mdashti requested a review from RatulDawar June 24, 2026 21:45
@mdashti
mdashti force-pushed the moe/null-equal-dynamic-filter branch from 9620b97 to 8ecd68f Compare July 28, 2026 23:44
@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.43590% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.88%. Comparing base (f9dde71) to head (d090cf5).

Files with missing lines Patch % Lines
...physical-plan/src/joins/hash_join/shared_bounds.rs 97.33% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #23106   +/-   ##
=======================================
  Coverage   80.88%   80.88%           
=======================================
  Files        1102     1102           
  Lines      375813   375870   +57     
  Branches   375813   375870   +57     
=======================================
+ Hits       303966   304021   +55     
+ Misses      53745    53741    -4     
- Partials    18102    18108    +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 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.

mdashti added 3 commits August 3, 2026 13:57
build-side predicate prunes a probe-side NULL that can null-match a build-side
NULL. Push the filter with `OR key IS NULL` over the nullable probe keys
instead, the way apache#23104 does for null-aware anti joins. A NOT NULL key never
widens the filter, so an all-NOT-NULL join keeps full selectivity.
The `unwrap_or(true)` widening on an unresolved nullability check wasn't
obvious. An extra NULL row is safe; dropping a needed one isn't.
A `nullable` failure means a key out of sync with the probe schema,
a construction bug that should surface. The call sites already return
`Result`, so nothing new can fail.
@mdashti
mdashti force-pushed the moe/null-equal-dynamic-filter branch from 164ee1b to d090cf5 Compare August 3, 2026 21:01

@mdashti mdashti left a comment

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.

@RatulDawar Thanks for the review.

@adriangb Can you please take a look?

let any_key_is_null = self
.on_right
.iter()
.filter(|key| key.nullable(&self.probe_schema).unwrap_or(true))

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.

That's a fair point. The call sites already return Result, so propagation costs nothing. Done in d090cf5, with a test to check the error path.

Comment on lines +1083 to +1084
query IIII rowsort
SELECT mnej_build.a, mnej_build.b, mnej_probe.a, mnej_probe.b FROM mnej_build JOIN mnej_probe ON (mnej_build.a IS NOT DISTINCT FROM mnej_probe.a) AND (mnej_build.b IS NOT DISTINCT FROM mnej_probe.b)

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.

Can we add an EXPLAIN or EXPLAIN ANALYZE for this query to show the filter is being applied?

# No DynamicFilter predicate may appear on the probe side of a null-equal join
# The probe side now carries a DynamicFilter for a null-equal join (widened with IS NULL at runtime)
query TT
EXPLAIN SELECT nej_build.id, nej_probe.id FROM nej_build JOIN nej_probe ON nej_build.id IS NOT DISTINCT FROM nej_probe.id

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.

Can we make sure we have some tests that run in partitioned mode? These are all CollectLeft. Making multiple files might work, or you can use set target_partitions

// Null-aware joins are single-key; null-equal joins can be multi-key, so OR every nullable
// key. If every key is NOT NULL the filter is left untouched, at full selectivity.
let mut any_key_is_null: Option<Arc<dyn PhysicalExpr>> = None;
for key in &self.on_right {

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 track dynamically if any build side rows are actually null and leave the filter unchanged if none of them are null?

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

Labels

physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants