@@ -39,7 +39,7 @@ use crate::metrics::{BaselineMetrics, SpillMetrics};
3939use crate :: projection:: { ProjectionExec , all_columns, make_with_child, update_expr} ;
4040use crate :: sorts:: streaming_merge:: StreamingMergeBuilder ;
4141use crate :: spill:: spill_manager:: SpillManager ;
42- use crate :: spill:: spill_pool:: { self , SpillPoolWriter } ;
42+ use crate :: spill:: spill_pool:: { self , SpillPoolSink , SpillPoolWriter } ;
4343use crate :: statistics:: { ChildStats , StatisticsArgs } ;
4444use crate :: stream:: { EmptyRecordBatchStream , RecordBatchStreamAdapter } ;
4545use crate :: {
@@ -56,7 +56,7 @@ use datafusion_common::stats::Precision;
5656use datafusion_common:: utils:: { compare_rows, extract_row_at_idx_to_buf, transpose} ;
5757use datafusion_common:: {
5858 ColumnStatistics , DataFusionError , HashMap , ScalarValue , SplitPoint ,
59- assert_or_internal_err, internal_err,
59+ assert_or_internal_err, internal_datafusion_err , internal_err,
6060} ;
6161use datafusion_common:: { Result , not_impl_err} ;
6262use datafusion_common_runtime:: SpawnedTask ;
@@ -164,10 +164,40 @@ type InputPartitionsToCurrentPartitionReceiver = Vec<DistributionReceiver<MaybeB
164164struct OutputChannel {
165165 sender : DistributionSender < MaybeBatch > ,
166166 reservation : SharedMemoryReservation ,
167- spill_writer : SpillPoolWriter ,
167+ spill_writer : SpillPoolSink ,
168168 shared_coalescer : Option < SharedCoalescer > ,
169169}
170170
171+ /// The set of spill-pool writers for a single output partition, before they are handed to the
172+ /// per-input tasks. The variant encodes the repartition mode so the wrong writer topology cannot
173+ /// be constructed for a given mode.
174+ enum PartitionSpillWriters {
175+ /// `preserve_order`: one single-producer FIFO writer per input partition. Each is `take`n
176+ /// exactly once (moved into the matching input task), so the pool always has one writer.
177+ PerInput ( Vec < Option < SpillPoolSink > > ) ,
178+ /// Non-preserve-order: one shared writer, cloned into every input task.
179+ Shared ( SpillPoolWriter ) ,
180+ }
181+
182+ impl PartitionSpillWriters {
183+ /// Hand out the writer for input partition `input`.
184+ ///
185+ /// In `PerInput` mode this moves the dedicated writer out (it must only be requested once per
186+ /// input); in `Shared` mode it clones the shared writer.
187+ fn take_for_input ( & mut self , input : usize ) -> Result < SpillPoolSink > {
188+ match self {
189+ PartitionSpillWriters :: PerInput ( writers) => {
190+ writers[ input] . take ( ) . ok_or_else ( || {
191+ internal_datafusion_err ! (
192+ "spill writer for input partition requested more than once"
193+ )
194+ } )
195+ }
196+ PartitionSpillWriters :: Shared ( writer) => Ok ( writer. new_sink ( ) ) ,
197+ }
198+ }
199+ }
200+
171201impl OutputChannel {
172202 fn coalesce ( & mut self , batch : RecordBatch ) -> Result < Vec < RecordBatch > > {
173203 match & self . shared_coalescer {
@@ -293,7 +323,7 @@ impl SharedCoalescer {
293323///
294324/// See [`RepartitionExec`] for the overall N×M architecture.
295325///
296- /// [`spill_pool::channel`]: crate::spill::spill_pool::channel
326+ /// [`spill_pool::channel`]: crate::spill::spill_pool::spsc_channel
297327struct PartitionChannels {
298328 /// Senders for each input partition to send data to this output partition
299329 tx : InputPartitionsToCurrentPartitionSender ,
@@ -305,9 +335,11 @@ struct PartitionChannels {
305335 /// partition. `None` in preserve-order mode (downstream
306336 /// `StreamingMergeBuilder` handles batching).
307337 shared_coalescer : Option < SharedCoalescer > ,
308- /// Spill writers for writing spilled data.
309- /// SpillPoolWriter is Clone, so multiple writers can share state in non-preserve-order mode.
310- spill_writers : Vec < SpillPoolWriter > ,
338+ /// Spill writers for writing spilled data, before they are handed to the per-input tasks.
339+ /// The variant is chosen by the repartition mode (see [`PartitionSpillWriters`]): a dedicated
340+ /// single-producer FIFO writer per input in preserve-order mode, or one shared writer in
341+ /// non-preserve-order mode.
342+ spill_writers : PartitionSpillWriters ,
311343 /// Spill readers for reading spilled data - one per input partition (FIFO semantics).
312344 /// Each (input, output) pair gets its own reader to maintain proper ordering.
313345 spill_readers : Vec < SendableRecordBatchStream > ,
@@ -465,15 +497,29 @@ impl RepartitionExecState {
465497 . execution
466498 . max_spill_file_size_bytes
467499 . get ( ) ;
468- let num_spill_channels = if preserve_order {
469- num_input_partitions
500+
501+ let ( spill_writers, spill_readers) = if preserve_order {
502+ // preserve_order: one dedicated single-producer FIFO pool per input partition.
503+ // Each writer is moved into exactly one input task (never cloned), so the ordering
504+ // the downstream merge relies on is preserved across the spill boundary.
505+ let mut writers = Vec :: with_capacity ( num_input_partitions) ;
506+ let mut readers = Vec :: with_capacity ( num_input_partitions) ;
507+ for _ in 0 ..num_input_partitions {
508+ let ( writer, reader) = spill_pool:: spsc_channel (
509+ max_file_size,
510+ Arc :: clone ( & spill_manager) ,
511+ ) ;
512+ writers. push ( Some ( writer) ) ;
513+ readers. push ( reader) ;
514+ }
515+ ( PartitionSpillWriters :: PerInput ( writers) , readers)
470516 } else {
471- 1
517+ // non-preserve-order: one shared multi-producer pool per output partition, since
518+ // all inputs share the same receiver and the output is an unordered multiset.
519+ let ( writer, reader) =
520+ spill_pool:: mpsc_channel ( max_file_size, Arc :: clone ( & spill_manager) ) ;
521+ ( PartitionSpillWriters :: Shared ( writer) , vec ! [ reader] )
472522 } ;
473- let ( spill_writers, spill_readers) : ( Vec < _ > , Vec < _ > ) = ( 0
474- ..num_spill_channels)
475- . map ( |_| spill_pool:: channel ( max_file_size, Arc :: clone ( & spill_manager) ) )
476- . unzip ( ) ;
477523
478524 // Coalesce on the producer side, before the channel's gate, so
479525 // the consumer never sees the per-input-task small batches.
@@ -506,23 +552,22 @@ impl RepartitionExecState {
506552 std:: mem:: take ( streams_and_metrics) . into_iter ( ) . enumerate ( )
507553 {
508554 let txs: HashMap < _ , _ > = channels
509- . iter ( )
555+ . iter_mut ( )
510556 . map ( |( partition, channels) | {
511- // In preserve_order mode: each input gets its own spill writer (index i)
512- // In non-preserve-order mode: all inputs share spill writer 0 via clone
513- let spill_writer_idx = if preserve_order { i } else { 0 } ;
514- (
557+ // Hand this input task its spill writer: in preserve_order mode this moves
558+ // the input's dedicated FIFO writer out; otherwise it clones the shared
559+ // writer. See [`PartitionSpillWriters::take_for_input`].
560+ Ok ( (
515561 * partition,
516562 OutputChannel {
517563 sender : channels. tx [ i] . clone ( ) ,
518564 reservation : Arc :: clone ( & channels. reservation ) ,
519- spill_writer : channels. spill_writers [ spill_writer_idx]
520- . clone ( ) ,
565+ spill_writer : channels. spill_writers . take_for_input ( i) ?,
521566 shared_coalescer : channels. shared_coalescer . clone ( ) ,
522567 } ,
523- )
568+ ) )
524569 } )
525- . collect ( ) ;
570+ . collect :: < Result < HashMap < _ , _ > > > ( ) ? ;
526571
527572 // Extract senders for wait_for_task before moving txs
528573 let senders: HashMap < _ , _ > = txs
@@ -3386,14 +3431,14 @@ mod tests {
33863431
33873432#[ cfg( test) ]
33883433mod test {
3389- use arrow:: array:: record_batch;
3390- use arrow:: compute:: SortOptions ;
3391- use arrow:: datatypes:: { DataType , Field , Schema } ;
3392- use datafusion_common:: assert_batches_eq;
3393-
33943434 use super :: * ;
33953435 use crate :: test:: TestMemoryExec ;
33963436 use crate :: union:: UnionExec ;
3437+ use arrow:: array:: { UInt32Array , record_batch} ;
3438+ use arrow:: compute:: SortOptions ;
3439+ use arrow:: datatypes:: { DataType , Field , Schema } ;
3440+ use datafusion_common:: assert_batches_eq;
3441+ use datafusion_common:: config:: ConfigNonZeroUsize ;
33973442
33983443 use datafusion_physical_expr:: expressions:: col;
33993444
@@ -3572,6 +3617,95 @@ mod test {
35723617 Ok ( ( ) )
35733618 }
35743619
3620+ /// Regression test for order preservation across spill *file rotation*.
3621+ ///
3622+ /// A `preserve_order` repartition relies on each per-(input, output) spill pool delivering
3623+ /// batches in strict FIFO order (see [`spill_pool::spsc_channel`] / [`SpillPoolSink`]). This uses
3624+ /// the same memory profile as [`Self::test_preserve_order_with_spilling`] — which is tuned to
3625+ /// force spilling while still completing — but additionally sets `max_spill_file_size_bytes`
3626+ /// to 1 so every spilled batch lands in its own file. That exercises the FIFO-across-rotation
3627+ /// path: if ordering were lost across rotated files (e.g. by feeding an ordered pool with a
3628+ /// shared multi-producer writer), the downstream `StreamingMerge` would emit out-of-order rows
3629+ /// and the sortedness assertion below would fail.
3630+ #[ tokio:: test]
3631+ async fn test_preserve_order_with_spill_file_rotation ( ) -> Result < ( ) > {
3632+ use datafusion_execution:: config:: SessionConfig ;
3633+ use datafusion_execution:: runtime_env:: RuntimeEnvBuilder ;
3634+
3635+ // Same sorted input as `test_preserve_order_with_spilling`:
3636+ // Partition1: [1,3], [5,7], [9,11]; Partition2: [2,4], [6,8], [10,12]
3637+ let batch1 = record_batch ! ( ( "c0" , UInt32 , [ 1 , 3 ] ) ) . unwrap ( ) ;
3638+ let batch2 = record_batch ! ( ( "c0" , UInt32 , [ 2 , 4 ] ) ) . unwrap ( ) ;
3639+ let batch3 = record_batch ! ( ( "c0" , UInt32 , [ 5 , 7 ] ) ) . unwrap ( ) ;
3640+ let batch4 = record_batch ! ( ( "c0" , UInt32 , [ 6 , 8 ] ) ) . unwrap ( ) ;
3641+ let batch5 = record_batch ! ( ( "c0" , UInt32 , [ 9 , 11 ] ) ) . unwrap ( ) ;
3642+ let batch6 = record_batch ! ( ( "c0" , UInt32 , [ 10 , 12 ] ) ) . unwrap ( ) ;
3643+ let schema = batch1. schema ( ) ;
3644+ let sort_exprs = LexOrdering :: new ( [ PhysicalSortExpr {
3645+ expr : col ( "c0" , & schema) . unwrap ( ) ,
3646+ options : SortOptions :: default ( ) . asc ( ) ,
3647+ } ] )
3648+ . unwrap ( ) ;
3649+ let partition1 = vec ! [ batch1, batch3, batch5] ;
3650+ let partition2 = vec ! [ batch2, batch4, batch6] ;
3651+ let input_partitions = vec ! [ partition1, partition2] ;
3652+
3653+ // Force a new spill file per spilled batch to exercise FIFO across rotation.
3654+ let mut session_config = SessionConfig :: new ( ) ;
3655+ session_config
3656+ . options_mut ( )
3657+ . execution
3658+ . max_spill_file_size_bytes = ConfigNonZeroUsize :: try_new ( 1 ) . unwrap ( ) ;
3659+ // Same tight limit as `test_preserve_order_with_spilling`: forces spilling while leaving
3660+ // the merge enough non-spillable headroom to complete.
3661+ let runtime = RuntimeEnvBuilder :: default ( )
3662+ . with_memory_limit ( 608 , 1.0 )
3663+ . build_arc ( ) ?;
3664+ let task_ctx = Arc :: new (
3665+ TaskContext :: default ( )
3666+ . with_session_config ( session_config)
3667+ . with_runtime ( runtime) ,
3668+ ) ;
3669+
3670+ let exec = TestMemoryExec :: try_new ( & input_partitions, Arc :: clone ( & schema) , None ) ?
3671+ . try_with_sort_information ( vec ! [ sort_exprs. clone( ) , sort_exprs] ) ?;
3672+ let exec = Arc :: new ( TestMemoryExec :: update_cache ( & Arc :: new ( exec) ) ) ;
3673+ let exec = RepartitionExec :: try_new ( exec, Partitioning :: RoundRobinBatch ( 3 ) ) ?
3674+ . with_preserve_order ( ) ;
3675+
3676+ // Each output partition merges sorted substreams, so its rows must be non-decreasing.
3677+ for i in 0 ..exec. partitioning ( ) . partition_count ( ) {
3678+ let mut stream = exec. execute ( i, Arc :: clone ( & task_ctx) ) ?;
3679+ let mut last: Option < u32 > = None ;
3680+ while let Some ( result) = stream. next ( ) . await {
3681+ let batch = result?;
3682+ let col = batch
3683+ . column ( 0 )
3684+ . as_any ( )
3685+ . downcast_ref :: < UInt32Array > ( )
3686+ . unwrap ( ) ;
3687+ for r in 0 ..col. len ( ) {
3688+ let v = col. value ( r) ;
3689+ if let Some ( prev) = last {
3690+ assert ! (
3691+ prev <= v,
3692+ "output partition {i} not sorted: {prev} came before {v}"
3693+ ) ;
3694+ }
3695+ last = Some ( v) ;
3696+ }
3697+ }
3698+ }
3699+
3700+ let metrics = exec. metrics ( ) . unwrap ( ) ;
3701+ assert ! (
3702+ metrics. spill_count( ) . unwrap( ) > 0 ,
3703+ "Expected spilling to occur for order-preserving repartition at this \
3704+ memory limit. If this fails, the memory limit may need adjustment."
3705+ ) ;
3706+ Ok ( ( ) )
3707+ }
3708+
35753709 #[ tokio:: test]
35763710 async fn test_hash_partitioning_with_spilling ( ) -> Result < ( ) > {
35773711 use datafusion_execution:: runtime_env:: RuntimeEnvBuilder ;
0 commit comments