Skip to content

fix: refuse memory-limited NestedLoopJoin fallback for left-emission joins with a multi-partition probe side - #24675

Merged
kosiew merged 2 commits into
apache:mainfrom
ranflarion:nlj-gate-left-family
Aug 28, 2026
Merged

fix: refuse memory-limited NestedLoopJoin fallback for left-emission joins with a multi-partition probe side#24675
kosiew merged 2 commits into
apache:mainfrom
ranflarion:nlj-gate-left-family

Conversation

@ranflarion

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

The memory-limited fallback returns wrong rows for join types whose final emission reads the visited-left bitmap (LEFT, LEFT SEMI, LEFT ANTI, LEFT MARK) when the probe side has more than one partition. The fallback rebuilds a per-partition JoinLeftData per left chunk with probe_threads_counter == 1 and a per-partition bitmap, so every partition believes it is the last to finish probing and emits its own unmatched set, from a bitmap that only saw its own right rows. Unmatched rows are emitted once per partition, and rows matched only in another partition are emitted as unmatched.

Proof, using this file's own build_table / prepare_join_filter / multi_partitioned_join_collect fixtures (4 right partitions) with a 100-byte memory limit versus unbounded:

for join_type in [JoinType::Left, JoinType::LeftAnti] {
    // unbounded memory -> correct; 100-byte pool -> memory-limited fallback
    let (_, correct, _) = multi_partitioned_join_collect(left, right, &join_type, Some(filter), ample_ctx).await?;
    let (_, wrong, _)   = multi_partitioned_join_collect(left, right, &join_type, Some(filter), tight_ctx).await?;
}
// Left:     correct=19  memory_limited=49
// LeftAnti: correct=1   memory_limited=31

The existing gate covers only FULL. This extends it to every need_produce_result_in_final type, so an over-budget build side fails with ResourcesExhausted instead of returning wrong rows. Right-side emission types (RIGHT, RIGHT SEMI, RIGHT ANTI, RIGHT MARK) keep the fallback: each partition owns its right rows exclusively, so their bitmaps are complete per partition. Proper cross-partition coordination of the left bitmap, which would re-enable the fallback for these types, is #22038.

What changes are included in this PR?

The full_join_multi_partition condition in NestedLoopJoinExec::execute becomes need_produce_result_in_final(self.join_type) && right_partition_count > 1, with the comment updated to describe the failure mode. test_overallocation moves LEFT/LEFT SEMI/LEFT ANTI/LEFT MARK from the succeed-via-fallback group (which collected but never checked the row values) into the must-OOM group alongside FULL.

Are these changes tested?

test_overallocation now asserts the ResourcesExhausted refusal for all five gated join types with a multi-partition probe side, and still asserts fallback success for Inner and the right-emission types. All 42 nested_loop_join tests pass; ./dev/rust_lint.sh is clean.

Are there any user-facing changes?

Left-family nested loop joins with a multi-partition probe side whose build side exceeds the memory budget now fail with ResourcesExhausted instead of returning incorrect results. No API changes.

@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Aug 25, 2026
@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.70588% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.47%. Comparing base (3cabfa9) to head (ce9440f).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...fusion/physical-plan/src/joins/nested_loop_join.rs 89.70% 0 Missing and 7 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24675      +/-   ##
==========================================
+ Coverage   81.45%   81.47%   +0.01%     
==========================================
  Files        1122     1122              
  Lines      403051   403629     +578     
  Branches   403051   403629     +578     
==========================================
+ Hits       328317   328855     +538     
- Misses      55498    55517      +19     
- Partials    19236    19257      +21     

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

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

@ranflarion,

Thanks for working on this. The change looks good to me. Expanding the fallback gate to the join types that depend on the visited-left bitmap avoids returning incorrect results when there are multiple right partitions, while keeping the fallback available for the join types where it is safe.

I have one non-blocking suggestion for some additional test coverage.

@@ -3872,24 +3871,34 @@ pub(crate) mod tests {
.await?;
}

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 add a tight-memory, single-right-partition spill test for LeftSemi, LeftAnti, and LeftMark as well? The new gate specifically applies when there is more than one right partition, but the current single-partition success coverage only exercises Left and Full. Adding these cases would help make sure the supported single-partition fallback continues to work while #22038 remains open.

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.

sure, added in ab71c9c: test_nlj_memory_limited_left_semi_join, test_nlj_memory_limited_left_anti_join, test_nlj_memory_limited_left_mark_join, same shape as the existing single-partition tests, 50-byte pool with spill_count asserted > 0 so the fallback path is actually exercised, results snapshot-checked against the unlimited-memory expectations.

@jayzhan211

Copy link
Copy Markdown
Contributor

Thanks @ranflarion and @kosiew, LGTM

@jayzhan211
jayzhan211 added this pull request to the merge queue Aug 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 27, 2026
@kosiew

kosiew commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

@ranflarion
Can you resolve the merge conflicts?

@ranflarion
ranflarion force-pushed the nlj-gate-left-family branch from ab71c9c to ce9440f Compare August 28, 2026 03:07
@ranflarion

Copy link
Copy Markdown
Contributor Author

@ranflarion Can you resolve the merge conflicts?

done

@kosiew
kosiew added this pull request to the merge queue Aug 28, 2026
Merged via the queue into apache:main with commit c4910e0 Aug 28, 2026
41 checks passed
viirya added a commit to viirya/arrow-datafusion that referenced this pull request Aug 28, 2026
…exhausts its left side (apache#24746)

## Which issue does this PR close?

- Closes apache#24745.

## Rationale for this change

A memory-limited `NestedLoopJoinExec` silently returned fewer rows than
the same query with an ample memory pool. No error was raised, so the
loss is invisible to the caller.

In memory-limited mode the left side is processed in chunks and the
right side is replayed for each chunk. A per-right-batch match bitmap
therefore cannot be emitted as soon as one chunk finishes probing it — a
later chunk may still match those rows. The operator already handles
this by merging each bitmap into
`SpillStateActive::global_right_bitmaps` and deferring emission to
`NLJState::EmitGlobalRightUnmatched`, which is reached from
`handle_emit_left_unmatched` once the left side is exhausted.

`handle_buffering_left` had a separate early exit:

```rust
if active.pending_batches.is_empty() {
    // No data at all — go directly to Done
    self.left_exhausted = true;
    self.state = NLJState::Done;
    return ControlFlow::Continue(());
}
```

Despite the comment, this also fires on the load that follows the final
left chunk, when the left side is exhausted and there is nothing more to
buffer. Ending the stream there discards the accumulated bitmaps, so
every probe-side row that no chunk matched is dropped.

Instrumenting a failing `LEFT JOIN` showed 39 bitmap merges and zero
emissions, with the completion path only ever running while
`left_exhausted` was still `false`.

The affected queries reach the operator as swapped join types (`Right`,
`RightAnti`, `RightSemi`) or as `Full`, so the rows needing unmatched
emission are on the operator's probe side. Measured against an unlimited
pool (`l` = 200 rows, `r` = 90 rows, `target_partitions = 1`,
`batch_size = 16`, 64-byte pool):

| query | ample | memory-limited (before) |
| --- | --- | --- |
| `NOT EXISTS (... l.v > r.w)` | 2 | 0 |
| `LEFT JOIN ... ON l.v > r.w` | 9336 | 9334 |
| `FULL JOIN ... ON l.v > r.w` | 9339 | 9337 |

`INNER` and explicit `RIGHT JOIN` were already correct.

This is pre-existing rather than a regression from apache#24675: that PR only
changes the spill gate in the same file, and its condition
(`need_produce_result_in_final(join_type) && right_partition_count > 1`)
does not cover this path.

## What changes are included in this PR?

Route that early exit to `EmitGlobalRightUnmatched` instead of `Done`
when the join tracks unmatched probe-side rows, clearing `right_data` so
a fresh replay pass is opened. A left side that was empty from the start
keeps its previous behaviour: no bitmaps have been accumulated, so that
state reports nothing unmatched and finishes immediately.

Spilling is preserved rather than refused — the fix corrects the
emission instead of turning these queries into `ResourcesExhausted`
errors.

## Are these changes tested?

Yes, `datafusion/core/tests/memory_limit/nlj_spill_unmatched.rs` adds
three tests comparing memory-limited results against an ample pool for
`LEFT JOIN`, `LEFT ANTI`, and `FULL JOIN`. Without the production change
they fail with `0 instead of 2`, `9334 instead of 9336`, and `9337
instead of 9339`; with it they pass.

The existing `nested_loop_join` unit tests (46) and the wider `joins`
suite (1116) pass unchanged, including
`test_nlj_memory_limited_right_join`, which asserts that a spilling
`RIGHT` join still returns its unmatched rows.

## Are there any user-facing changes?

No API changes. Queries that previously lost rows under a memory limit
now return the correct result.
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Correctness guard: disable memory-limited NestedLoopJoin fallback for LEFT-family joins with multi-partition right side

4 participants