Skip to content

Commit 12fa0ce

Browse files
pepijnveadriangbclaudealamb
authored
Resolve lost wakeup in SpillPoolReader with multiple concurrent SpillPoolWriters (apache#23522)
## Which issue does this PR close? - Closes apache#23447 ## Rationale for this change When multiple `SpillPoolWriter` clones concurrently push batches to the same channel, more than one non-finished `SpillFile` can be in flight. This happens because each `SpillPoolWriter` clone takes the `current_write_file` at the start of `push_batch` and puts it back when it's done. When multiple `push_batch` calls happen concurrently, only the first one will be able to take the `current_write_file` and the others will all create their own new spill file. Which one gets put back for subsequent use is a race condition. If this occurred and the writers are all dropped before rotation happens in, multiple files in the `files` deque will be have `writer_finished == false`. The last writer drop logic in `SpillPoolWriter::drop` only finishes whatever file is the `current_write_file` as finished. This can lead to a stalled situation when `SpillPoolFile::poll_next` catches up with the writer and returns `Pending` because `writer_finished == false`. A waker for the file is registered, but since the last writer drop logic only finishes and wakes whatever happens to be `current_write_file`, which may not be the current read file, the waker may end up never being notified. There is a secondary waker that is registered on the spill pool itself, but due to fine grained locking, it is possible for the wake call in the last writer drop logic to be called before the waker registration. ## What changes are included in this PR? - Add support for tracking multiple unfinished write files - Close all unfinished write files when the last writer is dropped - Removed `writer_dropped` field which was an unnecessary denormalisation of `active_writer_count == 0` An additional benefit of tracking all unfinished write files is that excessive creation of tiny spill files is avoided when many writers are pushing batches concurrently. ## Are these changes tested? Reproduction case from linked issue was used to confirm fix ## Are there any user-facing changes? No --------- Co-authored-by: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Andrew Lamb <andrew@nerdnetworks.org>
1 parent 955f70f commit 12fa0ce

2 files changed

Lines changed: 429 additions & 189 deletions

File tree

datafusion/physical-plan/src/repartition/mod.rs

Lines changed: 162 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use crate::metrics::{BaselineMetrics, SpillMetrics};
3939
use crate::projection::{ProjectionExec, all_columns, make_with_child, update_expr};
4040
use crate::sorts::streaming_merge::StreamingMergeBuilder;
4141
use crate::spill::spill_manager::SpillManager;
42-
use crate::spill::spill_pool::{self, SpillPoolWriter};
42+
use crate::spill::spill_pool::{self, SpillPoolSink, SpillPoolWriter};
4343
use crate::statistics::{ChildStats, StatisticsArgs};
4444
use crate::stream::{EmptyRecordBatchStream, RecordBatchStreamAdapter};
4545
use crate::{
@@ -56,7 +56,7 @@ use datafusion_common::stats::Precision;
5656
use datafusion_common::utils::{compare_rows, extract_row_at_idx_to_buf, transpose};
5757
use 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
};
6161
use datafusion_common::{Result, not_impl_err};
6262
use datafusion_common_runtime::SpawnedTask;
@@ -164,10 +164,40 @@ type InputPartitionsToCurrentPartitionReceiver = Vec<DistributionReceiver<MaybeB
164164
struct 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+
171201
impl 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
297327
struct 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)]
33883433
mod 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

Comments
 (0)