adriangb opened a new issue, #24883:
URL: https://github.com/apache/datafusion/issues/24883

   
   ### Describe the bug
   
   Under a memory limit, a grouped aggregation running on two or more 
partitions can
   deadlock permanently. Every tokio worker parks, CPU drops to 0%, and the 
query neither
   returns nor errors. It reproduces from a stock `datafusion-cli` session with 
three `SET`
   statements and no data files.
   
   ### To Reproduce
   
   Build and run `datafusion-cli` from a checkout, then paste the block below:
   
   ```console
   $ cargo build -p datafusion-cli
   $ ./target/debug/datafusion-cli -f deadlock.sql
   ```
   
   `deadlock.sql`:
   
   ```sql
   set datafusion.execution.target_partitions = 2;
   set datafusion.execution.batch_size = 64;
   
   create table t as
   select v % 13 as g, md5(cast(v % 337 as varchar)) as trace_id
   from generate_series(1, 40000) as s(v);
   
   set datafusion.runtime.memory_limit = '2M';
   
   -- Each of these takes about 0.1s when it completes. One of them never 
returns.
   select g, count(distinct trace_id) as n from t group by g;
   select g, count(distinct trace_id) as n from t group by g;
   select g, count(distinct trace_id) as n from t group by g;
   select g, count(distinct trace_id) as n from t group by g;
   select g, count(distinct trace_id) as n from t group by g;
   ```
   
   No CLI flags are needed. `datafusion-cli` already gives you a multi-threaded 
tokio
   runtime, and its default `RuntimeEnv` has the disk manager enabled
   (`DiskManagerMode::OsTmpDirectory`), so `SET` covers the rest.
   
   **Reproduction rate: 20 of 20 runs** on an unpatched tree (each run capped 
at 25s;
   `apache/main` at `ad5820c84ed00d31125842e62a5124303a39c8d2`, macOS arm64, 12 
cores,
   `cargo build -p datafusion-cli`). It also hangs 10 of 10 on an optimized 
build
   (`cargo build --profile release-nonlto -p datafusion-cli`).
   
   **Time to hang: 0.11s to 2.46s from process start, mean 0.29s**, so a few 
seconds of no
   output is enough to conclude it is stuck. The wedged session has printed 
only the
   `0 row(s) fetched` lines for the `SET` statements and the `CREATE TABLE`, or 
those plus a
   few completed `SELECT`s, and then nothing.
   
   A single `SELECT` hangs 10 of 12 runs. The statement is repeated five times 
only to make
   one paste reliable; the first `SELECT` is usually the one that wedges.
   
   **Negative control, same session, same memory limit:**
   
   ```sql
   set datafusion.execution.target_partitions = 1;   -- instead of 2
   ```
   
   0 hangs in 10 runs, and all five `SELECT`s return the correct 13 rows every 
time, in
   about 0.25s for the whole script.
   
   The memory limit is not delicate. With the block above, `256K`, `512K`, 
`1M`, `2M`, `3M`
   and `4M` all hang 6 of 6; `8M` hangs 2 of 6.
   
   Note that `datafusion.execution.target_partitions` must be set explicitly. 
Left at the
   default (one per core, 12 on this machine) the same query needs more memory 
before
   `RepartitionExec` reaches its spill path, and `2M` does not hang.
   
   ### Where the two sides are parked
   
   `lldb -p <pid> -o "thread backtrace all"` on the wedged process is not 
enough on its own,
   and it is worth saying why: both deadlocked participants are **suspended 
async tasks**,
   not blocked threads. Their `poll_next` returned `Poll::Pending` and the 
stack unwound, so
   the state machines sit on the heap inside their tokio tasks and appear on no 
thread stack.
   The thread dump shows only the scheduler parking (details below).
   
   Everything below comes from one wedged process of a plain debug build
   (`cargo build -p datafusion-cli`). Frames resolved fully without
   `-C force-frame-pointers` or a custom profile.
   
   The frames that matter are the ones at the moment each side last returned 
`Pending`.
   Capturing `std::backtrace::Backtrace::force_capture()` at the two park 
sites, with a
   global sequence number, gives the last four events of the query and then 
silence:
   
   ```
   seq=331  SpillPoolReader parked  pool=0x...61e90  files_len=2 
open_write_len=2 writers=2
                                    head_file=0x...50c50 batches_read=44 
batches_written=44 writer_finished=false
   seq=332  SpillPoolReader parked  pool=0x...61d90  files_len=2 
open_write_len=2 writers=2
                                    head_file=0x...70c50 batches_read=29 
batches_written=29 writer_finished=false
   seq=333  DistributionSender parked on the distributor gate  output_channel=0
   seq=334  DistributionSender parked on the distributor gate  output_channel=1
   <nothing further, 0% CPU>
   ```
   
   Both readers park on a head file they have fully drained (`batches_read == 
batches_written`)
   that is not finished, while their pool holds two files and two open write 
files. Then both
   producers park on the gate. The two stacks, DataFusion frames only:
   
   ```
   consumer (parked, seq=332)                         producer (parked, seq=333 
and 334)
     SpillPoolReader::poll_next                         DistributionSender::poll
       spill_pool.rs:744  Poll::Pending                   
distributor_channels.rs:224  Poll::Pending
     PerPartitionStream::poll_next_inner                OutputChannel::send
       repartition/mod.rs:2434  ReadingSpilled             
repartition/mod.rs:239  sender.send(..).await
     ...                                                
RepartitionExec::pull_from_input
     RepartitionExec::pull_from_input                     
repartition/mod.rs:2179  output_channel.send(batch).await
       repartition/mod.rs:2158  stream.next().await
   ```
   
   Same `RepartitionExec` on both sides: its `PerPartitionStream` is blocked in
   `StreamState::ReadingSpilled` on its own `SpillPoolReader`, and its input 
tasks are blocked
   in `OutputChannel::send` on its own distributor gate.
   
   <details>
   <summary>Full annotated stacks, and the lldb thread dump</summary>
   
   The physical plan for the MRE, for orientation (bottom up):
   
   ```
   DataSourceExec (memory, 40000 rows)
     AggregateExec Partial            group_by: g, trace_id as alias1
       RepartitionExec Hash([g, alias1], 2)      <-- this one deadlocks
         AggregateExec FinalPartitioned  group_by: g, alias1
           AggregateExec Partial           group_by: g, aggr: count(alias1)
             RepartitionExec Hash([g], 2)
               AggregateExec FinalPartitioned
                 ProjectionExec
   ```
   
   **Consumer side.** Line numbers are from the unpatched tree; the capture 
itself reported
   `spill_pool.rs:770` because the instrumentation added lines above it.
   
   ```
    3  SpillPoolReader::poll_next          spill/spill_pool.rs:744
           the `Poll::Pending` arm: head file drained, writer not finished, 
waker registered
    6  PerPartitionStream::poll_next_inner repartition/mod.rs:2434
           StreamState::ReadingSpilled, polling self.spill_stream, blocks to 
preserve ordering
    7  PerPartitionStream::poll_next       repartition/mod.rs:2469
   11  RecordBatchStreamAdapter::poll_next stream.rs:469
   14  GroupedHashAggregateStream::handle_reading_input   
aggregates/hash_stream.rs:1139
   15  GroupedHashAggregateStream::poll_next              
aggregates/hash_stream.rs:1511
           AggregateExec FinalPartitioned (group_by: g, alias1), pulling the 
repartition output
   18  GroupedHashAggregateStream::handle_reading_input   
aggregates/hash_stream.rs:565
   19  GroupedHashAggregateStream::poll_next              
aggregates/hash_stream.rs:956
           AggregateExec Partial (aggr: count(alias1))
   23  RepartitionExec::pull_from_input    repartition/mod.rs:2158
           `stream.next().await` in the *upper* RepartitionExec's input task
   24  spawned task body                   common-runtime/src/trace_utils.rs:137
   ```
   
   10 DataFusion frames of 86; the other 76 are `futures`, `std` and tokio 
task-harness
   boilerplate. Both of the query's two spill-pool readers produced this 
identical signature.
   
   **Producer side.**
   
   ```
    3  DistributionSender::poll            
repartition/distributor_channels.rs:224
           the `empty_channels == 0` gate: every output channel is non-empty, 
so senders park
    4  OutputChannel::send                 repartition/mod.rs:239
           `self.sender.send(Some(payload)).await`, after `push_batch` spilled 
the batch
    5  RepartitionExec::pull_from_input    repartition/mod.rs:2179
           `output_channel.send(batch).await`
    6  spawned task body                   common-runtime/src/trace_utils.rs:137
   ```
   
   4 DataFusion frames of 68. All 8 sender captures from this run share this 
one signature.
   
   **lldb thread dump of the same wedged process.** 15 threads, 4 distinct 
stacks, and
   **zero DataFusion frames anywhere in the dump**:
   
   | threads | stack | leaf |
   | --- | --- | --- |
   | 11 `tokio-rt-worker` | `worker::run` -> `park_internal` (worker.rs:887) | 
`park_condvar` -> `__psynch_cvwait` |
   | 1 `tokio-rt-worker` | same, holding the I/O driver | `kevent` |
   | 2 `tokio-rt-worker` (blocking pool) | `blocking::pool::run` -> 
`wait_timeout` (pool.rs:529), idle | `__psynch_cvwait` |
   | 1 `main` | `datafusion_cli::main` (main.rs:196) -> `Runtime::block_on` -> 
`park` | `__psynch_cvwait` |
   
   ```
   * thread #1, name = 'main'
       frame  #0: __psynch_cvwait
       frame  #9: park at park.rs:111
       frame #15: block_on<...datafusion_cli::main::{async_block_env#0}...> at 
park.rs:288
       frame #22: datafusion-cli`main at main.rs:196:15
   
     thread #2, name = 'tokio-rt-worker'          (and 10 more identical)
       frame  #0: __psynch_cvwait
       frame  #9: park_condvar at park.rs:203
       frame #12: park_internal at worker.rs:887
       frame #14: run at worker.rs:614
   ```
   
   Nothing is spinning and nothing is waiting on I/O, so this is a deadlock 
rather than a
   slow query. But no thread stack can name the DataFusion code that is stuck, 
which is why
   the `Pending`-site captures above are the useful artifact.
   
   `lsof` on the same process independently corroborates the pool state: 
several spill files
   open under the OS temp directory, some carrying two write handles and a read 
handle on the
   same file.
   
   </details>
   
   ### Root cause
   
   The deadlock is in `RepartitionExec`'s spill path.
   
   When `RepartitionExec` cannot reserve memory for an output batch it writes 
the batch to
   the output partition's `SpillPool` and sends a `RepartitionBatch::Spilled` 
marker down
   the distributor channel instead of the batch (`OutputChannel::send`,
   `datafusion/physical-plan/src/repartition/mod.rs`). On the read side a 
`Spilled` marker
   moves the output stream into `StreamState::ReadingSpilled`, where it 
*blocks* on the
   spill stream to preserve ordering:
   
   ```rust
   Poll::Pending => {
       // Spilled batch not ready yet, must wait
       // This preserves ordering by blocking until spill data arrives
       return Poll::Pending;
   }
   ```
   
   In non-`preserve_order` mode all input tasks share one pool per output 
partition via
   `SpillPoolWriter::new_sink` (`PartitionSpillWriters::Shared`,
   `repartition/mod.rs:206`). `SpillPoolSink::push_batch` pops the front of
   `SpillPoolShared::open_write_files`, releases the pool lock for the file 
I/O, and pushes
   the file back afterwards (`spill/spill_pool.rs:216-283`). Two input tasks 
that overlap
   inside that window leave the pool with **two open spill files**: the second 
finds the
   deque empty and creates a new file. This multiple-open-file model was 
introduced
   deliberately in #23522.
   
   `SpillPoolReader` however drains `SpillPoolShared::files` strictly head 
first and only
   advances past the head when that file yields `Ready(None)`, which requires
   `writer_finished == true` on the head (`spill_pool.rs:715-760`). Below
   `max_spill_file_size_bytes` (128 MB by default) nothing rotates, so a file 
is finished
   only when the *last* sink is dropped (`SpillPoolSink::drop`, 
`spill_pool.rs:143-176`).
   
   Batches written to the second open file are therefore unreachable while the 
first is
   still open, which closes this cycle:
   
   1. An output partition's reader pops a `Spilled` marker whose batch went 
into the second
      open file. Its head file has `batches_read == batches_written` and
      `writer_finished == false`, so the reader parks and stops draining that 
partition's
      memory channel.
   2. Every distributor channel ends up non-empty, so the global gate in
      `distributor_channels.rs` closes (`empty_channels == 0`, line 220).
   3. Both input tasks park in `DistributionSender::send`. No sink is dropped 
and no further
      batch is written to the head file.
   4. Nothing can wake anybody.
   
   #23522 fixed the drop-time variant of this (finish every open file when the 
last sink is
   dropped, closing #23447). The steady-state variant above is not covered, 
because the
   writers never reach their drop: they are blocked on the gate that only the 
parked reader
   could open.
   
   A separate instrumented run, viewed chronologically, shows the second open 
file being
   created while the first is still open, and then the gate closing on both 
channels:
   
   ```
   DBG new_spill_file pool=0x20000d10510 file=0x200081a0c50 files_len=2 
open_write_len=1
   DBG send_gate_closed chan=1
   DBG file_pending file=0x20008030710 read=12 written=15 finished=false
   DBG   pool=0x20000d10410 files_len=2 open_write_len=2 writers=2
   DBG send_gate_closed chan=0
   DBG file_pending file=0x200080301d0 read=18 written=18 finished=false
   DBG   pool=0x20000d10510 files_len=2 open_write_len=2 writers=2
   DBG send_gate_closed chan=0
   DBG file_pending file=0x20008030710 read=16 written=16 finished=false
   DBG   pool=0x20000d10410 files_len=2 open_write_len=2 writers=2
   <nothing further, 0% CPU>
   ```
   
   Both output-partition pools end with `files_len=2 open_write_len=2`, both 
readers park on
   a head file whose `read == written` and `finished=false`, and both producers 
park on the
   distributor gate, reproducing the same end state as the run whose stacks are 
shown
   above.
   
   ### Ingredients that matter
   
   Each verified by turning it off, all with the SQL block above unless stated:
   
   - **More than one tokio worker thread.** `datafusion-cli` supplies this. In 
the Rust
     reproducer below, a `current_thread` runtime (plain `#[tokio::test]`) 
passes every time
     (3 runs x 12 attempts), and so does `worker_threads = 1` (3 runs). With
     `worker_threads = 2` or the `multi_thread` default it deadlocks on the 
first attempt
     every time (8 runs). `push_batch` has no await point, so on a single 
worker the two
     input tasks can never overlap inside it and a second open file is never 
created.
   - **`target_partitions >= 2`.** `set datafusion.execution.target_partitions 
= 1`: 0 hangs
     in 10 runs from the CLI, and 0 hangs in 120 standalone Rust runs across 6 
memory limits
     and 5 column types.
   - **A memory limit low enough for `RepartitionExec` to reach its spill path, 
plus a disk
     manager that permits spilling.** With `DiskManagerMode::Disabled` the 
query fails with
     a resource error instead: 0 hangs in 180 Rust runs across the whole risky 
band.
   - **A spill file that does not rotate.**
     `set datafusion.execution.max_spill_file_size_bytes = 16384` removes the 
hang entirely
     (0 hangs in 10 CLI runs, 0 in 120 Rust runs in the worst cells), because 
the head file
     is finished on rotation and the reader can advance.
   - **A small `batch_size`.** Dropping `set datafusion.execution.batch_size = 
64` and using
     the default 8192 removes the hang: fewer, larger batches, and the query no 
longer
     reaches the repartition spill path at this limit.
   - `set datafusion.optimizer.repartition_aggregations = false` also removes 
it (0 of 10),
     while `set datafusion.optimizer.enable_round_robin_repartition = false` 
does not
     (9 of 10 still hang), so it is the hash `RepartitionExec` between the 
partial and final
     aggregate.
   
   The column type is **not** special. `Utf8View` and `BinaryView` hang at a 4 
MB limit,
   plain `Utf8` (used above) hangs from 256 KB to 8 MB. The type only decides 
how much
   accounted memory the query needs and therefore whether the repartition spill 
path is
   reached at a given limit. `COUNT(DISTINCT ...)` is likewise incidental; it 
is a
   convenient way to build enough aggregate state to reach that path at a small 
limit.
   
   ### Expected behavior
   
   The query completes, or fails with a resource error. It should not park 
forever.
   
   ### Rust reproducer
   
   Equivalent regression test, if a test in the tree is preferred over the SQL 
above. It
   needs a multi-threaded runtime and fails on the first attempt on an unfixed 
tree.
   
   <details>
   <summary>tests/repartition_spill_deadlock.rs</summary>
   
   ```rust
   use std::sync::Arc;
   use std::time::Duration;
   
   use datafusion::execution::runtime_env::RuntimeEnvBuilder;
   use datafusion::execution::session_state::SessionStateBuilder;
   use datafusion::prelude::{SessionConfig, SessionContext};
   use datafusion_execution::disk_manager::{DiskManagerBuilder, 
DiskManagerMode};
   use datafusion_execution::memory_pool::{GreedyMemoryPool, MemoryPool};
   
   /// Memory limit that puts `RepartitionExec` into its spilling path for this
   /// query without failing the aggregation outright.
   const MEMORY_LIMIT: usize = 4 * 1024 * 1024;
   
   /// Number of attempts. On an unfixed tree the first attempt deadlocks; the
   /// budget is only there so a fix cannot pass by luck.
   const ATTEMPTS: usize = 12;
   
   /// Generous per-attempt budget: a healthy run of this query takes well under
   /// a second.
   const PER_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(20);
   
   async fn run_once() -> datafusion::error::Result<usize> {
       let pool: Arc<dyn MemoryPool> = 
Arc::new(GreedyMemoryPool::new(MEMORY_LIMIT));
       let runtime = RuntimeEnvBuilder::new()
           // Spilling must be possible: with the disk manager disabled the 
query
           // fails with a resource error instead of deadlocking.
           .with_disk_manager_builder(
               
DiskManagerBuilder::default().with_mode(DiskManagerMode::OsTmpDirectory),
           )
           .with_memory_pool(pool)
           .build_arc()?;
   
       let config = SessionConfig::new()
           // Two input partitions feeding one hash RepartitionExec is the
           // smallest configuration with two concurrent writers per spill pool.
           .with_target_partitions(2)
           // Small batches so many small batches reach the repartition spill
           // path while the spill file stays far below
           // `max_spill_file_size_bytes` and so never rotates.
           .with_batch_size(64);
   
       let state = SessionStateBuilder::new()
           .with_config(config)
           .with_runtime_env(runtime)
           .with_default_features()
           .build();
       let ctx = SessionContext::new_with_state(state);
   
       ctx.sql(
           "create table trace_events as
            select v % 13 as g,
                   case when v % 29 = 0 then null
                        else md5(cast(v % 337 as varchar)) end as trace_id
            from generate_series(1, 40000) as t(v)",
       )
       .await?
       .collect()
       .await?;
   
       ctx.sql(
           "create view tv as
            select g, arrow_cast(trace_id, 'Utf8View') as trace_id from 
trace_events",
       )
       .await?
       .collect()
       .await?;
   
       let batches = ctx
           .sql("select g, count(distinct trace_id) as n from tv group by g")
           .await?
           .collect()
           .await?;
   
       Ok(batches.iter().map(|b| b.num_rows()).sum())
   }
   
   #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
   async fn repartition_spill_pool_does_not_deadlock() {
       for attempt in 0..ATTEMPTS {
           match tokio::time::timeout(PER_ATTEMPT_TIMEOUT, run_once()).await {
               Err(_) => panic!(
                   "attempt {attempt}: grouped COUNT(DISTINCT Utf8View) never 
completed \
                    within {PER_ATTEMPT_TIMEOUT:?}; RepartitionExec spill pool 
deadlock"
               ),
               Ok(Ok(rows)) => assert_eq!(rows, 13, "attempt {attempt}: wrong 
row count"),
               Ok(Err(e)) => panic!("attempt {attempt}: query failed: {e}"),
           }
       }
   }
   ```
   
   </details>
   
   ### Additional context
   
   - Reproduced on `apache/main` at `ad5820c84ed00d31125842e62a5124303a39c8d2`
     (workspace version 55.0.0), macOS arm64, Rust 1.97.0 per 
`rust-toolchain.toml`.
   - Related: #23447 and its fix #23522, which introduced the 
multiple-open-file model and
     fixed the drop-time lost wakeup. The case above is the steady-state one 
that a
     drop-time fix cannot reach.
   - Two directions a fix could take, for discussion: let `SpillPoolReader` 
read from any
     open file in the queue rather than only the head, or make `push_batch` 
keep a single
     open file per pool so the head is always the file being written.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to