@@ -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
0 commit comments