Uh oh!
There was an error while loading. Please reload this page.
Fix memory reservation starvation in sort-merge - #20642
Conversation
Update: I added an end-to-end test which fails on main |
0f2140c to
651e9c9Compare| // Transfer the pre-reserved merge memory to the streaming merge | ||
| // using `take()` instead of `new_empty()`. This ensures the merge | ||
| // stream starts with `sort_spill_reservation_bytes` already | ||
| // allocated, preventing starvation when concurrent sort partitions | ||
| // compete for pool memory. `take()` moves the bytes atomically | ||
| // without releasing them back to the pool, so other partitions | ||
| // cannot race to consume the freed memory. |
There was a problem hiding this comment.
The pre reserved merge memory should be used as part of the sort merge stream.
I mean that if x pre reserved merge memory was reserved the sort merge stream should know about that so it wont think it starting from 0, otherwise this just reserve for unaccounted memory
There was a problem hiding this comment.
Thanks! Good point — just take() alone wouldn't be enough if the merge stream doesn't know about the pre-reserved bytes.
The PR does address this in the other changed files:
- In builder.rs: BatchBuilder now tracks batches_mem_used separately and only calls try_grow() when actual usage exceeds the current reservation size. It also records initial_reservation so
it never shrinks below that during build_output. This way the pre-reserved bytes are used as the initial budget rather than requesting from the pool on top of them. - In multi_level_merge.rs: get_sorted_spill_files_to_merge now tracks total_needed and only requests additional pool memory when total_needed > reservation.size(), so spill file buffers
covered by the pre-reserved bytes don't trigger extra pool allocations.
So the merge stream is aware of the pre-reserved bytes and uses them as its starting budget — it doesn't think it's starting from 0.
DataFusion 52.1.0 has a TOCTOU race in ExternalSorter where merge reservations are freed and re-created empty, letting other partitions steal the memory (apache/datafusion#20642). Until the upstream fix lands, compute a data-aware sort_spill_reservation_bytes by sampling actual Arrow row sizes from the input, estimating spill file count, and reserving enough for the merge phase.
* Sample-based sort spill reservation to mitigate merge OOM DataFusion 52.1.0 has a TOCTOU race in ExternalSorter where merge reservations are freed and re-created empty, letting other partitions steal the memory (apache/datafusion#20642). Until the upstream fix lands, compute a data-aware sort_spill_reservation_bytes by sampling actual Arrow row sizes from the input, estimating spill file count, and reserving enough for the merge phase. * Add more tests * formatting * Allow some truncation here * Handle when budgets are tight * Better estimate in-memory size using row count and avg row size * Remove dead code * Lint fix * linting * Fix low memory situations
01ae92d to
acd7caaCompare| for spill in &self.sorted_spill_files { | ||
| // For memory pools that are not shared this is good, for other this is not | ||
| // and there should be some upper limit to memory reservation so we won't starve the system |
There was a problem hiding this comment.
I think this comment still applies: if you have multiple partitions running, one partition will still be able to starve the others
| ) -> Result<(Vec<SortedSpillFile>, usize)> { | ||
| assert_ne!(buffer_len, 0, "Buffer length must be greater than 0"); | ||
| let mut number_of_spills_to_read_for_current_phase = 0; | ||
| // Track total memory needed for spill file buffers. When the |
There was a problem hiding this comment.
It feels like this whole method is ripe for a refactor, and introducing a memory floor is making it even more complex. Is there a way to incorporate the memory floor, but also simplify this a little bit
There was a problem hiding this comment.
add a try_grow_reservation_to_at_least help to reduce complexity
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working on this.
| // concurrent sort partitions compete for pool memory: the pre-reserved | ||
| // bytes cover spill file buffer reservations without additional pool | ||
| // allocation. | ||
| let mut memory_reservation = self.reservation.take(); |
There was a problem hiding this comment.
It looks like merge_sorted_runs_within_mem_limit() is transferring self.reservation into memory_reservation before it actually knows whether any spill files will be merged. If the builder already has enough in-memory streams to satisfy minimum_number_of_required_streams, but the first spill file still cannot fit, then get_sorted_spill_files_to_merge() could legitimately return zero spill files.
In that situation, is_only_merging_memory_streams would become true, but memory_reservation would still contain the bytes taken from self.reservation. That seems like it could trigger the assertion at lines 297–302 even though falling back to an all-in-memory merge is valid.
My understanding is that this creates a behavior regression in the mixed {sorted_streams + sorted_spill_files} path. Should the reservation transfer instead happen only after at least one spill file is selected, or should the unused reservation be returned to the all-in-memory merge path rather than being asserted away? 🤔
| if self.batches_mem_used > self.reservation.size() { | ||
| self.reservation | ||
| .try_grow(self.batches_mem_used - self.reservation.size())?; |
There was a problem hiding this comment.
This “grow only when usage exceeds current reservation” pattern is also checked at get_sorted_spill_files_to_merge in multi_level_merge.rs.
I think extracting this into a helper will make the intended invariant easier to check.
xudong963
commented
Mar 16, 2026
I plan to merge the PR tomorrow if there are no more comments |
alamb
commented
Mar 18, 2026
I am merging to try and keep the code flowing |
alamb
commented
Mar 18, 2026
Let's address any additional comments as follow on PRs |
<!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closesapache#123` indicates that this PR will close issue apache#123. --> - Closes #. <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> This PR fixes memory reservation starvation in sort-merge when multiple sort partitions share a GreedyMemoryPool. When multiple `ExternalSorter` instances run concurrently and share a single memory pool, the merge phase starves: 1. Each partition pre-reserves sort_spill_reservation_bytes via merge_reservation 2. When entering the merge phase, new_empty() was used to create a new reservation starting at 0 bytes, while the pre-reserved bytes sat idle in ExternalSorter.merge_reservation 3. Those freed bytes were immediately consumed by other partitions racing for memory 4. The merge could no longer allocate memory from the pool → OOM / starvation <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> ~~I can't find a deterministic way to reproduce the bug, but it occurs in our production.~~ Add an end-to-end test to verify the fix <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. -->
<!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closesapache#123` indicates that this PR will close issue apache#123. --> - Closes #. <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> This PR fixes memory reservation starvation in sort-merge when multiple sort partitions share a GreedyMemoryPool. When multiple `ExternalSorter` instances run concurrently and share a single memory pool, the merge phase starves: 1. Each partition pre-reserves sort_spill_reservation_bytes via merge_reservation 2. When entering the merge phase, new_empty() was used to create a new reservation starting at 0 bytes, while the pre-reserved bytes sat idle in ExternalSorter.merge_reservation 3. Those freed bytes were immediately consumed by other partitions racing for memory 4. The merge could no longer allocate memory from the pool → OOM / starvation <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> ~~I can't find a deterministic way to reproduce the bug, but it occurs in our production.~~ Add an end-to-end test to verify the fix <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. -->
## Which issue does this PR close? <!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closesapache#123` indicates that this PR will close issue apache#123. --> - Closes #. ## Rationale for this change <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> This PR fixes memory reservation starvation in sort-merge when multiple sort partitions share a GreedyMemoryPool. When multiple `ExternalSorter` instances run concurrently and share a single memory pool, the merge phase starves: 1. Each partition pre-reserves sort_spill_reservation_bytes via merge_reservation 2. When entering the merge phase, new_empty() was used to create a new reservation starting at 0 bytes, while the pre-reserved bytes sat idle in ExternalSorter.merge_reservation 3. Those freed bytes were immediately consumed by other partitions racing for memory 4. The merge could no longer allocate memory from the pool → OOM / starvation ## What changes are included in this PR? <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> ## Are these changes tested? <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> ~~I can't find a deterministic way to reproduce the bug, but it occurs in our production.~~ Add an end-to-end test to verify the fix ## Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. -->
## Why are the changes needed? Closesapache#24739. An external sort can fail while spilling even after it has acquired enough workspace for that spill. Spill preparation returns the reserve to the parent memory pool, then cursors and encoded rows request fresh capacity. Those requests can fail if the allocation limit has decreased or another consumer has taken the released capacity. For example, a sorter holding 80 MiB of input and 16 MiB of spill workspace can encounter a new allocation ceiling of 64 MiB. Releasing the workspace leaves 80 MiB reserved, so even a 1 MiB cursor request can fail. Reusing the workspace lets the spill proceed without asking for those bytes again. This follows [apache#20642](apache#20642), which preserved the reservation for the final disk merge, and addresses the remaining release during spill preparation. ## What changes were proposed in this PR? When spilling requires a merge of separately sorted batches, the sorter keeps one parent reservation for its workspace and lets its cursor, encoded-row, and merge-buffer reservations draw from it. `MergeMemoryPool`, provided by `datafusion_execution::memory_pool`, tracks this shared capacity, so moving bytes between these reservations does not return them to the execution pool or require them to be granted again. The workspace remains available across multi-run spills, intermediate merge passes, and retries that split an oversized spill batch. Single-batch spills also retain this workspace, including a spill triggered by the next input batch and the final leftover batch after an earlier spill. Chunked sorted output can temporarily borrow unused workspace when its accounted size exceeds the input reservation. Borrowed bytes return as batches are emitted or the stream is dropped. Any remaining growth of the sorted output still goes through the original sort consumer, preserving the parent pool's limit and fair-share checks. The sorter releases unused workspace when no further spill needs it. For a disk merge, this happens after the final pass has selected its buffer budget; live reservations remain charged. Other consumers can reclaim the idle capacity without waiting for the previous sort's output stream to be dropped. The small multi-batch concatenation path still releases idle workspace before resizing the ordinary sort reservation, because that reservation cannot borrow merge workspace. This path can still require a fresh parent allocation. The user-facing change is that sorts affected by the covered reservation losses can spill successfully. SQL behavior and configuration options are unchanged; insufficient memory beyond the available reservations still produces an error. `MergeMemoryPool` and `WorkspaceLoan` are additive public APIs in `datafusion_execution::memory_pool`, documented with a runnable example and the single-parent-consumer accounting policy. ## How was this PR tested? The single-batch regressions were first run with the original spill branch logic: the reduced-limit case failed with `ResourcesExhausted("allocation limit reached")`, and the live-loan case detected that the retained reservation had been released. Both pass after removing the empty/single-batch releases. The reduced-limit test covers insertion-triggered spilling and finalization, complete sorted output, the batch-size cap, and cleanup; the cancellation test exercises the actual single-input dispatch and checks the live workspace loan. The regression `test_spill_preserves_merge_workspace_after_limit_decreases` fails with `ResourcesExhausted("allocation limit reached")` when its test-only fixture is added to upstream `ee59f628b44eb80e8a4f126288632ef51fda5dc2` without the production fix. It passes with this change. Both runs were rebuilt from the corresponding source, using the same fixture and dependency lockfile. The updated source was validated with Rust 1.97.0: - Extended workspace coverage: 10,918 Rust tests passed and eight were ignored, including 1,913 physical-plan tests and 113 execution tests. Features were `avro,json,backtrace,extended_tests,recursive_protection,parquet_encryption`, with examples, benchmarks, and CLI excluded as prescribed by the contributor checks. - All 505 SQL logic test files passed with four test threads. - All 107 CLI tests passed. - The execution and physical-plan documentation suites passed 29 doctests, with 23 ignored, including the new public-pool example. - `cargo fmt --all`, `git diff --check`, `cargo clippy --locked --all-targets --all-features -- -D warnings`, and the complete `./dev/rust_lint.sh` suite passed, including license, spelling, Markdown, and Rust documentation checks. The first extended run passed 117 fuzz cases but hit the process's 1,024-open-file limit in `test_sort_10k_mem`. The same test executable passed that case with the limit raised to 65,536. The remaining workspace suites were then completed with the higher limit while skipping the already-exercised fuzz module; the five SQL test-driver unit tests and SQL logic tests were run separately. No source change was needed for this environment limit. Additional cases compare complete sorted results and cover chunked string-view and dictionary output, overlapping output streams, intermediate disk-merge passes, parent-pool limits, and cleanup after completion, errors, or cancellation. On the earlier `94c1c16` revision, I also ran four existing `datafusion/core/benches/sort.rs` cases with one million rows, four mixed-type sort keys, low/high cardinality, and zero/five extra payload columns. Both versions used `release-nonlto` and identical benchmark source and dependencies. An initial ten-sample comparison showed 1.6–3.0% higher mean latency with the patch. I then repeated the already-built binaries in base/patch/patch/base order with 20 samples per case; the averages of the two runs per version were: | Cardinality / extra payload columns | Upstream mean | Patched mean | Change | | --- | ---: | ---: | ---: | | Low / 0 | 117.06 ms | 120.26 ms | +2.73% | | Low / 5 | 136.85 ms | 138.13 ms | +0.94% | | High / 0 | 115.99 ms | 116.78 ms | +0.68% | | High / 5 | 134.78 ms | 137.25 ms | +1.83% | These local measurements show higher mean latency in the selected cases, with visible variation between runs. They exercise normal in-memory sorting with an unbounded pool; they do not measure spill recovery or end-to-end query performance. The bounded-pool regressions above establish the correctness benefit. The updated source was also compared with upstream main at the PR base (`d1fc4b334`) using the existing low-cardinality Utf8View sort cases. Both revisions used Rust 1.97.0, `release-nonlto`, identical benchmark source and dependencies, independent target directories, and verified source/executable hashes. Two rounds ran in main/patch/patch/main order with four Tokio workers, 30 samples per case, one second of warmup, and five seconds of measurement: | Rows | Main mean | Updated mean | Change | | --- | ---: | ---: | ---: | | 100K | 5.549 ms | 5.425 ms | -2.24% | | 1M | 61.098 ms | 59.625 ms | -2.41% | The means average the two rounds. No regression was detected in these two cases; the 100K differences were not statistically significant, and the 1M reverse-order comparison fell within Criterion's noise threshold. These normal, unbounded-memory sorts do not establish a general speedup, measure constrained spilling, or isolate the review changes from the rest of the PR. AI assistance: Codex assisted with the implementation, regression tests, PR text, and the reported local checks and source reviews.
Which issue does this PR close?
Rationale for this change
This PR fixes memory reservation starvation in sort-merge when multiple sort partitions share a GreedyMemoryPool.
When multiple
ExternalSorterinstances run concurrently and share a single memory pool, the merge phase starves:What changes are included in this PR?
Are these changes tested?
I can't find a deterministic way to reproduce the bug, but it occurs in our production.Add an end-to-end test to verify the fixAre there any user-facing changes?