Skip to content

Commit 33f4ab2

Browse files
committed
fix: respect async spill memory budgets
Async spill writes must retain their input budget without bypassing pool limits. Reuse reserved workspace and release consumed merge inputs so spilling can progress within its budget. Refs #23247
1 parent 7b7a396 commit 33f4ab2

3 files changed

Lines changed: 120 additions & 74 deletions

File tree

datafusion/physical-plan/src/sorts/builder.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,15 @@ impl BatchBuilder {
168168
// is finished. This means all remaining rows from all but the last batch
169169
// for each stream have been yielded to the newly created record batch
170170
//
171-
// We can therefore drop all but the last batch for each stream
171+
// Keep the last batch only if its cursor still has unread rows.
172+
// Fully-consumed batches must release their reservation before the
173+
// output consumer reserves memory for the newly built batch.
172174
let mut batch_idx = 0;
173175
let mut retained = 0;
174176
self.batches.retain(|(stream_idx, batch)| {
175177
let stream_cursor = &mut self.cursors[*stream_idx];
176-
let retain = stream_cursor.batch_idx == batch_idx;
178+
let retain = stream_cursor.batch_idx == batch_idx
179+
&& stream_cursor.row_idx < batch.num_rows();
177180
batch_idx += 1;
178181

179182
if retain {
@@ -341,6 +344,29 @@ mod tests {
341344
assert!(matches!(error, DataFusionError::Execution(msg) if msg == "boom"));
342345
}
343346

347+
#[test]
348+
fn test_releases_fully_consumed_last_batch() -> Result<()> {
349+
let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int32, false)]));
350+
let pool: Arc<dyn MemoryPool> = Arc::new(UnboundedMemoryPool::default());
351+
let reservation = MemoryConsumer::new("test").register(&pool);
352+
let mut builder = BatchBuilder::new(Arc::clone(&schema), 1, 1, reservation);
353+
for values in [[1, 2], [3, 4]] {
354+
let batch = RecordBatch::try_new(
355+
Arc::clone(&schema),
356+
vec![Arc::new(Int32Array::from(values.to_vec()))],
357+
)?;
358+
let size = get_record_batch_memory_size(&batch);
359+
builder.push_batch(0, batch)?;
360+
builder.push_row(0);
361+
assert_eq!(builder.build_record_batch()?.unwrap().num_rows(), 1);
362+
assert_eq!(pool.reserved(), size, "unread rows must remain reserved");
363+
builder.push_row(0);
364+
assert_eq!(builder.build_record_batch()?.unwrap().num_rows(), 1);
365+
assert_eq!(pool.reserved(), 0, "consumed batches must release memory");
366+
}
367+
Ok(())
368+
}
369+
344370
#[test]
345371
fn test_try_interleave_columns_surfaces_arrow_offset_overflow() {
346372
let batch = overflow_list_batch();

datafusion/physical-plan/src/sorts/sort.rs

Lines changed: 89 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -419,16 +419,8 @@ impl ExternalSorter {
419419
self.metrics.spill_metrics.spill_file_count.value()
420420
}
421421

422-
/// Appending globally sorted batches to the in-progress spill file, and clears
423-
/// the `globally_sorted_batches` (also its memory reservation) afterwards.
424-
async fn consume_and_spill_append(
425-
&mut self,
426-
globally_sorted_batches: &mut Vec<RecordBatch>,
427-
) -> Result<()> {
428-
if globally_sorted_batches.is_empty() {
429-
return Ok(());
430-
}
431-
422+
/// Appends a globally sorted batch, retaining its reservation until written.
423+
async fn consume_and_spill_append(&mut self, batch: RecordBatch) -> Result<()> {
432424
// Lazily initialize the in-progress spill file
433425
if self.in_progress_spill_file.is_none() {
434426
self.in_progress_spill_file =
@@ -437,8 +429,7 @@ impl ExternalSorter {
437429

438430
debug!("Spilling sort data of ExternalSorter to disk whilst inserting");
439431

440-
let batches_to_spill = std::mem::take(globally_sorted_batches);
441-
// Keep the reservation alive while the batches remain in memory across
432+
// Keep the reservation alive while the batch remains in memory across
442433
// asynchronous writes. It is released on success or error via RAII.
443434
let _spill_reservation = self.reservation.take();
444435

@@ -447,16 +438,8 @@ impl ExternalSorter {
447438
internal_datafusion_err!("In-progress spill file should be initialized")
448439
})?;
449440

450-
for batch in batches_to_spill {
451-
let gc_sliced_size = in_progress_file.append_batch_async(&batch).await?;
452-
453-
*max_record_batch_size = (*max_record_batch_size).max(gc_sliced_size);
454-
}
455-
456-
assert_or_internal_err!(
457-
globally_sorted_batches.is_empty(),
458-
"This function consumes globally_sorted_batches, so it should be empty after taking."
459-
);
441+
let gc_sliced_size = in_progress_file.append_batch_async(&batch).await?;
442+
*max_record_batch_size = (*max_record_batch_size).max(gc_sliced_size);
460443

461444
Ok(())
462445
}
@@ -520,49 +503,35 @@ impl ExternalSorter {
520503
self.in_mem_batches.is_empty(),
521504
"in_mem_batches should be empty after constructing sorted stream"
522505
);
523-
// 'global' here refers to all buffered batches when the memory limit is
524-
// reached. This variable will buffer the sorted batches after
525-
// sort-preserving merge and incrementally append to spill files.
526-
let mut globally_sorted_batches: Vec<RecordBatch> = vec![];
527-
528506
while let Some(batch) = sorted_stream.next().await {
529507
let batch = batch?;
530-
let sorted_size = get_reserved_bytes_for_record_batch(&batch)?;
531-
let reservation_failed = match self.reservation.try_grow(sorted_size) {
532-
Ok(()) => false,
508+
// Sorting is complete: retain the batch's footprint, not the input
509+
// estimate that also budgets for creating a sorted copy.
510+
let sorted_size = get_record_batch_memory_size(&batch);
511+
let spill_workspace = match self.reservation.try_grow(sorted_size) {
512+
Ok(()) => None,
533513
Err(_) => {
534-
// The batch is already materialized, so account for it while
535-
// spilling even if this temporarily exceeds the pool limit.
536-
self.reservation.grow(sorted_size);
537-
true
514+
// Reuse already-reserved workspace without bypassing the
515+
// execution pool's limit. Any remainder still needs a grant
516+
// through the original sort consumer.
517+
let workspace = self.merge_pool.borrow(sorted_size);
518+
self.reservation.try_grow(sorted_size - workspace.size())?;
519+
Some(workspace)
538520
}
539521
};
540-
// Even if the reservation is not enough, the batch is already in
541-
// memory, so it's okay to combine it with previously sorted
542-
// batches, and spill together.
543-
globally_sorted_batches.push(batch);
544-
if reservation_failed {
545-
self.consume_and_spill_append(&mut globally_sorted_batches)
546-
.await?; // reservation is released when the spill completes
547-
}
522+
// Write each batch before polling the merge again. Accumulating
523+
// output would compete with the merge's workspace without combining
524+
// any writes. Keep both forms of reservation alive across the await.
525+
self.consume_and_spill_append(batch).await?;
526+
drop(spill_workspace);
548527
}
549528

550529
// Drop early to free up memory reserved by the sorted stream, otherwise the
551530
// upcoming `self.reserve_memory_for_merge()` may fail due to insufficient memory.
552531
drop(sorted_stream);
553532

554-
self.consume_and_spill_append(&mut globally_sorted_batches)
555-
.await?;
556533
self.spill_finish().await?;
557534

558-
// Sanity check after spilling
559-
let buffers_cleared_property =
560-
self.in_mem_batches.is_empty() && globally_sorted_batches.is_empty();
561-
assert_or_internal_err!(
562-
buffers_cleared_property,
563-
"in_mem_batches and globally_sorted_batches should be cleared before"
564-
);
565-
566535
// Reserve headroom for next sort/merge
567536
self.reserve_memory_for_merge()?;
568537

@@ -3386,6 +3355,32 @@ mod tests {
33863355
Ok(())
33873356
}
33883357

3358+
#[tokio::test]
3359+
async fn test_spill_output_respects_memory_limit() -> Result<()> {
3360+
let result = test_sort_output_batch_size_and_base_metrics(10, 25, |batches| {
3361+
let batches_memory = batches.iter().map(|b| b.get_array_memory_size()).sum();
3362+
TaskContext::default()
3363+
.with_session_config(
3364+
SessionConfig::new()
3365+
.with_batch_size(100)
3366+
.with_sort_in_place_threshold_bytes(1)
3367+
.with_sort_spill_reservation_bytes(1),
3368+
)
3369+
.with_runtime(
3370+
RuntimeEnvBuilder::default()
3371+
.with_memory_limit(batches_memory, 1.0)
3372+
.build_arc()
3373+
.unwrap(),
3374+
)
3375+
})
3376+
.await;
3377+
assert!(matches!(
3378+
result,
3379+
Err(DataFusionError::ResourcesExhausted(_))
3380+
));
3381+
Ok(())
3382+
}
3383+
33893384
#[tokio::test]
33903385
async fn should_return_stream_with_batches_in_the_requested_size_and_update_metrics_when_having_to_spill()
33913386
-> Result<()> {
@@ -3396,18 +3391,23 @@ mod tests {
33963391
.iter()
33973392
.map(|b| b.get_array_memory_size())
33983393
.sum::<usize>();
3394+
// Leave space for one output batch while the merge still holds
3395+
// partially consumed input batches. The insufficient-budget case
3396+
// is covered by test_spill_output_respects_memory_limit.
3397+
let spill_workspace =
3398+
make_partition(batch_size as i32).get_array_memory_size();
33993399

34003400
TaskContext::default()
34013401
.with_session_config(
34023402
SessionConfig::new()
34033403
.with_batch_size(batch_size)
34043404
// To make sure there is no in place sorting
34053405
.with_sort_in_place_threshold_bytes(1)
3406-
.with_sort_spill_reservation_bytes(1),
3406+
.with_sort_spill_reservation_bytes(spill_workspace),
34073407
)
34083408
.with_runtime(
34093409
RuntimeEnvBuilder::default()
3410-
.with_memory_limit(batches_memory, 1.0)
3410+
.with_memory_limit(batches_memory + spill_workspace, 1.0)
34113411
.build_arc()
34123412
.unwrap(),
34133413
)
@@ -3957,7 +3957,27 @@ mod tests {
39573957

39583958
#[tokio::test]
39593959
async fn test_spill_reservation_held_during_async_write() -> Result<()> {
3960-
let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(0));
3960+
let schema = Arc::new(Schema::new(vec![Field::new(
3961+
"x",
3962+
DataType::Utf8View,
3963+
false,
3964+
)]));
3965+
let batch = RecordBatch::try_new(
3966+
Arc::clone(&schema),
3967+
vec![Arc::new(StringViewArray::from_iter_values(
3968+
(0..4096).rev().map(|i| format!("{i:08}{}", "x".repeat(87))),
3969+
))],
3970+
)?;
3971+
let ordering =
3972+
[PhysicalSortExpr::new_default(Arc::new(Column::new("x", 0)))].into();
3973+
let batch_size = 1024;
3974+
let sorted_bytes = sort_batch_chunked(&batch, &ordering, batch_size)?
3975+
.iter()
3976+
.map(get_record_batch_memory_size)
3977+
.sum::<usize>();
3978+
let workspace_bytes = sorted_bytes - get_reserved_bytes_for_record_batch(&batch)?;
3979+
assert!(workspace_bytes > 0);
3980+
let pool = spill_tests::AdjustablePool::new(sorted_bytes);
39613981
let write_started = Arc::new(Notify::new());
39623982
let aborted = Arc::new(Notify::new());
39633983
let abort_count = Arc::new(AtomicUsize::new(0));
@@ -3969,29 +3989,26 @@ mod tests {
39693989
}),
39703990
);
39713991
let runtime = RuntimeEnvBuilder::new()
3972-
.with_memory_pool(Arc::clone(&pool))
3992+
.with_memory_pool(Arc::clone(&pool) as Arc<dyn MemoryPool>)
39733993
.with_disk_manager_builder(disk_manager_builder)
39743994
.build_arc()?;
3975-
let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int32, false)]));
39763995
let metrics = ExecutionPlanMetricsSet::new();
39773996
let mut sorter = ExternalSorter::new(
39783997
0,
3979-
Arc::clone(&schema),
3980-
[PhysicalSortExpr::new_default(Arc::new(Column::new("x", 0)))].into(),
3981-
128,
3998+
schema,
3999+
ordering,
4000+
batch_size,
4001+
workspace_bytes,
39824002
0,
3983-
usize::MAX,
39844003
SpillCompression::Uncompressed,
39854004
&metrics,
39864005
runtime,
39874006
)?;
3988-
let batch = RecordBatch::try_new(
3989-
schema,
3990-
vec![Arc::new(Int32Array::from(vec![3, 2, 1]))],
3991-
)?;
3992-
let reserved_bytes = get_reserved_bytes_for_record_batch(&batch)?;
3993-
sorter.reservation.grow(reserved_bytes);
3994-
sorter.in_mem_batches.push(batch);
4007+
sorter.insert_batch(batch).await?;
4008+
// Existing reservations remain valid, but the emitted batch must reuse
4009+
// workspace rather than request new parent capacity.
4010+
pool.set_limit(sorted_bytes - 1);
4011+
let merge_pool = Arc::clone(&sorter.merge_pool);
39954012

39964013
#[expect(clippy::disallowed_methods)] // spawn allowed only in tests
39974014
let task =
@@ -4008,9 +4025,12 @@ mod tests {
40084025
let _ = task.await;
40094026
panic!("spill write did not start before the timeout");
40104027
}
4028+
// Idle workspace must be releasable without releasing the loan held by
4029+
// the pending write. The stream still owns the remaining sorted batches.
4030+
merge_pool.release_unused();
40114031
assert_eq!(
40124032
pool.reserved(),
4013-
reserved_bytes,
4033+
sorted_bytes,
40144034
"resident batches must remain accounted for while an async spill is pending"
40154035
);
40164036

datafusion/physical-plan/src/sorts/sort/spill_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ struct AllocationState {
5757
/// Existing allocations survive a lower limit, but fresh allocations must fit.
5858
/// The test changes the limit before insertion, independently of spill internals.
5959
#[derive(Debug)]
60-
struct AdjustablePool {
60+
pub(super) struct AdjustablePool {
6161
capacity: usize,
6262
state: Mutex<AllocationState>,
6363
}
6464

6565
impl AdjustablePool {
66-
fn new(capacity: usize) -> Arc<Self> {
66+
pub(super) fn new(capacity: usize) -> Arc<Self> {
6767
Arc::new(Self {
6868
capacity,
6969
state: Mutex::new(AllocationState {
@@ -73,7 +73,7 @@ impl AdjustablePool {
7373
})
7474
}
7575

76-
fn set_limit(&self, limit: usize) {
76+
pub(super) fn set_limit(&self, limit: usize) {
7777
assert!(limit <= self.capacity);
7878
self.state.lock().unwrap().limit = limit;
7979
}

0 commit comments

Comments
 (0)