Share per-chunk JoinLeftData across right partitions in NLJ memory-limited fallback - #22038
Share per-chunk JoinLeftData across right partitions in NLJ memory-limited fallback#22038viirya wants to merge 2 commits into
Conversation
a1a8bbe to
15e05fe
Compare
| 04)------AggregateExec: mode=Partial, gby=[], aggr=[count(Int64(1))], metrics=[<slt:ignore>] | ||
| 05)--------NestedLoopJoinExec: join_type=Left, filter=v1@0 + v2@1 = 101, projection=[], metrics=[output_rows=5.00 K, <slt:ignore> spill_count=2, <slt:ignore>] | ||
| 06)----------ProjectionExec: expr=[value@0 as v1], metrics=[<slt:ignore>] | ||
| 07)------------LazyMemoryExec: partitions=1, batch_generators=[generate_series: start=1, end=5000, batch_size=8192], metrics=[<slt:ignore>] |
There was a problem hiding this comment.
Thanks for adding the regression coverage here. I do not think this currently exercises the multi-chunk coordinator path though.
The plan shows the left generate_series(1, 5000) being produced as one batch_size=8192 batch, and load_one_chunk accepts a single over-budget batch so it can make progress. Because of that, these SLT cases appear to validate only the one-chunk fallback path.
The main invariant added by the coordinator is per-chunk sharing, followed by releasing the current chunk and advancing to the next one. Without a test where the left input is split into at least two chunks, the carryover, release_chunk, waiter notification, and global-right accumulation across chunks are not really covered.
Could you please add a multi-partition spill regression with multiple left batches or chunks? For example, this could be a Rust test that feeds more than one left batch, or an SLT shape that produces multiple left batches. It would be good to assert the same LEFT/FULL or LEFT SEMI/ANTI counts, plus spill_count > 0.
| ); | ||
| } | ||
|
|
||
| // Case 1: requested chunk is already loaded. |
There was a problem hiding this comment.
Small follow-up suggestion: while a leader is loading a chunk, waiters can see left_stream.is_none() and reopen the spill stream before they reach the loader_in_flight wait path.
The leader later overwrites that stream, so this looks like wasted I/O and state churn. It may be worth guarding lazy stream/reservation initialization with !inner.loader_in_flight, or moving that initialization into the leader-claim branch so only the loader sets up those fields.
Previously the shared left spill stream and chunk reservation were lazily initialized at the top of `FallbackCoordinator::next_chunk`, before the `loader_in_flight` guard. While a leader was loading a chunk, a waiting partition could observe `left_stream.is_none()` and reopen the spill stream, which the leader would then overwrite — wasted I/O and state churn. Move the lazy initialization into the leader-claim branch (under the `loader_in_flight` guard), so only the leader sets up the stream, schema, and reservation. Waiters fall straight through to the notify wait path. Addresses a review suggestion on apache#22038. Co-authored-by: Claude Code
The existing multi-partition spill tests feed the left side as a single batch, so the memory-limited fallback only ever loads one chunk. That leaves the coordinator's multi-chunk machinery untested: `carryover` between chunks, `release_chunk` advancing to the next chunk, waiter notification, and (for FULL) the global right-unmatched bitmap accumulated across chunks. Add `*_multi_chunk_left_join` and `*_multi_chunk_full_join`: the left input is one row per batch under a tight memory limit, so the coordinator loads each row as a separate chunk (verified to load multiple chunks with spill_count > 0). Both assert exact row sets, so a regression in per-chunk `JoinLeftData` sharing (duplicate unmatched rows) or cross-chunk right accumulation would fail. These collect the four output partitions concurrently: a chunk is not released until all partitions finish probing it, so sequential collection would deadlock — concurrent collection mirrors how partitions run under the runtime. Also drop a stray `dbg!` in `join_inner_with_filter`. Addresses review feedback on apache#22038. Co-authored-by: Claude Code
|
@kosiew Thanks for the careful review — both points were spot on. Multi-chunk coverage. You're right that the existing SLT cases only exercised the single-chunk path: One thing worth noting: these tests collect the four output partitions concurrently. Sequential collection deadlocks on the multi-chunk path — a chunk isn't released until every partition finishes probing it (the Leader-only stream init. Good catch — moved the lazy stream/schema/reservation initialization into the leader-claim branch (under the Pushed both as separate commits. Ready for another look when you have a chance. |
There was a problem hiding this comment.
Thanks for the updates. I re-reviewed the changes and the two follow-ups I raised have been addressed:
- Added multi-chunk regression coverage with concurrent output partitions, strict memory pressure, exact LEFT/FULL output assertions, and spill verification.
- Moved stream initialization under the leader claim so waiting tasks no longer create throwaway spill streams.
I only have one small non-blocking suggestion.
| None => { | ||
| let stream = spill_data | ||
| .spill_manager | ||
| .read_spill_as_stream(spill_data.spill_file.clone(), None)?; |
There was a problem hiding this comment.
Nice improvement moving stream initialization behind the leader claim.
One small thing that caught my eye: loader_in_flight is set to true before calling read_spill_as_stream(...), and the call uses ?. Today that seems safe because read_spill_as_stream only constructs the buffered stream and returns Ok(...); any real file/open/schema errors happen later while polling the stream, where load_one_chunk already clears loader_in_flight and wakes any waiters.
This feels a little fragile though. If stream construction ever becomes genuinely fallible in the future, an early return here could leave waiters blocked indefinitely. It might be worth avoiding ? while the flag is set, or using a small guard/helper that guarantees the flag is cleared and waiters are notified if leader setup fails.
There was a problem hiding this comment.
Good catch — agreed it's worth hardening even though it isn't reachable today (read_spill_as_stream only builds the buffered stream; the real I/O errors surface later while polling, where load_one_chunk already clears loader_in_flight and notifies).
Fixed: on stream-construction failure the leader now clears loader_in_flight, drops the lock, wakes waiters, and propagates the error, so another partition can claim the leader role and retry rather than blocking on a release that never comes. Pushed as a separate commit.
|
Leaving this open for more review as this is a big PR. |
…ails In `FallbackCoordinator::next_chunk`, the leader sets `loader_in_flight` before constructing the spill stream. If that construction returned an error via `?`, the leader would bail out while still holding `loader_in_flight = true` and without notifying waiters — leaving every other partition blocked forever on a chunk release that the failed leader can never make. `read_spill_as_stream` only builds the buffered stream today (real I/O errors surface later while polling, where the flag is already cleared), so this is not currently reachable. Handle it defensively anyway: on construction failure, clear `loader_in_flight`, drop the lock, wake waiters, and propagate the error so another partition can retry. Addresses a review suggestion on apache#22038. Co-authored-by: Claude Code
|
Thanks for fixing this. A question about distributed execution. Downstream engines like Ballista and datafusion-distributed run each output partition of a plan as an independent task, often in a separate process. Each task deserializes and instantiates the physical plan on its own and calls If I read Could a distributed consumer opt out of this cross-partition coordination through a config flag, the way This review was assisted by an LLM. |
The memory-limited NestedLoopJoin fallback coordinates per-chunk left state (visited bitmap + probe-thread counter) across all right-side partitions via `FallbackCoordinator`. That coordination assumes every right partition runs in the same process. Distributed engines (Ballista, datafusion-distributed) execute each output partition as an independent task that builds its own coordinator and polls a single partition, so the shared probe-thread counter never reaches zero, no partition is elected to release the current chunk, and the waiters block forever — the memory-limited fallback deadlocks. Add `datafusion.execution.enable_nlj_coordinated_fallback` (default true, preserving current behavior). When set to false, the coordinated fallback is disabled for the join types that would deadlock — left-emitting joins (LEFT, LEFT SEMI, LEFT ANTI, LEFT MARK, FULL) with a multi-partition right side — which then use `SpillState::Disabled` and fail with resource exhaustion under memory pressure rather than deadlocking. Single-partition and non-left-emitting joins are always safe and keep the coordinated fallback regardless of the flag. Raised by @andygrove on apache#22038. Co-authored-by: Claude Code
|
@andygrove Thanks for looking at this closely — your analysis is correct, and it's a real deadlock, not a stall in theory. To confirm the mechanism: the coordinator initializes One nuance I want to be honest about: the single-process assumption itself isn't new — the single-pass path ( I've pushed an opt-out along the lines you suggested: a new config flag I kept the default as |
fdb63b4 to
8f2fd93
Compare
Previously the shared left spill stream and chunk reservation were lazily initialized at the top of `FallbackCoordinator::next_chunk`, before the `loader_in_flight` guard. While a leader was loading a chunk, a waiting partition could observe `left_stream.is_none()` and reopen the spill stream, which the leader would then overwrite — wasted I/O and state churn. Move the lazy initialization into the leader-claim branch (under the `loader_in_flight` guard), so only the leader sets up the stream, schema, and reservation. Waiters fall straight through to the notify wait path. Addresses a review suggestion on apache#22038. Co-authored-by: Claude Code
The existing multi-partition spill tests feed the left side as a single batch, so the memory-limited fallback only ever loads one chunk. That leaves the coordinator's multi-chunk machinery untested: `carryover` between chunks, `release_chunk` advancing to the next chunk, waiter notification, and (for FULL) the global right-unmatched bitmap accumulated across chunks. Add `*_multi_chunk_left_join` and `*_multi_chunk_full_join`: the left input is one row per batch under a tight memory limit, so the coordinator loads each row as a separate chunk (verified to load multiple chunks with spill_count > 0). Both assert exact row sets, so a regression in per-chunk `JoinLeftData` sharing (duplicate unmatched rows) or cross-chunk right accumulation would fail. These collect the four output partitions concurrently: a chunk is not released until all partitions finish probing it, so sequential collection would deadlock — concurrent collection mirrors how partitions run under the runtime. Also drop a stray `dbg!` in `join_inner_with_filter`. Addresses review feedback on apache#22038. Co-authored-by: Claude Code
…ails In `FallbackCoordinator::next_chunk`, the leader sets `loader_in_flight` before constructing the spill stream. If that construction returned an error via `?`, the leader would bail out while still holding `loader_in_flight = true` and without notifying waiters — leaving every other partition blocked forever on a chunk release that the failed leader can never make. `read_spill_as_stream` only builds the buffered stream today (real I/O errors surface later while polling, where the flag is already cleared), so this is not currently reachable. Handle it defensively anyway: on construction failure, clear `loader_in_flight`, drop the lock, wake waiters, and propagate the error so another partition can retry. Addresses a review suggestion on apache#22038. Co-authored-by: Claude Code
The memory-limited NestedLoopJoin fallback coordinates per-chunk left state (visited bitmap + probe-thread counter) across all right-side partitions via `FallbackCoordinator`. That coordination assumes every right partition runs in the same process. Distributed engines (Ballista, datafusion-distributed) execute each output partition as an independent task that builds its own coordinator and polls a single partition, so the shared probe-thread counter never reaches zero, no partition is elected to release the current chunk, and the waiters block forever — the memory-limited fallback deadlocks. Add `datafusion.execution.enable_nlj_coordinated_fallback` (default true, preserving current behavior). When set to false, the coordinated fallback is disabled for the join types that would deadlock — left-emitting joins (LEFT, LEFT SEMI, LEFT ANTI, LEFT MARK, FULL) with a multi-partition right side — which then use `SpillState::Disabled` and fail with resource exhaustion under memory pressure rather than deadlocking. Single-partition and non-left-emitting joins are always safe and keep the coordinated fallback regardless of the flag. Raised by @andygrove on apache#22038. Co-authored-by: Claude Code
|
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #22038 +/- ##
==========================================
+ Coverage 81.50% 81.53% +0.03%
==========================================
Files 1123 1123
Lines 404763 406545 +1782
Branches 404763 406545 +1782
==========================================
+ Hits 329898 331490 +1592
- Misses 55553 55657 +104
- Partials 19312 19398 +86 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…joins with a multi-partition probe side (apache#24675) ## Which issue does this PR close? - Closes apache#22641. ## 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: ```rust 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 apache#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.
…lback Rebase of the coordinated-fallback work onto current main. The memory-limited NestedLoopJoin fallback built a per-partition `JoinLeftData` with `probe_threads_counter == 1`. For join types whose final emission reads the visited-left bitmap (LEFT, LEFT SEMI, LEFT ANTI, LEFT MARK, FULL) each right partition therefore emitted from a bitmap that had only seen its own right rows, so unmatched rows came out once per partition and rows matched only in another partition came out as unmatched. Because those results are wrong, the fallback was refused for that combination and the query failed with ResourcesExhausted instead of spilling. `FallbackCoordinator` now loads each chunk once via a leader partition and publishes it as a shared `Arc<JoinLeftData>` whose probe-thread counter is seeded with `right_partition_count`, so the last partition to finish a chunk emits its unmatched left rows -- matching how the single-pass path coordinates through `collect_left_input(.., probe_threads_count)`. Those join types now spill instead of erroring. The coordination assumes all right partitions run in one process. Distributed engines run each partition as an independent task with its own coordinator, so the shared counter would never reach zero and the fallback would stall; `enable_nlj_coordinated_fallback = false` lets them opt out and keep the previous fail-fast behavior for the affected join types. Rebase notes: main since shares the spilled left side across partitions via `OnceAsync<LeftLoad>`, so the original `left_spill_fut`/`OnceFut` half of this change is dropped in favor of main's mechanism. The `EmitGlobalRightUnmatched` routing added in apache#24746 is preserved on the coordinator's exhausted-left path, keeping its three regression tests green. Co-authored-by: Claude Code
fa6902c to
03c8821
Compare
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working on this. The cross-partition coordination approach looks promising, and the multi-partition and multi-chunk coverage is helpful.
I found one issue with the final chunk lifecycle that I think needs to be fixed before merging. The coordinator can retain the final chunk and its memory reservation after the query has completed if the physical plan remains alive. I left an inline comment with the details.
I also left one non-blocking suggestion for additional opt-out coverage.
| // recomputed when `ProbeEnd` is re-entered for the next | ||
| // chunk, so it does not need to be reset here. | ||
| self.state = NLJState::BufferingLeft; | ||
| } else if self.is_memory_limited() |
There was a problem hiding this comment.
I think the final chunk is never actually released here.
The release future is created above, but chunk_release_in_flight is only polled when we return to BufferingLeft or re-enter handle_emit_left_unmatched. That works for non-final chunks because they transition back to BufferingLeft. For the final chunk, though, a LEFT join goes directly to Done, while a FULL join goes to EmitGlobalRightUnmatched.
As a result, FallbackCoordinatorInner.current can keep its Arc<JoinLeftData> along with the coordinator reservation until the physical plan itself is dropped. If a completed query's plan stays alive, the final chunk's batch and bitmap remain accounted against the memory pool and could cause later queries to hit the memory limit.
Could we make sure the final chunk release is driven to completion before transitioning to Done or EmitGlobalRightUnmatched? It would also be good to add a regression that keeps the plan alive after collection and verifies that the coordinator reservation has been released.
There was a problem hiding this comment.
Good catch, and confirmed — this was a real leak, not just a theoretical one.
I reproduced it before fixing: holding the plan alive after collecting every partition, the pool still had 1921 bytes reserved for both LEFT and FULL. Your reading of the control flow was exactly right: the release future is created, then the final chunk goes straight to Done / EmitGlobalRightUnmatched, neither of which polls chunk_release_in_flight.
Fixing it turned out to need two parts, the second of which I only found because the test still failed:
-
A new
ReleasingFinalChunkstate that polls the release future before finishing, then continues to whichever state would have followed. (My first attempt just re-enteredEmitLeftUnmatchedso its existing drain would run, but that re-runsprocess_left_unmatched()and panics withLeftData should be available, sincebuffered_left_datahas already been cleared by then.) -
release_chunkalso has toresize(0)the coordinator reservation once the left side is exhausted. With only fix 1 the retained figure stayed at exactly 1921 — the per-chunkJoinLeftDatacarries just an empty RAII placeholder (reservation.new_empty()), so dropping theArcfrees nothing by itself; the real bytes live in the coordinator's reservation, which otherwise waits for aload_one_chunkthat never comes after the final chunk.
Added test_nlj_memory_limited_releases_final_chunk_{left,full}_join, which keep the plan alive after collection and assert pool.reserved() == 0. Both fail without the change.
| Ok(()) | ||
| } | ||
|
|
||
| /// When `enable_nlj_coordinated_fallback` is disabled, a LEFT join with |
There was a problem hiding this comment.
Could we also add an opt-out regression for one non-left-emitting join, such as RIGHT or INNER, under the same multi-partition memory pressure?
The documented contract says enable_nlj_coordinated_fallback = false only disables the coordinated fallback for left-emitting multi-partition joins. A small regression here would help ensure that a future change to this guard does not accidentally disable spill fallback for unaffected join types.
There was a problem hiding this comment.
Agreed — added test_nlj_memory_limited_fallback_disabled_right_join_still_spills, covering both RIGHT and INNER under the same multi-partition memory pressure with enable_nlj_coordinated_fallback = false, asserting spill_count > 0. That pins the guard's scope so a future change can't quietly disable the spill fallback for join types that never needed left-bitmap coordination.
One thing worth recording from writing it: my first version drained the partitions sequentially (reusing multi_partition_memory_limited_join_collect) and hung. The coordinator seeds every chunk's probe counter with right_partition_count regardless of join type — with_visited_bitmap only controls whether a bitmap is allocated, not the counter — so with a multi-chunk left side, partition 0 waits on the other three to report before chunk 0 can be released, and collecting them one after another deadlocks. That's why all the existing multi-chunk coordinated tests use the concurrent helper; the new test does too, and I noted the reason in a comment so nobody reuses the sequential helper here.
It's a test-harness constraint rather than a product issue (a real plan always polls its partitions concurrently), but it does mean "RIGHT joins don't need coordination" is true of the bitmap and not of the chunk lockstep. Happy to make the counter join-type-aware in a follow-up if you think that lockstep is worth removing for non-left-emitting joins — it would let those partitions advance independently, but it's a behavioural change beyond this PR's scope.
Addresses review feedback on the coordinated fallback.
`release_chunk` is what drops the coordinator's `Arc<JoinLeftData>` and
returns the chunk's memory. Non-final chunks were released on the way back
through `BufferingLeft`, but after the last chunk the stream went straight
to `Done` (LEFT) or `EmitGlobalRightUnmatched` (FULL), and neither state
polls `chunk_release_in_flight`. The coordinator hangs off the exec rather
than the stream, so the final chunk's batch, bitmap and reservation stayed
accounted against the memory pool for as long as the plan was alive --
enough to push a later query over the limit if a finished plan is still
referenced.
Two changes were needed. A new `ReleasingFinalChunk` state polls the
release future before finishing, and `release_chunk` now also resizes the
coordinator reservation to zero once the left side is exhausted: the
per-chunk `JoinLeftData` carries only an empty RAII placeholder, so
dropping the `Arc` frees nothing on its own, and the real bytes would
otherwise wait for a `load_one_chunk` that never comes.
Tests: `test_nlj_memory_limited_releases_final_chunk_{left,full}_join` keep
the plan alive after collecting every partition and assert the pool is back
to zero. Both fail without this change (1921 bytes retained).
Also adds `test_nlj_memory_limited_fallback_disabled_right_join_still_spills`,
pinning the scope of the `enable_nlj_coordinated_fallback` opt-out: RIGHT and
INNER do not need left-bitmap coordination, so the opt-out must leave their
spill fallback intact. It drains partitions concurrently because the
coordinator seeds every chunk's probe counter with `right_partition_count`
whatever the join type, so a multi-chunk left side cannot advance when the
partitions are collected one after another.
Co-authored-by: Claude Code
kosiew
left a comment
There was a problem hiding this comment.
Thanks for the updates here. The multi-chunk coordinator coverage, leader-only stream initialization, and opt-out coverage for RIGHT and INNER joins all look addressed.
I found two remaining issues that I think should be resolved before merging. The first is around the lifetime of the final chunk's memory reservation, and the second is the SemVer impact of adding a field to ExecutionOptions.
I left inline comments with the details. The focused NLJ tests I ran are passing.
| if inner.left_exhausted | ||
| && let Some(reservation) = inner.reservation.as_mut() | ||
| { | ||
| reservation.resize(0); |
There was a problem hiding this comment.
I think there is still a memory-accounting race here. release_chunk calls reservation.resize(0) as soon as the probe-counter emitter releases the final chunk, but that emitter is only the last stream to finish probing. It is not necessarily the last stream to drop its buffered_left_data reference.
For example, another partition can already be in EmitLeftUnmatched, flush a completed matched-output batch from maybe_flush_ready_batch, and return while still holding its Arc<JoinLeftData>. The emitter can then reach this cleanup and release the reservation even though that chunk's RecordBatch and bitmap are still live in the other partition. At that point the pool under-accounts the actual live memory and could allow the configured limit to be exceeded.
Could we keep the reservation charged until every partition has relinquished its reference to the chunk? One option would be a separate per-chunk release acknowledgement. Another would be to tie reservation ownership directly to the shared chunk data so its lifetime follows the data naturally.
It would also be good to add a scheduling-sensitive regression that holds a non-emitter after it flushes output while allowing the emitter to release the final chunk. That should catch this lifetime gap.
| /// with a resource-exhaustion error under memory pressure rather than | ||
| /// deadlocking. Single-partition and non-left-emitting joins are | ||
| /// unaffected and always keep the fallback. | ||
| pub enable_nlj_coordinated_fallback: bool, default = true |
There was a problem hiding this comment.
I think the SemVer concern raised by the bot still needs to be addressed. ExecutionOptions is public and can be constructed exhaustively, so adding this public field breaks downstream struct literals. cargo-semver-checks reports constructible_struct_adds_field for this field.
Could we use a configuration mechanism that does not add a field to the publicly constructible struct? Otherwise, if this API break is intentional, I think it needs to be explicitly approved and targeted for the appropriate major-version change.
Which issue does this PR close?
Rationale for this change
The memory-limited fallback path builds a per-output-partition
JoinLeftDatafor each left chunk, withprobe_threads_counter == 1. The single-pass path instead shares oneJoinLeftDataseeded viacollect_left_input(.., probe_threads_count). With a private visited bitmap per partition, each right partition sees only its own right rows, so unmatched left rows would be emitted once per partition, and rows matched only in another partition would be emitted as unmatched.Because those results would be wrong,
mainrefuses the fallback for the affected join types rather than producing them:So the state on
maintoday is not incorrect output but a missing capability: withtarget_partitions > 1, aLEFT/LEFT SEMI/LEFT ANTI/LEFT MARK/FULLnested loop join has no spilling path at all and fails withResourcesExhaustedunder memory pressure. This PR makes those joins spill instead.(When this PR was first opened the guard covered only
FULL, so the wrong results were still reachable for the other join types. #21833 and #24675 have since widened it to every left-emitting type, which is why the framing above differs from the original description.)What changes are included in this PR?
A plan-level
FallbackCoordinatorthat:MemoryReservation, so each chunk is read exactly once no matter how many partitions probe it,Arc<JoinLeftData>(withprobe_threads_counter == right_partition_count) into a shared slot,Arcclone of that sameJoinLeftData, so the visited bitmap and probe-thread counter are shared exactly as in the single-passcollect_left_inputpath,The per-chunk in-flight fetch and release are driven through future fields on
SpillStateActive(chunk_fetch_in_flight/chunk_release_in_flight), polled acrosspoll_nextiterations. Partitions waiting for a chunk sleep on aNotifyrather than busy-looping, and every early return after a leader claim clearsloader_in_flightand wakes waiters so a failed load cannot leave others parked.The
left_emission_multi_partitionguard is removed, so these join types now take the coordinated path.datafusion.execution.enable_nlj_coordinated_fallback(defaulttrue) lets distributed engines opt out. The coordination assumes all right partitions run in the same process; an engine that runs each partition as an independent task would get one coordinator per task, so the shared probe-thread counter would never reach zero and the fallback would stall. Setting the flag tofalserestores the current fail-fast behavior for the affected join types.Rebase notes (2026-08-28)
Rebased onto current
main. Two things changed underneath this PR:mainnow shares the spilled left side across partitions itself, resolving a sharedOnceAsync<LeftLoad>so the left child is executed and spilled exactly once. That was one half of this PR's original design (left_spill_fut: OnceFut<LeftSpillData>), so that half is dropped in favor ofmain's mechanism. What remains — and whatmainstill lacks — is sharing the per-chunk visited bitmap and probe-thread counter.EmitGlobalRightUnmatchedrouting so the memory-limited path does not drop deferred unmatched probe-side rows when the left side is exhausted. That routing is preserved on the coordinator's exhausted-left path, and its three regression tests indatafusion/core/tests/memory_limit/nlj_spill_unmatched.rspass.Design discussion from the original round: #21833 (comment) and #21833 (comment).
Are these changes tested?
Yes:
nested_loop_join.rs: one each forLEFT,FULL,LEFT SEMI,LEFT ANTI,LEFT MARK, plus two multi-chunk cases (LEFT,FULL) that exercise the chunk hand-off between leaders. Each compares the memory-limited result against the unrestricted one. All seven fail onmain(asResourcesExhausted) and pass here.nested_loop_join_spill.slt, including anEXPLAIN ANALYZEassertion onspill_countso the tests fail if the fallback silently stops being taken.enable_nlj_coordinated_fallbacklisted ininformation_schema.slt.Regression-checked after the rebase: 55 NLJ unit tests, 1125
joinstests, 36memory_limittests (including the three #24746 tests above), 259datafusion-prototests, and thenested_loop_join*/information_schemasqllogictest files.cargo clippy --all-targets --all-features -- -D warningsis clean.Are there any user-facing changes?
Yes, two:
LEFT,LEFT SEMI,LEFT ANTI,LEFT MARK, andFULLnested loop joins over a multi-partition right side now spill under memory pressure instead of failing withResourcesExhausted.datafusion.execution.enable_nlj_coordinated_fallback(defaulttrue), documented indocs/source/user-guide/configs.md. No public API signature changes.