MisterRaindrop opened a new pull request, #26264:
URL: https://github.com/apache/datafusion/pull/26264

   ## Which issue does this PR close?
   
   - Part of #25804 (finding 9: "The final spill merge grows its reservation 
until the pool refuses").
   
   ## Rationale for this change
   
   A sort that has spilled finishes with a merge of its spilled runs. That merge
   seats as many runs as the memory pool will admit (the fan-in is unlimited by
   default), and it holds those buffers until its output is fully consumed.
   
   In a query with several partitions, partitions that finish their input early
   start this final merge while other partitions are still buffering input, and
   while the operator above the sort is still running. Each early merge takes
   whatever the pool admits, and the pool can be exhausted. The partition that 
is
   still sorting then fails to grow its reservation and the query fails with
   `ResourcesExhausted`, even though only memory for read-ahead buffers was
   taken.
   
   A standalone reproduction (below): 6.67M rows,
   `SUM(p) OVER (PARTITION BY g ORDER BY k ROWS UNBOUNDED PRECEDING)` followed 
by
   `MAX`, 10 target partitions, a `FairSpillPool` of 62.5 MB, 40 runs per
   setting:
   
   | `sort_spill_reservation_bytes` | main (2c568b47) | this PR |
   |---|---|---|
   | 1 MB | 25 / 40 succeed | 40 / 40 |
   | 1.6 MB | 34 / 40 | 40 / 40 |
   | 3 MB | 7 / 40 | 35 / 40 |
   
   The failures are the partitions that are still sorting:
   `Failed to allocate additional 3.0 MB for ExternalSorterMerge[6] with 0.0 B
   already allocated`, `... 598.8 KB for ExternalSorter[8] with 0.0 B ...`, or
   `... 144.1 KB for ExternalSorterMerge[7]` in `BatchBuilder::push_batch` while
   merging in-memory batches to spill. With a pool instrumented to record each
   consumer's reservation, five partitions in their final merge held 12-29 MB
   each when the refusal happened. Capping `max_spill_merge_fan_in` at 4 made
   the same runs succeed, which confirms that the merge fan-in causes the 
failures.
   
   ## What changes are included in this PR?
   
   #25383 lets aggregate spill replay leave memory for the aggregate: the merge
   asks the pool to admit its buffers plus as much again, then returns the 
extra.
   The sort uses a narrower form of that policy:
   
   - The minimum merge of two runs is admitted as before, against the pool
     alone. A sort under tight memory keeps its current behavior: it does not
     re-spill runs smaller or emit shorter batches only to leave headroom.
   - Each run beyond the minimum is seated only if the pool can admit what the
     merge takes beyond the workspace it already holds
     (`sort_spill_reservation_bytes`, retained since #24740), plus as much
     again. The extra is returned before the merge starts. Seating runs within
     the merge's own workspace needs no headroom, because it takes nothing
     from others.
   
   When the pool refuses, the merge seats fewer runs and merges in more passes,
   as it already does under memory pressure.
   
   The policy is a new `with_fan_in_headroom` option on `StreamingMergeBuilder`
   and `MultiLevelMergeBuilder`, enabled by `ExternalSorter`. The aggregate's
   replay headroom is unchanged.
   
   ### What this does not fix
   
   Each merge leaves as much free as it takes, but merges that start one after
   another still compound: in the remaining 3 MB failures, nine partitions held
   3-18 MB each (17.9, 11.5, 7.7, 5.1, ... MB), and the last partition to start
   could not reserve its own 3 MB `sort_spill_reservation_bytes`. With 10
   partitions, the workspaces alone are half of this pool. A guarantee would
   need each merge to know how many partitions share the pool, for example a
   per-partition share of the limit. I left that out of this PR, because it is
   a larger policy change and I would like to hear whether it is wanted.
   
   ## What is the testing strategy for this PR?
   
   - `test_final_spilled_merge_leaves_headroom` (sort/spill_tests.rs) spills a
     sort and leaves the pool room for the final merge to seat every run, but
     not to seat every run and still leave as much free. It checks that whatever
     the merge takes beyond its workspace, it leaves as much free, and that the
     output is correct and every reservation is released. It fails on main
     ("the final merge takes 168128 bytes beyond its workspace and leaves 109728
     free").
   - Existing tests pass unchanged: `datafusion-physical-plan` (2530),
     `datafusion-execution`, the `memory_limit` tests in `core_integration`, and
     the sort, merge, spilling and aggregate fuzz tests (`extended_tests`).
     In particular, the batch-size test under a tight limit
     (`..._when_having_to_spill`) and the workspace retention tests from #24740
     are unaffected.
   
   <details>
   <summary>Reproduction</summary>
   
   `Cargo.toml`:
   
   ```toml
   [package]
   name = "sortrepro"
   version = "0.1.0"
   edition = "2024"
   
   [dependencies]
   datafusion = { git = "https://github.com/apache/datafusion";, 
default-features = false }
   tokio = { version = "1", features = ["rt-multi-thread"] }
   futures = "0.3"
   ```
   
   `src/main.rs`, run as `sortrepro <sort_spill_reservation_bytes> <runs>`:
   
   ```rust
   //! A window over a spilling sort, 10 partitions, one FairSpillPool.
   //! Usage: sortrepro <sort_spill_reservation_bytes> <runs>
   use std::sync::Arc;
   
   use datafusion::arrow::array::{Decimal256Array, Int32Array, RecordBatch};
   use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef, i256};
   use datafusion::catalog::streaming::StreamingTable;
   use datafusion::common::ScalarValue;
   use datafusion::functions_aggregate::expr_fn::max;
   use datafusion::functions_aggregate::sum::sum_udaf;
   use datafusion::logical_expr::expr::WindowFunction;
   use datafusion::logical_expr::{
       Expr, ExprFunctionExt, WindowFrame, WindowFrameBound, WindowFrameUnits,
       WindowFunctionDefinition, col,
   };
   use datafusion::execution::memory_pool::FairSpillPool;
   use datafusion::execution::runtime_env::RuntimeEnvBuilder;
   use datafusion::execution::{SendableRecordBatchStream, TaskContext};
   use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
   use datafusion::physical_plan::streaming::PartitionStream;
   use datafusion::prelude::{SessionConfig, SessionContext};
   
   const ROWS: i32 = 6_666_815;
   const BATCH: i32 = 8192;
   const POOL: usize = 65_484_800; // 62.5 MB
   
   /// (g, k, p): 1000 groups of increasing k, with a decimal to sum.
   #[derive(Debug)]
   struct Gen(SchemaRef);
   
   impl PartitionStream for Gen {
       fn schema(&self) -> &SchemaRef {
           &self.0
       }
   
       fn execute(&self, _: Arc<TaskContext>) -> SendableRecordBatchStream {
           let schema = Arc::clone(&self.0);
           let s = Arc::clone(&schema);
           let batches = (0..(ROWS + BATCH - 1) / BATCH).map(move |b| {
               let (lo, hi) = (b * BATCH, ((b + 1) * BATCH).min(ROWS));
               let k: Vec<i32> = (lo..hi).map(|i| i * 3).collect();
               let g: Vec<i32> = k.iter().map(|k| k % 1000).collect();
               let p: Vec<i256> = k
                   .iter()
                   .map(|k| i256::from_i128((*k as i128 * 7919) % 10_000_000))
                   .collect();
               let p = Decimal256Array::from(p).with_precision_and_scale(76, 
2)?;
               Ok(RecordBatch::try_new(
                   Arc::clone(&s),
                   vec![
                       Arc::new(Int32Array::from(g)),
                       Arc::new(Int32Array::from(k)),
                       Arc::new(p),
                   ],
               )?)
           });
           Box::pin(RecordBatchStreamAdapter::new(
               schema,
               futures::stream::iter(batches),
           ))
       }
   }
   
   async fn run(sort_spill_reservation_bytes: usize) -> 
datafusion::error::Result<()> {
       let mut config = SessionConfig::new()
           .with_target_partitions(10)
           .with_batch_size(BATCH as usize);
       config.options_mut().execution.sort_spill_reservation_bytes =
           sort_spill_reservation_bytes;
       let runtime = RuntimeEnvBuilder::new()
           .with_memory_pool(Arc::new(FairSpillPool::new(POOL)))
           .build_arc()?;
       let ctx = SessionContext::new_with_config_rt(config, runtime);
       let schema = Arc::new(Schema::new(vec![
           Field::new("g", DataType::Int32, false),
           Field::new("k", DataType::Int32, false),
           Field::new("p", DataType::Decimal256(76, 2), false),
       ]));
       let table = StreamingTable::try_new(
           Arc::clone(&schema),
           vec![Arc::new(Gen(schema))],
       )?;
       // SELECT max(s) FROM (SELECT sum(p) OVER (PARTITION BY g ORDER BY k
       //   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS s FROM t)
       let s = Expr::from(WindowFunction::new(
           WindowFunctionDefinition::AggregateUDF(sum_udaf()),
           vec![col("p")],
       ))
       .partition_by(vec![col("g")])
       .order_by(vec![col("k").sort(true, false)])
       .window_frame(WindowFrame::new_bounds(
           WindowFrameUnits::Rows,
           WindowFrameBound::Preceding(ScalarValue::UInt64(None)),
           WindowFrameBound::CurrentRow,
       ))
       .build()?
       .alias("s");
       ctx.read_table(Arc::new(table))?
           .window(vec![s])?
           .aggregate(vec![], vec![max(col("s"))])?
           .collect()
           .await?;
       Ok(())
   }
   
   fn main() {
       let args: Vec<String> = std::env::args().collect();
       let reservation: usize = args[1].parse().unwrap();
       let runs: usize = args[2].parse().unwrap();
       let rt = tokio::runtime::Builder::new_multi_thread()
           .worker_threads(10)
           .enable_all()
           .build()
           .unwrap();
       let mut ok = 0;
       for _ in 0..runs {
           match rt.block_on(run(reservation)) {
               Ok(()) => ok += 1,
               Err(e) => println!("{e}"),
           }
       }
       println!("sort_spill_reservation_bytes={reservation}: {ok} / {runs} 
succeeded");
   }
   ```
   
   </details>
   
   ## Are there any user-facing changes?
   
   No API changes. A sort that spills to many runs may merge in more passes when
   the pool is shared, instead of failing other consumers of the pool.
   


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