Skip to content

fix: RepartitionExec deadlock when its spill pool holds two open files - #73

Closed
adriangb wants to merge 3 commits into
mainfrom
claude/datafusion-24883-fixes-jeha12
Closed

fix: RepartitionExec deadlock when its spill pool holds two open files#73
adriangb wants to merge 3 commits into
mainfrom
claude/datafusion-24883-fixes-jeha12

Conversation

@adriangb

@adriangb adriangb commented Sep 2, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

Under a memory limit, a grouped aggregation on two or more partitions can stop and never complete. The query does not return and does not give an error. All worker threads wait. The issue has the full analysis.

In short: when RepartitionExec cannot reserve memory for a batch, it writes the batch to a spill pool and sends a marker. The reader then blocks on the pool until it gets a batch. When two input tasks spill at the same time, the pool has two open files. The reader reads only the oldest file. When that file has no unread batch, the reader waits for it. But the batch that the reader needs is in the newer file. When each channel has data, the gate closes and the writers wait too. Nothing can wake the reader.

What changes are included in this PR?

The PR has three commits, in test-first order.

  1. test: reproduce the RepartitionExec spill pool deadlock with a SQL query. This is the query from the issue as a test in datafusion/core/tests/memory_limit/repartition_mem_limit.rs. On main, attempt 0 does not complete and the test stops it after 20 seconds.
  2. test: add a scenario fuzzer for the spill pool contract. This adds a test double that can hold a writer inside its first disk write and can delay the reads of a file. It adds a regression test that holds writer 1 while writer 2 pushes a batch. It adds spill_pool_scenario_fuzz, which runs random scenarios from a seed and checks the pool contract: after overlapping pushes the reader can read each pushed batch without more writer activity, it does not signal EOF while a writer is alive, it gives exactly the pushed batches (in order for one writer), and it releases the files. Both tests are red on this commit. The extended CI job now runs 1000 seeds.
  3. fix: RepartitionExec deadlock when its spill pool holds two open files. SpillPoolReader now keeps all the files that it received, oldest first, and gives the first available batch. It skips a file that has no unread batch. It waits for the oldest file that has an unread batch when the read of that file is not complete, so one writer gets FIFO order. The writer side does not change. Both tests are green on this commit.

The fuzzer finds each known bug of this pool that a test can reach:

Pool version Unit tests of that version Fuzzer
Before apache#23522 (has apache#23447) 16 of 16 pass Seeds 0 and 1: stall with unread batches. Seed 4: stall after the drop of all writers (the apache#23447 signature).
main (has apache#24883) pass Seed 0: stall with 5 of 13 batches unread.
A draft of this fix that skips a file with a read in progress 1 failure Seed 2: FIFO violation with one writer, [1, 3, 4, 2, ...].
This fix pass 500 seeds pass.

What is the testing strategy for this PR?

  • The SQL reproducer runs 12 attempts. With the fix, 156 runs and 3000 instrumented attempts had 0 hangs. One attempt in 3000 got a resources-exhausted error from the final aggregate. That error is a correct result of the 4 MB limit and the greedy pool, and the test accepts it.
  • The fuzzer runs 50 seeds by default, in approximately three seconds. DATAFUSION_SPILL_POOL_FUZZ_ITERATIONS sets the count. DATAFUSION_SPILL_POOL_FUZZ_SEED replays one seed.
  • The 33 memory-limit tests, the 109 spill and repartition unit tests, and the spill pool doctests pass.
  • cargo fmt and cargo clippy --all-targets --all-features -- -D warnings pass on the two changed crates.

I also evaluated a second fix that keeps one open write file per pool. It passes the same tests. It serializes writers that spill at the same time on the file lock, which apache#23522 avoided on purpose. It is not part of this PR.

Are there any user-facing changes?

No.

The branch-54 line has the same bug, because apache#23654 backported the same file model. A backport of this fix is necessary.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RauD5PqPCp4y92RUZUD6C9


Generated by Claude Code

Add a test that runs the query from issue apache#24883 under a 4 MB memory
limit, with two partitions and 64-row batches. The query is a grouped
COUNT(DISTINCT) on a Utf8View column. It sends `RepartitionExec` into
its spill path.

On the current code, the first attempt does not complete. The test stops
the attempt after 20 seconds and fails. The test does 12 attempts. One
attempt that completes is not sufficient for a pass.

The test uses a memory limit that is too small for the query. This is
necessary to make `RepartitionExec` spill. Once in a few thousand
attempts, the greedy memory pool refuses an allocation of an aggregate,
because the repartition reservations hold the pool. The test accepts a
resources-exhausted error. That error is a correct result of the limit
and not the deadlock.

Ref: apache#24883

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RauD5PqPCp4y92RUZUD6C9
The spill pool had three bugs since its unbuffered reads: apache#20683, apache#23447
and apache#24883. In each bug, two writers were in `push_batch` at the same
time. No unit test could cause that overlap.

This commit adds a test double and two tests:

- `IoHookFactory` is a `TempFileFactory` that runs a hook before each
  disk write. A writer writes while it holds the lock of its file. Thus
  a hook that blocks can hold a writer at the point where it has a file
  checked out. The factory can also delay the reads of a file.
- `test_reader_does_not_wait_on_drained_file_while_another_has_data`
  holds writer 1 inside its first write while writer 2 pushes a batch.
  Then the reader must give both batches while both writers are alive.
- `spill_pool_scenario_fuzz` runs random scenarios from a seed: one to
  four writers, file rotation after each batch, after some batches or
  never, and one to six phases. In each phase, a random set of writers
  pushes one to three batches each. A pauser holds each writer of the
  phase inside its first write, so the pushes overlap. Then the reader
  reads a random number of the available batches. Some phases drop a
  writer.

Each scenario checks the contract that `RepartitionExec` depends on:

- When no push is in progress, the reader can read each pushed batch
  without more writer activity.
- The reader does not signal EOF while a writer is alive.
- After the last writer is dropped, the reader gives the backlog and
  then EOF.
- The reader gives exactly the pushed batches. With one writer, it gives
  them in push order.
- Disk usage is zero after the reader is dropped.

The two tests are red on this commit. The fuzzer also fails on these
versions of the pool:

- The code before apache#23522. Seed 4 shows the apache#23447 stall after the drop
  of all writers. Seeds 0 and 1 show the apache#24883 stall. All 16 pool tests
  of that time pass on that code.
- A draft of the fix in the next commit that skips a file with a read in
  progress. Seed 2 shows a FIFO violation with one writer.

The default of 50 seeds takes approximately three seconds. The extended
CI job runs 1000 seeds. `DATAFUSION_SPILL_POOL_FUZZ_SEED` replays one
seed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RauD5PqPCp4y92RUZUD6C9
In non-preserve-order mode, all input tasks of a `RepartitionExec` share
one spill pool per output partition. When two input tasks spilled at the
same time, the pool had two open spill files. The `SpillPoolReader` read
only the oldest file. When that file had no unread batch and was not
finished, the reader waited for it. But the batch that the reader needed
was in the newer file. When each distributor channel had data, the gate
closed. Then both input tasks stopped in `send`, no sink was dropped,
and nothing could wake the reader.

The reader now keeps all the files that it received, oldest first. It
gives the first batch that is available in one of them. It skips a file
that has no unread batch. It waits for the oldest file that has an
unread batch when the read of that file is not complete. Thus one writer
gets its batches in FIFO order.

This commit makes the tests from the two commits before it green. The
fuzzer passes 500 seeds.

Ref: apache#24883

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RauD5PqPCp4y92RUZUD6C9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RepartitionExec deadlocks under a memory limit when its spill pool holds two open files

2 participants