Repository navigation
[branch-54] fix: allow NULL-padded rows in sort-merge join filter evaluation (#26027) - #27356
Merged
Merged
Conversation
…che#26027) - Closes apache#26001. With sort-merge join (`datafusion.optimizer.prefer_hash_join = false`), a `LEFT`, `RIGHT` or `FULL` join with an extra join filter fails with `Arrow error: Invalid argument error: Column '...' is declared as non-nullable but contains null values` when the NULL-padded side has `NOT NULL` columns referenced by the filter. Hash join returns the correct result for the same queries. **Root cause.** As @jayzhan211 pointed out in the issue, when a streamed row has no match but the buffered side still holds a batch, `null_join_streamed_row` appends it with `Some(scanning_batch_idx)`, so it is materialized in the same chunk as matched rows. Its NULL-padded buffered-side columns then reach join filter evaluation, but the filter's intermediate schema declares them non-nullable, so `RecordBatch::try_new` fails. **Fix.** Following the fix suggested in the issue, `MaterializingSortMergeJoinStream::try_new` builds the filter schema once with buffered-side fields marked nullable (`JoinSide::Right` for `Left`/`Full`, `JoinSide::Left` for `Right`), and filter evaluation uses it. It is only built when `deferred_filtering` is set (outer join with a filter); inner joins keep the original schema. **Why this is safe.** The filter result for a NULL-padded row does not affect the output. Such a row is the only entry for its streamed row, so `get_corrected_filter_mask` keeps it as-is if the filter returns true and null-joins it if false; either way the same NULL-padded row is emitted. The `Full` join filter-status tracking already skips NULL buffered indices, so this path already expects such rows. **Alternative considered.** Keep NULL-padded rows out of the filter by appending them with `buffered_batch_idx = None`. Done naively, this reorders output: `freeze_streamed` pushes `None` chunks immediately but pushes matched chunks together at the end, so sort order would be silently broken. Flushing matched chunks before each `None` chunk would preserve order, but splits the single batched filter evaluation in `freeze_streamed_matched`, which matters most when keys are near-unique. Making the schema nullable avoids both. Added sqllogictest cases to `sort_merge_join.slt` with `NOT NULL` columns and `target_partitions = 2` (needed to reproduce; the queries pass with 1 partition): - `LEFT`, `FULL` and `RIGHT` joins from the issue, with expected results matching hash join. - A `LEFT` join whose filter divides by a buffered-side column (`a.v / b.w < 1`), to check that the zero-valued slots under NULL-padded rows do not cause a divide-by-zero error. All four queries fail on `main` with the Arrow error above and pass with this change. The full extended test suite also passes locally. No. Bug fix only; no public API change. (cherry picked from commit 8248a57) Backport notes (branch-54): the sort-merge join on this branch predates the generator refactor, so the cherry-pick conflicted in `materializing_stream.rs`. Resolved by hand: there is no cached `deferred_filtering` field here, so the filter schema is built from `needs_deferred_filtering(&filter, join_type)`; the filter evaluation change and the sqllogictest cases are as upstream. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
9 of 23 tasks
9 of 24 tasks
alamb
approved these changes
Nov 12, 2026
alamb
left a comment
Contributor
There was a problem hiding this comment.
Clean cherry-pick, looks good to me. Thank you @Sunninsky
alamb
marked this pull request as ready for review
November 12, 2026 23:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
54.2.0(minor/patch) Release #23805 (the 54.2.0 patch release tracking issue).branch-54. fix: allow NULL-padded rows in sort-merge join filter evaluation #26027 fixed Sort-merge join outer join with a filter fails on NOT NULL columns: "declared as non-nullable but contains null values" #26001.Rationale for this change
With a sort-merge join (
datafusion.optimizer.prefer_hash_join = false), aLEFT,RIGHTorFULLjoin with an extra join filter fails withArrow error: Invalid argument error: Column '...' is declared as non-nullable but contains null valueswhen the NULL-padded side hasNOT NULLcolumns referenced by the filter. Hash join returns the correct result for the same queries. This is a failed query, which makes it eligible for backport as a correctness fix. The four new sqllogictest queries fail onbranch-54exactly as onmain.#26027 fixed it on
main(labelledv56.0.0); it is onbranch-56but not onbranch-54. It was requested forbranch-54on #26868 (#26868 (comment)), "to prevent regressions on upgrade to 54".What changes are included in this PR?
git cherry-pick -xof 8248a57 (fix: allow NULL-padded rows in sort-merge join filter evaluation #26027, by @CuteChuanChuan), with the conflicts inmaterializing_stream.rsresolved by hand. The sort-merge join on this branch predates the generator refactor (chore: refactor SortMergeJoin bitwise stream to generators and simplify to be textbook like as possible #23761, chore: refactorMaterializingSortMergeJoinStreaminto generators and simplify code to be textbook like as possible #23976), so the surrounding code differs.datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs:MaterializingSortMergeJoinStream::try_newbuilds the filter schema once with the buffered-side fields marked nullable, and filter evaluation uses it. It is only built when the join needs deferred filtering. This branch has no cacheddeferred_filteringfield and callsneeds_deferred_filtering(&self.filter, self.join_type)where it is needed, so the filter schema is built from that call; there is no other change in the constructor. The filter result of a NULL-padded row does not affect the output, so this does not change results.datafusion/sqllogictest/test_files/sort_merge_join.slt: the cases from fix: allow NULL-padded rows in sort-merge join filter evaluation #26027, unchanged (they applied cleanly).Are these changes tested?
Yes. On this branch (Rust 1.95.0) I ran:
cargo test -p datafusion-sqllogictest --test sqllogictests -- join: all 14 matching files pass, includingsort_merge_join.sltwith the new cases.materializing_stream.rsreverted and the new slt cases kept, the four new queries fail with the Arrow error above (sort_merge_join.sltlines 990, 997, 1004 and 1013).cargo test -p datafusion-physical-plan --lib: 1439 passed.cargo fmt --all -- --checkandcargo clippy -p datafusion-physical-plan --all-targets --all-features -- -D warningspass.Are there any user-facing changes?
No. Bug fix only; no public API change.
🤖 Generated with Claude Code