datafusion_physical_plan/spill/spill_pool.rs
1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use futures::{Stream, StreamExt};
19use std::collections::VecDeque;
20use std::mem;
21use std::sync::Arc;
22use std::task::Waker;
23
24use parking_lot::Mutex;
25
26use arrow::datatypes::SchemaRef;
27use arrow::record_batch::RecordBatch;
28use datafusion_common::Result;
29use datafusion_execution::{RecordBatchStream, SendableRecordBatchStream, SpillFile};
30
31use super::in_progress_spill_file::InProgressSpillFile;
32use super::spill_manager::SpillManager;
33
34/// Shared state between the writer and readers of a spill pool.
35/// This contains the queue of files and coordination state.
36///
37/// # Locking Design
38///
39/// This struct uses **fine-grained locking** with nested `Arc<Mutex<>>`:
40/// - `SpillPoolShared` is wrapped in `Arc<Mutex<>>` (outer lock)
41/// - Each `ActiveSpillFileShared` is wrapped in `Arc<Mutex<>>` (inner lock)
42///
43/// This enables:
44/// 1. **Short critical sections**: The outer lock is held only for queue operations
45/// 2. **I/O outside locks**: Disk I/O happens while holding only the file-specific lock
46/// 3. **Concurrent operations**: Reader can access the queue while writer does I/O
47///
48/// **Lock ordering discipline**: Never hold both locks simultaneously to prevent deadlock.
49/// Always: acquire outer lock → release outer lock → acquire inner lock (if needed).
50struct SpillPoolShared {
51 /// Queue of ALL files (including the current write files if any exist).
52 /// Readers always read from the front of this queue (FIFO).
53 /// Each file has its own lock to enable concurrent reader/writer access.
54 files: VecDeque<Arc<Mutex<ActiveSpillFileShared>>>,
55 /// SpillManager for creating files and tracking metrics
56 spill_manager: Arc<SpillManager>,
57 /// Pool-level waker to notify when new files are available (single reader)
58 waker: Option<Waker>,
59 /// FIFO queue of open write files. The queue may contain multiple items when multiple
60 /// writers concurrently write to the pool.
61 /// Each write file has its own lock to allow I/O without blocking queue access.
62 open_write_files: VecDeque<Arc<Mutex<ActiveSpillFileShared>>>,
63 /// Number of `SpillPoolWriter` instances that have not been dropped yet. As long as this value
64 /// is greater than zero, readers should assume batches may still be pushed. This prevents
65 /// premature EOF signaling.
66 remaining_writer_count: usize,
67}
68
69impl SpillPoolShared {
70 /// Creates a new shared pool state
71 fn new(spill_manager: Arc<SpillManager>) -> Self {
72 Self {
73 files: VecDeque::new(),
74 spill_manager,
75 waker: None,
76 open_write_files: VecDeque::new(),
77 remaining_writer_count: 1,
78 }
79 }
80
81 /// Registers a waker to be notified when new data is available (pool-level)
82 fn register_waker(&mut self, waker: Waker) {
83 self.waker = Some(waker);
84 }
85
86 /// Wakes the pool-level reader
87 fn wake(&mut self) {
88 if let Some(waker) = self.waker.take() {
89 waker.wake();
90 }
91 }
92}
93
94/// Writer for a spill pool that can be cloned to produce additional writers.
95///
96/// Created by [`mpsc_channel`]. See that function for architecture diagrams and usage
97/// examples.
98pub struct SpillPoolWriter {
99 /// The underlying shared writer. Kept private and never cloned, so this pool always has
100 /// exactly one writer.
101 inner: SpillPoolSink,
102}
103
104impl SpillPoolWriter {
105 /// Spills a batch to the pool, rotating files when necessary.
106 ///
107 /// See [`mpsc_channel`] for the rotation semantics.
108 ///
109 /// # Errors
110 ///
111 /// Returns an error if disk I/O fails or disk quota is exceeded.
112 pub fn push_batch(&self, batch: &RecordBatch) -> Result<()> {
113 self.inner.push_batch(batch)
114 }
115}
116
117impl SpillPoolWriter {
118 /// Returns a new sink that can be used to spill batches to the pool.
119 ///
120 /// As an alternative to this function, it is also possible to clone the writer. The benefit
121 /// of this method is that the output type matches the type used by [`spsc_channel`]. This
122 /// enables cost-free abstraction for producers over SPSC and MPSC channels.
123 pub fn new_sink(&self) -> SpillPoolSink {
124 // Increment `remaining_writer_count`. The corresponding decrement is done in the `Drop`
125 // implementation of `SpillPoolWriter`.
126 self.inner.shared.lock().remaining_writer_count += 1;
127 SpillPoolSink {
128 max_file_size_bytes: self.inner.max_file_size_bytes,
129 shared: Arc::clone(&self.inner.shared),
130 }
131 }
132}
133
134impl Clone for SpillPoolWriter {
135 fn clone(&self) -> Self {
136 Self {
137 inner: self.new_sink(),
138 }
139 }
140}
141
142impl Drop for SpillPoolSink {
143 fn drop(&mut self) {
144 let mut shared = self.shared.lock();
145
146 shared.remaining_writer_count -= 1;
147 let is_last_writer = shared.remaining_writer_count == 0;
148
149 if !is_last_writer {
150 // Other writer clones are still active; do not finalize or
151 // signal EOF to readers.
152 return;
153 }
154
155 // Finalize any spill files that were not finished yet
156 if !shared.open_write_files.is_empty() {
157 let files = mem::take(&mut shared.open_write_files);
158 drop(shared);
159
160 for file in files {
161 let mut file_shared = file.lock();
162
163 // Finish the current writer if it exists
164 if let Some(mut writer) = file_shared.writer.take() {
165 // Ignore errors on drop - we're in destructor
166 let _ = writer.finish();
167 }
168
169 // Mark as finished so readers know not to wait for more data
170 file_shared.writer_finished = true;
171
172 // Wake reader waiting on this file (it's now finished)
173 file_shared.wake();
174 drop(file_shared);
175 }
176
177 shared = self.shared.lock();
178 }
179
180 // Wake pool-level readers
181 shared.wake();
182 }
183}
184
185/// Single writer for a spill pool that cannot be cloned.
186///
187/// Created by [`spsc_channel`] and [`SpillPoolWriter::new_sink`].
188pub struct SpillPoolSink {
189 /// Maximum size in bytes before rotating to a new file.
190 /// Typically set from configuration `datafusion.execution.max_spill_file_size_bytes`.
191 max_file_size_bytes: usize,
192 /// Shared state with readers (includes current_write_file for coordination)
193 shared: Arc<Mutex<SpillPoolShared>>,
194}
195
196impl SpillPoolSink {
197 /// Spills a batch to the pool, rotating files when necessary.
198 ///
199 /// See [`spsc_channel`] for overall architecture and examples.
200 ///
201 /// # Errors
202 ///
203 /// Returns an error if disk I/O fails or disk quota is exceeded.
204 pub fn push_batch(&self, batch: &RecordBatch) -> Result<()> {
205 if batch.num_rows() == 0 {
206 // Skip empty batches
207 return Ok(());
208 }
209
210 let batch_size = batch.get_array_memory_size();
211
212 // Fine-grained locking: Lock shared state briefly for queue access
213 let mut shared = self.shared.lock();
214
215 // Create new file if there is none available to append to
216 let write_file = if !shared.open_write_files.is_empty() {
217 shared.open_write_files.pop_front().unwrap()
218 } else {
219 let spill_manager = Arc::clone(&shared.spill_manager);
220 // Release shared lock before disk I/O (fine-grained locking)
221 drop(shared);
222
223 let writer = spill_manager.create_in_progress_file("SpillPool")?;
224 // Clone the file so readers can access it immediately
225 let file = Arc::clone(writer.file().expect(
226 "InProgressSpillFile should always have a file when it is first created",
227 ));
228
229 let file_shared = Arc::new(Mutex::new(ActiveSpillFileShared {
230 writer: Some(writer),
231 file: Some(file), // Set immediately so readers can access it
232 batches_written: 0,
233 estimated_size: 0,
234 writer_finished: false,
235 waker: None,
236 }));
237
238 // Re-acquire lock and push to shared queue
239 shared = self.shared.lock();
240 shared.files.push_back(Arc::clone(&file_shared));
241 shared.wake(); // Wake readers waiting for new files
242 file_shared
243 };
244
245 // Release shared lock before file I/O (fine-grained locking)
246 // This allows readers to access the queue while we do disk I/O
247 drop(shared);
248
249 // Write batch to current file - lock only the specific file
250 let mut file_shared = write_file.lock();
251
252 // Append the batch
253 if let Some(ref mut writer) = file_shared.writer {
254 writer.append_batch(batch)?;
255 // make sure we flush the writer for readers
256 writer.flush()?;
257 file_shared.batches_written += 1;
258 file_shared.estimated_size += batch_size;
259 }
260
261 // Wake reader waiting on this specific file
262 file_shared.wake();
263
264 let max_file_size_reached = file_shared.estimated_size > self.max_file_size_bytes;
265
266 if max_file_size_reached {
267 // Finish the IPC writer
268 if let Some(mut writer) = file_shared.writer.take() {
269 writer.finish()?;
270 }
271 // Mark as finished so readers know not to wait for more data
272 file_shared.writer_finished = true;
273 // Wake reader waiting on this file (it's now finished)
274 file_shared.wake();
275
276 // Don't place `write_file` back in the `open_write_files` queue so we don't
277 // try writing to it again
278 } else {
279 // Release file lock
280 drop(file_shared);
281 // Put back the current file for further writing
282 let mut shared = self.shared.lock();
283 shared.open_write_files.push_back(write_file);
284 }
285
286 Ok(())
287 }
288}
289
290/// Creates a paired writer and reader for a spill pool with SPSC (single-producer,
291/// single-consumer) semantics and strict FIFO ordering.
292///
293/// If you need a spill pool that supports several producers, use [`mpsc_channel`] instead.
294///
295/// The reader can start reading immediately after the writer appends a batch
296/// to the spill file, without waiting for the file to be sealed, while the writer continues to
297/// write more data.
298///
299/// Internally this coordinates rotating spill files based on size limits, and
300/// handles asynchronous notification between the writer and reader using wakers.
301/// This ensures that we manage disk usage efficiently while allowing concurrent
302/// I/O between the writer and reader.
303///
304/// # Data Flow Overview
305///
306/// 1. Writer write batch `B0` to F1
307/// 2. Writer write batch `B1` to F1, notices the size limit exceeded, finishes F1.
308/// 3. Reader read `B0` from F1
309/// 4. Reader read `B1`, no more batch to read -> wait on the waker
310/// 5. Writer write batch `B2` to a new file `F2`, wake up the waiting reader.
311/// 6. Reader read `B2` from F2.
312/// 7. Repeat until writer is dropped.
313///
314/// # Architecture
315///
316/// ```text
317/// ┌─────────────────────────────────────────────────────────────────────────┐
318/// │ SpillPool │
319/// │ │
320/// │ Writer Side Shared State Reader Side │
321/// │ ─────────── ──────────── ─────────── │
322/// │ │
323/// │ SpillPoolSink ┌────────────────────┐ RecordBatchStream │
324/// │ │ │ VecDeque<File> │ │ │
325/// │ │ │ ┌────┐┌────┐ │ │ │
326/// │ push_batch() │ │ F1 ││ F2 │ ... │ next().await │
327/// │ │ │ └────┘└────┘ │ │ │
328/// │ ▼ │ │ ▼ │
329/// │ ┌─────────┐ │ │ ┌──────────┐ │
330/// │ │Current │───────▶│ Coordination: │◀───│ Current │ │
331/// │ │Write │ │ - Wakers │ │ Read │ │
332/// │ │File │ │ - Batch counts │ │ File │ │
333/// │ └─────────┘ │ - Writer status │ └──────────┘ │
334/// │ │ └────────────────────┘ │ │
335/// │ │ │ │
336/// │ Size > limit? Read all batches? │
337/// │ │ │ │
338/// │ ▼ ▼ │
339/// │ Rotate to new file Pop from queue │
340/// └─────────────────────────────────────────────────────────────────────────┘
341///
342/// Writer produces → Shared queue → Reader consumes
343/// ```
344///
345/// # File State Machine
346///
347/// Each file in the pool coordinates between writer and reader:
348///
349/// ```text
350/// Writer View Reader View
351/// ─────────── ───────────
352///
353/// Created writer: Some(..) batches_read: 0
354/// batches_written: 0 (waiting for data)
355/// │
356/// ▼
357/// Writing append_batch() Can read if:
358/// batches_written++ batches_read < batches_written
359/// wake readers
360/// │ │
361/// │ ▼
362/// ┌──────┴──────┐ poll_next() → batch
363/// │ │ batches_read++
364/// ▼ ▼
365/// Size > limit? More data?
366/// │ │
367/// │ └─▶ Yes ──▶ Continue writing
368/// ▼
369/// finish() Reader catches up:
370/// writer_finished = true batches_read == batches_written
371/// wake readers │
372/// │ ▼
373/// └─────────────────────▶ Returns Poll::Ready(None)
374/// File complete, pop from queue
375/// ```
376///
377/// # Arguments
378///
379/// * `max_file_size_bytes` - Maximum size per file before rotation. When a file
380/// exceeds this size, the writer automatically rotates to a new file.
381/// * `spill_manager` - Manager for file creation and metrics tracking
382///
383/// # Returns
384///
385/// A tuple of `(SpillPoolSink, SendableRecordBatchStream)` that share the same
386/// underlying pool. The reader is returned as a stream for immediate use with
387/// async stream combinators.
388///
389/// # Example
390///
391/// ```
392/// use std::sync::Arc;
393/// use arrow::array::{ArrayRef, Int32Array};
394/// use arrow::datatypes::{DataType, Field, Schema};
395/// use arrow::record_batch::RecordBatch;
396/// use datafusion_execution::runtime_env::RuntimeEnv;
397/// use futures::StreamExt;
398///
399/// # use datafusion_physical_plan::spill::spill_pool;
400/// # use datafusion_physical_plan::spill::SpillManager; // Re-exported for doctests
401/// # use datafusion_physical_plan::metrics::{ExecutionPlanMetricsSet, SpillMetrics};
402/// #
403/// # #[tokio::main]
404/// # async fn main() -> datafusion_common::Result<()> {
405/// # // Setup for the example (typically comes from TaskContext in production)
406/// # let env = Arc::new(RuntimeEnv::default());
407/// # let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0);
408/// # let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
409/// # let spill_manager = Arc::new(SpillManager::new(env, metrics, schema.clone()));
410/// #
411/// // Create channel with 1MB file size limit
412/// let (writer, mut reader) = spill_pool::spsc_channel(1024 * 1024, spill_manager);
413///
414/// // Spawn writer and reader concurrently; writer wakes reader via wakers
415/// let writer_task = tokio::spawn(async move {
416/// for i in 0..5 {
417/// let array: ArrayRef = Arc::new(Int32Array::from(vec![i; 100]));
418/// let batch = RecordBatch::try_new(schema.clone(), vec![array]).unwrap();
419/// writer.push_batch(&batch)?;
420/// }
421/// // Explicitly drop writer to finalize the spill file and wake the reader
422/// drop(writer);
423/// datafusion_common::Result::<()>::Ok(())
424/// });
425///
426/// let reader_task = tokio::spawn(async move {
427/// let mut batches_read = 0;
428/// while let Some(result) = reader.next().await {
429/// let _batch = result?;
430/// batches_read += 1;
431/// }
432/// datafusion_common::Result::<usize>::Ok(batches_read)
433/// });
434///
435/// let (writer_res, reader_res) = tokio::join!(writer_task, reader_task);
436/// writer_res
437/// .map_err(|e| datafusion_common::DataFusionError::Execution(e.to_string()))??;
438/// let batches_read = reader_res
439/// .map_err(|e| datafusion_common::DataFusionError::Execution(e.to_string()))??;
440///
441/// assert_eq!(batches_read, 5);
442/// # Ok(())
443/// # }
444/// ```
445///
446/// # Why rotate files?
447///
448/// File rotation ensures we don't end up with unreferenced disk usage.
449/// If we used a single file for all spilled data, we would end up with
450/// unreferenced data at the beginning of the file that has already been read
451/// by readers but we can't delete because you can't truncate from the start of a file.
452///
453/// Consider the case of a query like `SELECT * FROM large_table WHERE false`.
454/// Obviously this query produces no output rows, but if we had a spilling operator
455/// in the middle of this query between the scan and the filter it would see the entire
456/// `large_table` flow through it and thus would spill all of that data to disk.
457/// So we'd end up using up to `size(large_table)` bytes of disk space.
458/// If instead we use file rotation, and as long as the readers can keep up with the writer,
459/// then we can ensure that once a file is fully read by all readers it can be deleted,
460/// thus bounding the maximum disk usage to roughly `max_file_size_bytes`.
461pub fn spsc_channel(
462 max_file_size_bytes: usize,
463 spill_manager: Arc<SpillManager>,
464) -> (SpillPoolSink, SendableRecordBatchStream) {
465 let schema = Arc::clone(spill_manager.schema());
466 let shared = Arc::new(Mutex::new(SpillPoolShared::new(spill_manager)));
467
468 let writer = SpillPoolSink {
469 max_file_size_bytes,
470 shared: Arc::clone(&shared),
471 };
472
473 let reader = SpillPoolReader::new(shared, schema);
474
475 (writer, Box::pin(reader))
476}
477
478/// Alias for [`mpsc_channel`].
479#[deprecated(note = "Use mpsc_channel instead")]
480pub fn channel(
481 max_file_size_bytes: usize,
482 spill_manager: Arc<SpillManager>,
483) -> (SpillPoolWriter, SendableRecordBatchStream) {
484 mpsc_channel(max_file_size_bytes, spill_manager)
485}
486
487/// Creates a paired writer and reader for a spill pool with MPSC (multi-producer,
488/// single-consumer) semantics. See [`spsc_channel`] for the general architecture description
489/// of the spill pool.
490///
491/// Additional writers can be created by cloning the returned [`SpillPoolWriter`].
492///
493/// In contrast to [`spsc_channel`], this implementation provides no guarantees regarding
494/// the read order of the returned [`SendableRecordBatchStream`].
495///
496/// If you need strict end-to-end FIFO (a single writer whose batches are read back in exact
497/// write order), use [`spsc_channel`] instead.
498///
499/// # File Management
500///
501/// The shared channel uses the same size-based rotation trigger as the [single producer channel](spsc_channel).
502/// All writers share the same pool of write files and coordinate file rotation. The number of open
503/// files is kept as small as possible. When more writes occur concurrently than there are open write
504/// files an additional file will be opened to write to. This prevents multiple writers from blocking
505/// each other.
506///
507/// When the last writer clone is dropped, it finalizes any remaining open write files so that all
508/// written data can be accessed by the reader.
509///
510/// # Returns
511///
512/// A tuple of `(SpillPoolWriter, SendableRecordBatchStream)` that share the same
513/// underlying pool. The reader is returned as a stream for immediate use with
514/// async stream combinators. The writer can be cloned to create additional writers.
515pub fn mpsc_channel(
516 max_file_size_bytes: usize,
517 spill_manager: Arc<SpillManager>,
518) -> (SpillPoolWriter, SendableRecordBatchStream) {
519 let (inner, reader) = spsc_channel(max_file_size_bytes, spill_manager);
520 (SpillPoolWriter { inner }, reader)
521}
522
523/// Shared state between writer and readers for an active spill file.
524/// Protected by a Mutex to coordinate between concurrent readers and the writer.
525struct ActiveSpillFileShared {
526 /// Writer handle - taken (set to None) when finish() is called
527 writer: Option<InProgressSpillFile>,
528 /// The spill file, set when the writer finishes.
529 /// Taken by the reader when creating a stream (the file stays open via file handles).
530 file: Option<Arc<dyn SpillFile>>,
531 /// Total number of batches written to this file
532 batches_written: usize,
533 /// Estimated size in bytes of data written to this file
534 estimated_size: usize,
535 /// Whether the writer has finished writing to this file
536 writer_finished: bool,
537 /// Waker for reader waiting on this specific file (SPSC: only one reader)
538 waker: Option<Waker>,
539}
540
541impl ActiveSpillFileShared {
542 /// Registers a waker to be notified when new data is written to this file
543 fn register_waker(&mut self, waker: Waker) {
544 self.waker = Some(waker);
545 }
546
547 /// Wakes the reader waiting on this file
548 fn wake(&mut self) {
549 if let Some(waker) = self.waker.take() {
550 waker.wake();
551 }
552 }
553}
554
555/// Reader state for a SpillPoolFile (owned by individual SpillPoolFile instances).
556/// This is kept separate from the shared state to avoid holding locks during I/O.
557struct SpillPoolFileReader {
558 /// The actual stream reading from disk
559 stream: SendableRecordBatchStream,
560 /// Number of batches this reader has consumed
561 batches_read: usize,
562}
563
564struct SpillPoolFile {
565 /// Shared coordination state (contains writer and batch counts)
566 shared: Arc<Mutex<ActiveSpillFileShared>>,
567 /// Reader state (lazy-initialized, owned by this SpillPoolFile)
568 reader: Option<SpillPoolFileReader>,
569 /// Spill manager for creating readers
570 spill_manager: Arc<SpillManager>,
571}
572
573impl Stream for SpillPoolFile {
574 type Item = Result<RecordBatch>;
575
576 fn poll_next(
577 mut self: std::pin::Pin<&mut Self>,
578 cx: &mut std::task::Context<'_>,
579 ) -> std::task::Poll<Option<Self::Item>> {
580 use std::task::Poll;
581
582 // Step 1: Lock shared state and check coordination
583 let (should_read, file) = {
584 let mut shared = self.shared.lock();
585
586 // Determine if we can read
587 let batches_read = self.reader.as_ref().map_or(0, |r| r.batches_read);
588
589 if batches_read < shared.batches_written {
590 // More data available to read - take the file if we don't have a reader yet
591 let file = if self.reader.is_none() {
592 shared.file.take()
593 } else {
594 None
595 };
596 (true, file)
597 } else if shared.writer_finished {
598 // No more data and writer is done - EOF
599 return Poll::Ready(None);
600 } else {
601 // Caught up to writer, but writer still active - register waker and wait
602 shared.register_waker(cx.waker().clone());
603 return Poll::Pending;
604 }
605 }; // Lock released here
606
607 // Step 2: Lazy-create reader stream if needed
608 if self.reader.is_none() && should_read {
609 if let Some(file) = file {
610 // we want this unbuffered because files are actively being written to
611 match self
612 .spill_manager
613 .read_spill_as_stream_unbuffered(file, None)
614 {
615 Ok(stream) => {
616 self.reader = Some(SpillPoolFileReader {
617 stream,
618 batches_read: 0,
619 });
620 }
621 Err(e) => return Poll::Ready(Some(Err(e))),
622 }
623 } else {
624 // File not available yet (writer hasn't finished or already taken)
625 // Register waker and wait for file to be ready
626 let mut shared = self.shared.lock();
627 shared.register_waker(cx.waker().clone());
628 return Poll::Pending;
629 }
630 }
631
632 // Step 3: Poll the reader stream (no lock held)
633 if let Some(reader) = &mut self.reader {
634 match reader.stream.poll_next_unpin(cx) {
635 Poll::Ready(Some(Ok(batch))) => {
636 // Successfully read a batch - increment counter
637 reader.batches_read += 1;
638 Poll::Ready(Some(Ok(batch)))
639 }
640 Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
641 Poll::Ready(None) => {
642 // Stream exhausted unexpectedly
643 // This shouldn't happen if coordination is correct, but handle gracefully
644 Poll::Ready(None)
645 }
646 Poll::Pending => Poll::Pending,
647 }
648 } else {
649 // Should not reach here, but handle gracefully
650 Poll::Ready(None)
651 }
652 }
653}
654
655/// A stream that reads from a SpillPool. The reader guarantees FIFO order if a single writer is used.
656///
657/// Created by [`spsc_channel`]. See that function for architecture diagrams and usage examples.
658///
659/// The stream automatically handles file rotation and reads from completed files.
660/// When no data is available, it returns `Poll::Pending` and registers a waker to
661/// be notified when the writer produces more data.
662///
663/// # Infinite Stream Semantics
664///
665/// This stream never returns `None` (`Poll::Ready(None)`) on its own - it will keep
666/// waiting for the writer to produce more data. The stream ends only when:
667/// - The reader is dropped
668/// - The writer is dropped AND all queued data has been consumed
669///
670/// This makes it suitable for continuous streaming scenarios where the writer may
671/// produce data intermittently.
672pub struct SpillPoolReader {
673 /// Shared reference to the spill pool
674 shared: Arc<Mutex<SpillPoolShared>>,
675 /// Current SpillPoolFile we're reading from
676 current_file: Option<SpillPoolFile>,
677 /// Schema of the spilled data
678 schema: SchemaRef,
679}
680
681impl SpillPoolReader {
682 /// Creates a new reader from shared pool state.
683 ///
684 /// This is private - use the [`spsc_channel`] function to create a reader/writer pair.
685 ///
686 /// # Arguments
687 ///
688 /// * `shared` - Shared reference to the pool state
689 fn new(shared: Arc<Mutex<SpillPoolShared>>, schema: SchemaRef) -> Self {
690 Self {
691 shared,
692 current_file: None,
693 schema,
694 }
695 }
696}
697
698impl Stream for SpillPoolReader {
699 type Item = Result<RecordBatch>;
700
701 fn poll_next(
702 mut self: std::pin::Pin<&mut Self>,
703 cx: &mut std::task::Context<'_>,
704 ) -> std::task::Poll<Option<Self::Item>> {
705 use std::task::Poll;
706
707 loop {
708 // If we have a current file, try to read from it
709 if let Some(ref mut file) = self.current_file {
710 match file.poll_next_unpin(cx) {
711 Poll::Ready(Some(Ok(batch))) => {
712 // Got a batch, return it
713 return Poll::Ready(Some(Ok(batch)));
714 }
715 Poll::Ready(Some(Err(e))) => {
716 // Error reading batch
717 return Poll::Ready(Some(Err(e)));
718 }
719 Poll::Ready(None) => {
720 // Current file stream exhausted
721 // Check if this file is marked as writer_finished
722 let writer_finished = { file.shared.lock().writer_finished };
723
724 if writer_finished {
725 // File is complete, pop it from the queue and move to next
726 let mut shared = self.shared.lock();
727 shared.files.pop_front();
728 drop(shared); // Release lock
729
730 // Clear current file and continue loop to get next file
731 self.current_file = None;
732 continue;
733 } else {
734 // Stream exhausted but writer not finished - unexpected
735 // This shouldn't happen with proper coordination
736 return Poll::Ready(None);
737 }
738 }
739 Poll::Pending => {
740 // File not ready yet (waiting for writer)
741 // Register waker so we get notified when writer adds more batches
742 let mut shared = self.shared.lock();
743 shared.register_waker(cx.waker().clone());
744 return Poll::Pending;
745 }
746 }
747 }
748
749 // No current file, need to get the next one
750 let mut shared = self.shared.lock();
751
752 // Peek at the front of the queue (don't pop yet)
753 if let Some(file_shared) = shared.files.front() {
754 // Create a SpillPoolFile from the shared state
755 let spill_manager = Arc::clone(&shared.spill_manager);
756 let file_shared = Arc::clone(file_shared);
757 drop(shared); // Release lock before creating SpillPoolFile
758
759 self.current_file = Some(SpillPoolFile {
760 shared: file_shared,
761 reader: None,
762 spill_manager,
763 });
764
765 // Continue loop to poll the new file
766 continue;
767 }
768
769 // No files in queue - check if writer is done
770 if shared.remaining_writer_count == 0 {
771 // Writer is done and no more files will be added - EOF
772 return Poll::Ready(None);
773 }
774
775 // Writer still active, register waker that will get notified when new files are added
776 shared.register_waker(cx.waker().clone());
777 return Poll::Pending;
778 }
779 }
780}
781
782impl RecordBatchStream for SpillPoolReader {
783 fn schema(&self) -> SchemaRef {
784 Arc::clone(&self.schema)
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use crate::metrics::{ExecutionPlanMetricsSet, SpillMetrics};
792 use arrow::array::{ArrayRef, Int32Array};
793 use arrow::datatypes::{DataType, Field, Schema};
794 use datafusion_common_runtime::{JoinSet, SpawnedTask};
795 use datafusion_execution::runtime_env::RuntimeEnv;
796
797 fn create_test_schema() -> SchemaRef {
798 Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]))
799 }
800
801 fn create_test_batch(start: i32, count: usize) -> RecordBatch {
802 let schema = create_test_schema();
803 let a: ArrayRef = Arc::new(Int32Array::from(
804 (start..start + count as i32).collect::<Vec<_>>(),
805 ));
806 RecordBatch::try_new(schema, vec![a]).unwrap()
807 }
808
809 fn create_spill_channel(
810 max_file_size: usize,
811 ) -> (SpillPoolSink, SendableRecordBatchStream) {
812 let env = Arc::new(RuntimeEnv::default());
813 let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0);
814 let schema = create_test_schema();
815 let spill_manager = Arc::new(SpillManager::new(env, metrics, schema));
816
817 spsc_channel(max_file_size, spill_manager)
818 }
819
820 fn create_shared_spill_channel(
821 max_file_size: usize,
822 ) -> (SpillPoolWriter, SendableRecordBatchStream) {
823 let env = Arc::new(RuntimeEnv::default());
824 let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0);
825 let schema = create_test_schema();
826 let spill_manager = Arc::new(SpillManager::new(env, metrics, schema));
827
828 mpsc_channel(max_file_size, spill_manager)
829 }
830
831 fn create_spill_channel_with_metrics(
832 max_file_size: usize,
833 ) -> (SpillPoolSink, SendableRecordBatchStream, SpillMetrics) {
834 let env = Arc::new(RuntimeEnv::default());
835 let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0);
836 let schema = create_test_schema();
837 let spill_manager = Arc::new(SpillManager::new(env, metrics.clone(), schema));
838
839 let (writer, reader) = spsc_channel(max_file_size, spill_manager);
840 (writer, reader, metrics)
841 }
842
843 #[tokio::test]
844 async fn test_basic_write_and_read() -> Result<()> {
845 let (writer, mut reader) = create_spill_channel(1024 * 1024);
846
847 // Write one batch
848 let batch1 = create_test_batch(0, 10);
849 writer.push_batch(&batch1)?;
850
851 // Read the batch
852 let result = reader.next().await.unwrap()?;
853 assert_eq!(result.num_rows(), 10);
854
855 // Write another batch
856 let batch2 = create_test_batch(10, 5);
857 writer.push_batch(&batch2)?;
858 // Read the second batch
859 let result = reader.next().await.unwrap()?;
860 assert_eq!(result.num_rows(), 5);
861
862 Ok(())
863 }
864
865 #[tokio::test]
866 async fn test_single_batch_write_read() -> Result<()> {
867 let (writer, mut reader) = create_spill_channel(1024 * 1024);
868
869 // Write one batch
870 let batch = create_test_batch(0, 5);
871 writer.push_batch(&batch)?;
872
873 // Read it back
874 let result = reader.next().await.unwrap()?;
875 assert_eq!(result.num_rows(), 5);
876
877 // Verify the actual data
878 let col = result
879 .column(0)
880 .as_any()
881 .downcast_ref::<Int32Array>()
882 .unwrap();
883 assert_eq!(col.value(0), 0);
884 assert_eq!(col.value(4), 4);
885
886 Ok(())
887 }
888
889 #[tokio::test]
890 async fn test_multiple_batches_sequential() -> Result<()> {
891 let (writer, mut reader) = create_spill_channel(1024 * 1024);
892
893 // Write multiple batches
894 for i in 0..5 {
895 let batch = create_test_batch(i * 10, 10);
896 writer.push_batch(&batch)?;
897 }
898
899 // Read all batches and verify FIFO order
900 for i in 0..5 {
901 let result = reader.next().await.unwrap()?;
902 assert_eq!(result.num_rows(), 10);
903
904 let col = result
905 .column(0)
906 .as_any()
907 .downcast_ref::<Int32Array>()
908 .unwrap();
909 assert_eq!(col.value(0), i * 10, "Batch {i} not in FIFO order");
910 }
911
912 Ok(())
913 }
914
915 #[tokio::test]
916 async fn test_empty_writer() -> Result<()> {
917 let (_writer, reader) = create_spill_channel(1024 * 1024);
918
919 // Reader should pend since no batches were written
920 let mut reader = reader;
921 let result =
922 tokio::time::timeout(std::time::Duration::from_millis(100), reader.next())
923 .await;
924
925 assert!(result.is_err(), "Reader should timeout on empty writer");
926
927 Ok(())
928 }
929
930 #[tokio::test]
931 async fn test_empty_batch_skipping() -> Result<()> {
932 let (writer, mut reader) = create_spill_channel(1024 * 1024);
933
934 // Write empty batch
935 let empty_batch = create_test_batch(0, 0);
936 writer.push_batch(&empty_batch)?;
937
938 // Write non-empty batch
939 let batch = create_test_batch(0, 5);
940 writer.push_batch(&batch)?;
941
942 // Should only read the non-empty batch
943 let result = reader.next().await.unwrap()?;
944 assert_eq!(result.num_rows(), 5);
945
946 Ok(())
947 }
948
949 #[tokio::test]
950 async fn test_rotation_triggered_by_size() -> Result<()> {
951 // Set a small max_file_size to trigger rotation after one batch
952 let batch1 = create_test_batch(0, 10);
953 let batch_size = batch1.get_array_memory_size() + 1;
954
955 let (writer, mut reader, metrics) = create_spill_channel_with_metrics(batch_size);
956
957 // Write first batch (should fit in first file)
958 writer.push_batch(&batch1)?;
959
960 // Check metrics after first batch - file created but not finalized yet
961 assert_eq!(
962 metrics.spill_file_count.value(),
963 1,
964 "Should have created 1 file after first batch"
965 );
966 assert_eq!(
967 metrics.spilled_bytes.value(),
968 320,
969 "Spilled bytes should reflect data written (header + 1 batch)"
970 );
971 assert_eq!(
972 metrics.spilled_rows.value(),
973 10,
974 "Should have spilled 10 rows from first batch"
975 );
976
977 // Write second batch (should trigger rotation - finalize first file)
978 let batch2 = create_test_batch(10, 10);
979 assert!(
980 batch2.get_array_memory_size() <= batch_size,
981 "batch2 size {} exceeds limit {batch_size}",
982 batch2.get_array_memory_size(),
983 );
984 assert!(
985 batch1.get_array_memory_size() + batch2.get_array_memory_size() > batch_size,
986 "Combined size {} does not exceed limit to trigger rotation",
987 batch1.get_array_memory_size() + batch2.get_array_memory_size()
988 );
989 writer.push_batch(&batch2)?;
990
991 // Check metrics after rotation - first file finalized, but second file not created yet
992 // (new file created lazily on next push_batch call)
993 assert_eq!(
994 metrics.spill_file_count.value(),
995 1,
996 "Should still have 1 file (second file not created until next write)"
997 );
998 assert!(
999 metrics.spilled_bytes.value() > 0,
1000 "Spilled bytes should be > 0 after first file finalized (got {})",
1001 metrics.spilled_bytes.value()
1002 );
1003 assert_eq!(
1004 metrics.spilled_rows.value(),
1005 20,
1006 "Should have spilled 20 total rows (10 + 10)"
1007 );
1008
1009 // Write a third batch to confirm rotation occurred (creates second file)
1010 let batch3 = create_test_batch(20, 5);
1011 writer.push_batch(&batch3)?;
1012
1013 // Now check that second file was created
1014 assert_eq!(
1015 metrics.spill_file_count.value(),
1016 2,
1017 "Should have created 2 files after writing to new file"
1018 );
1019 assert_eq!(
1020 metrics.spilled_rows.value(),
1021 25,
1022 "Should have spilled 25 total rows (10 + 10 + 5)"
1023 );
1024
1025 // Read all three batches
1026 let result1 = reader.next().await.unwrap()?;
1027 assert_eq!(result1.num_rows(), 10);
1028
1029 let result2 = reader.next().await.unwrap()?;
1030 assert_eq!(result2.num_rows(), 10);
1031
1032 let result3 = reader.next().await.unwrap()?;
1033 assert_eq!(result3.num_rows(), 5);
1034
1035 Ok(())
1036 }
1037
1038 #[tokio::test]
1039 async fn test_multiple_rotations() -> Result<()> {
1040 let batches = (0..10)
1041 .map(|i| create_test_batch(i * 10, 10))
1042 .collect::<Vec<_>>();
1043
1044 let batch_size = batches[0].get_array_memory_size() * 2 + 1;
1045
1046 // Very small max_file_size to force frequent rotations
1047 let (writer, mut reader, metrics) = create_spill_channel_with_metrics(batch_size);
1048
1049 // Write many batches to cause multiple rotations
1050 for i in 0..10 {
1051 let batch = create_test_batch(i * 10, 10);
1052 writer.push_batch(&batch)?;
1053 }
1054
1055 // Check metrics after all writes - should have multiple files due to rotations
1056 // With batch_size = 2 * one_batch + 1, each file fits ~2 batches before rotating
1057 // 10 batches should create multiple files (exact count depends on rotation timing)
1058 let file_count = metrics.spill_file_count.value();
1059 assert!(
1060 file_count >= 4,
1061 "Should have created at least 4 files with multiple rotations (got {file_count})"
1062 );
1063 assert!(
1064 metrics.spilled_bytes.value() > 0,
1065 "Spilled bytes should be > 0 after rotations (got {})",
1066 metrics.spilled_bytes.value()
1067 );
1068 assert_eq!(
1069 metrics.spilled_rows.value(),
1070 100,
1071 "Should have spilled 100 total rows (10 batches * 10 rows)"
1072 );
1073
1074 // Read all batches and verify order
1075 for i in 0..10 {
1076 let result = reader.next().await.unwrap()?;
1077 assert_eq!(result.num_rows(), 10);
1078
1079 let col = result
1080 .column(0)
1081 .as_any()
1082 .downcast_ref::<Int32Array>()
1083 .unwrap();
1084 assert_eq!(
1085 col.value(0),
1086 i * 10,
1087 "Batch {i} not in correct order after rotations"
1088 );
1089 }
1090
1091 Ok(())
1092 }
1093
1094 #[tokio::test]
1095 async fn test_single_batch_larger_than_limit() -> Result<()> {
1096 // Very small limit
1097 let (writer, mut reader, metrics) = create_spill_channel_with_metrics(100);
1098
1099 // Write a batch that exceeds the limit
1100 let large_batch = create_test_batch(0, 100);
1101 writer.push_batch(&large_batch)?;
1102
1103 // Check metrics after large batch - should trigger rotation immediately
1104 assert_eq!(
1105 metrics.spill_file_count.value(),
1106 1,
1107 "Should have created 1 file for large batch"
1108 );
1109 assert_eq!(
1110 metrics.spilled_rows.value(),
1111 100,
1112 "Should have spilled 100 rows from large batch"
1113 );
1114
1115 // Should still write and read successfully
1116 let result = reader.next().await.unwrap()?;
1117 assert_eq!(result.num_rows(), 100);
1118
1119 // Next batch should go to a new file
1120 let batch2 = create_test_batch(100, 10);
1121 writer.push_batch(&batch2)?;
1122
1123 // Check metrics after second batch - should have rotated to a new file
1124 assert_eq!(
1125 metrics.spill_file_count.value(),
1126 2,
1127 "Should have created 2 files after rotation"
1128 );
1129 assert_eq!(
1130 metrics.spilled_rows.value(),
1131 110,
1132 "Should have spilled 110 total rows (100 + 10)"
1133 );
1134
1135 let result2 = reader.next().await.unwrap()?;
1136 assert_eq!(result2.num_rows(), 10);
1137
1138 Ok(())
1139 }
1140
1141 #[tokio::test]
1142 async fn test_very_small_max_file_size() -> Result<()> {
1143 // Test with just 1 byte max (extreme case)
1144 let (writer, mut reader) = create_spill_channel(1);
1145
1146 // Any batch will exceed this limit
1147 let batch = create_test_batch(0, 5);
1148 writer.push_batch(&batch)?;
1149
1150 // Should still work
1151 let result = reader.next().await.unwrap()?;
1152 assert_eq!(result.num_rows(), 5);
1153
1154 Ok(())
1155 }
1156
1157 #[tokio::test]
1158 async fn test_exact_size_boundary() -> Result<()> {
1159 // Create a batch and measure its approximate size
1160 let batch = create_test_batch(0, 10);
1161 let batch_size = batch.get_array_memory_size();
1162
1163 // Set max_file_size to exactly the batch size
1164 let (writer, mut reader, metrics) = create_spill_channel_with_metrics(batch_size);
1165
1166 // Write first batch (exactly at the size limit)
1167 writer.push_batch(&batch)?;
1168
1169 // Check metrics after first batch - should NOT rotate yet (size == limit, not >)
1170 assert_eq!(
1171 metrics.spill_file_count.value(),
1172 1,
1173 "Should have created 1 file after first batch at exact boundary"
1174 );
1175 assert_eq!(
1176 metrics.spilled_rows.value(),
1177 10,
1178 "Should have spilled 10 rows from first batch"
1179 );
1180
1181 // Write second batch (exceeds the limit, should trigger rotation)
1182 let batch2 = create_test_batch(10, 10);
1183 writer.push_batch(&batch2)?;
1184
1185 // Check metrics after second batch - rotation triggered, first file finalized
1186 // Note: second file not created yet (lazy creation on next write)
1187 assert_eq!(
1188 metrics.spill_file_count.value(),
1189 1,
1190 "Should still have 1 file after rotation (second file created lazily)"
1191 );
1192 assert_eq!(
1193 metrics.spilled_rows.value(),
1194 20,
1195 "Should have spilled 20 total rows (10 + 10)"
1196 );
1197 // Verify first file was finalized by checking spilled_bytes
1198 assert!(
1199 metrics.spilled_bytes.value() > 0,
1200 "Spilled bytes should be > 0 after file finalization (got {})",
1201 metrics.spilled_bytes.value()
1202 );
1203
1204 // Both should be readable
1205 let result1 = reader.next().await.unwrap()?;
1206 assert_eq!(result1.num_rows(), 10);
1207
1208 let result2 = reader.next().await.unwrap()?;
1209 assert_eq!(result2.num_rows(), 10);
1210
1211 // Spill another batch, now we should see the second file created
1212 let batch3 = create_test_batch(20, 5);
1213 writer.push_batch(&batch3)?;
1214 assert_eq!(
1215 metrics.spill_file_count.value(),
1216 2,
1217 "Should have created 2 files after writing to new file"
1218 );
1219
1220 Ok(())
1221 }
1222
1223 #[tokio::test]
1224 async fn test_concurrent_reader_writer() -> Result<()> {
1225 let (writer, mut reader) = create_spill_channel(1024 * 1024);
1226
1227 // Spawn writer task
1228 let writer_handle = SpawnedTask::spawn(async move {
1229 for i in 0..10 {
1230 let batch = create_test_batch(i * 10, 10);
1231 writer.push_batch(&batch).unwrap();
1232 // Small delay to simulate real concurrent work
1233 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1234 }
1235 });
1236
1237 // Reader task (runs concurrently)
1238 let reader_handle = SpawnedTask::spawn(async move {
1239 let mut count = 0;
1240 for i in 0..10 {
1241 let result = reader.next().await.unwrap().unwrap();
1242 assert_eq!(result.num_rows(), 10);
1243
1244 let col = result
1245 .column(0)
1246 .as_any()
1247 .downcast_ref::<Int32Array>()
1248 .unwrap();
1249 assert_eq!(col.value(0), i * 10);
1250 count += 1;
1251 }
1252 count
1253 });
1254
1255 // Wait for both to complete
1256 writer_handle.await.unwrap();
1257 let batches_read = reader_handle.await.unwrap();
1258 assert_eq!(batches_read, 10);
1259
1260 Ok(())
1261 }
1262
1263 #[tokio::test(flavor = "multi_thread", worker_threads = 10)]
1264 async fn test_concurrent_writers() -> Result<()> {
1265 let (writer, mut reader) = create_shared_spill_channel(1024 * 1024);
1266
1267 // Spawn writer tasks
1268 let mut writer_join_set = JoinSet::new();
1269 for w in 0..10 {
1270 let writer = writer.clone();
1271 writer_join_set.spawn(async move {
1272 for b in 0..10 {
1273 let batch = create_test_batch((w * 100) + (b * 10), 10);
1274 writer.push_batch(&batch).unwrap();
1275 }
1276 });
1277 }
1278 drop(writer);
1279
1280 // Reader task (runs concurrently)
1281 let reader_handle = SpawnedTask::spawn(async move {
1282 let mut batch_order = vec![];
1283 loop {
1284 match reader.next().await {
1285 None => break,
1286 Some(batch) => {
1287 let batch = batch.unwrap();
1288
1289 assert_eq!(batch.num_rows(), 10);
1290
1291 let col = batch
1292 .column(0)
1293 .as_any()
1294 .downcast_ref::<Int32Array>()
1295 .unwrap();
1296 batch_order.push(col.value(0) / 10);
1297 }
1298 }
1299 }
1300 batch_order
1301 });
1302
1303 // Wait for both to complete
1304 writer_join_set.join_all().await;
1305 let mut batch_order = reader_handle.await.unwrap();
1306
1307 // When used with multiple writers, order is not guaranteed
1308 batch_order.sort();
1309 assert_eq!(batch_order, (0i32..100i32).collect::<Vec<_>>());
1310
1311 Ok(())
1312 }
1313
1314 #[tokio::test]
1315 async fn test_reader_catches_up_to_writer() -> Result<()> {
1316 let (writer, mut reader) = create_spill_channel(1024 * 1024);
1317
1318 let (reader_waiting_tx, reader_waiting_rx) = tokio::sync::oneshot::channel();
1319 let (first_read_done_tx, first_read_done_rx) = tokio::sync::oneshot::channel();
1320
1321 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1322 enum ReadWriteEvent {
1323 ReadStart,
1324 Read(usize),
1325 Write(usize),
1326 }
1327
1328 let events = Arc::new(Mutex::new(vec![]));
1329 // Start reader first (will pend)
1330 let reader_events = Arc::clone(&events);
1331 let reader_handle = SpawnedTask::spawn(async move {
1332 reader_events.lock().push(ReadWriteEvent::ReadStart);
1333 reader_waiting_tx
1334 .send(())
1335 .expect("reader_waiting channel closed unexpectedly");
1336 let result = reader.next().await.unwrap().unwrap();
1337 reader_events
1338 .lock()
1339 .push(ReadWriteEvent::Read(result.num_rows()));
1340 first_read_done_tx
1341 .send(())
1342 .expect("first_read_done channel closed unexpectedly");
1343 let result = reader.next().await.unwrap().unwrap();
1344 reader_events
1345 .lock()
1346 .push(ReadWriteEvent::Read(result.num_rows()));
1347 });
1348
1349 // Wait until the reader is pending on the first batch
1350 reader_waiting_rx
1351 .await
1352 .expect("reader should signal when waiting");
1353
1354 // Now write a batch (should wake the reader)
1355 let batch = create_test_batch(0, 5);
1356 events.lock().push(ReadWriteEvent::Write(batch.num_rows()));
1357 writer.push_batch(&batch)?;
1358
1359 // Wait for the reader to finish the first read before allowing the
1360 // second write. This ensures deterministic ordering of events:
1361 // 1. The reader starts and pends on the first `next()`
1362 // 2. The first write wakes the reader
1363 // 3. The reader processes the first batch and signals completion
1364 // 4. The second write is issued, ensuring consistent event ordering
1365 first_read_done_rx
1366 .await
1367 .expect("reader should signal when first read completes");
1368
1369 // Write another batch
1370 let batch = create_test_batch(5, 10);
1371 events.lock().push(ReadWriteEvent::Write(batch.num_rows()));
1372 writer.push_batch(&batch)?;
1373
1374 // Reader should complete
1375 reader_handle.await.unwrap();
1376 let events = events.lock().clone();
1377 assert_eq!(
1378 events,
1379 vec![
1380 ReadWriteEvent::ReadStart,
1381 ReadWriteEvent::Write(5),
1382 ReadWriteEvent::Read(5),
1383 ReadWriteEvent::Write(10),
1384 ReadWriteEvent::Read(10)
1385 ]
1386 );
1387
1388 Ok(())
1389 }
1390
1391 #[tokio::test]
1392 async fn test_reader_starts_after_writer_finishes() -> Result<()> {
1393 let (writer, reader) = create_spill_channel(128);
1394
1395 // Writer writes all data
1396 for i in 0..5 {
1397 let batch = create_test_batch(i * 10, 10);
1398 writer.push_batch(&batch)?;
1399 }
1400
1401 drop(writer);
1402
1403 // Now start reader
1404 let mut reader = reader;
1405 let mut count = 0;
1406 for i in 0..5 {
1407 let result = reader.next().await.unwrap()?;
1408 assert_eq!(result.num_rows(), 10);
1409
1410 let col = result
1411 .column(0)
1412 .as_any()
1413 .downcast_ref::<Int32Array>()
1414 .unwrap();
1415 assert_eq!(col.value(0), i * 10);
1416 count += 1;
1417 }
1418
1419 assert_eq!(count, 5, "Should read all batches after writer finishes");
1420
1421 Ok(())
1422 }
1423
1424 #[tokio::test]
1425 async fn test_writer_drop_finalizes_file() -> Result<()> {
1426 let env = Arc::new(RuntimeEnv::default());
1427 let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0);
1428 let schema = create_test_schema();
1429 let spill_manager =
1430 Arc::new(SpillManager::new(Arc::clone(&env), metrics.clone(), schema));
1431
1432 let (writer, mut reader) = spsc_channel(1024 * 1024, spill_manager);
1433
1434 // Write some batches
1435 for i in 0..5 {
1436 let batch = create_test_batch(i * 10, 10);
1437 writer.push_batch(&batch)?;
1438 }
1439
1440 // Check metrics before drop - spilled_bytes already reflects written data
1441 let spilled_bytes_before = metrics.spilled_bytes.value();
1442 assert_eq!(
1443 spilled_bytes_before, 1088,
1444 "Spilled bytes should reflect data written (header + 5 batches)"
1445 );
1446
1447 // Explicitly drop the writer - this should finalize the current file
1448 drop(writer);
1449
1450 // Check metrics after drop - spilled_bytes should be > 0 now
1451 let spilled_bytes_after = metrics.spilled_bytes.value();
1452 assert!(
1453 spilled_bytes_after > 0,
1454 "Spilled bytes should be > 0 after writer is dropped (got {spilled_bytes_after})"
1455 );
1456
1457 // Verify reader can still read all batches
1458 let mut count = 0;
1459 for i in 0..5 {
1460 let result = reader.next().await.unwrap()?;
1461 assert_eq!(result.num_rows(), 10);
1462
1463 let col = result
1464 .column(0)
1465 .as_any()
1466 .downcast_ref::<Int32Array>()
1467 .unwrap();
1468 assert_eq!(col.value(0), i * 10);
1469 count += 1;
1470 }
1471
1472 assert_eq!(count, 5, "Should read all batches after writer is dropped");
1473
1474 Ok(())
1475 }
1476
1477 /// Verifies that the reader stays alive as long as any writer clone exists.
1478 ///
1479 /// `SpillPoolWriter` is `Clone`, and in non-preserve-order repartitioning
1480 /// mode multiple input partition tasks share clones of the same writer.
1481 /// The reader must not see EOF until **all** clones have been dropped,
1482 /// even if the queue is temporarily empty between writes from different
1483 /// clones.
1484 ///
1485 /// The test sequence is:
1486 ///
1487 /// 1. writer1 writes a batch, then is dropped.
1488 /// 2. The reader consumes that batch (queue is now empty).
1489 /// 3. writer2 (still alive) writes a batch.
1490 /// 4. The reader must see that batch.
1491 /// 5. EOF is only signalled after writer2 is also dropped.
1492 #[tokio::test]
1493 async fn test_clone_drop_does_not_signal_eof_prematurely() -> Result<()> {
1494 let (writer1, mut reader) = create_shared_spill_channel(1024 * 1024);
1495 let writer2 = writer1.clone();
1496
1497 // Synchronization: tell writer2 when it may proceed.
1498 let (proceed_tx, proceed_rx) = tokio::sync::oneshot::channel::<()>();
1499
1500 // Spawn writer2 — it waits for the signal before writing.
1501 let writer2_handle = SpawnedTask::spawn(async move {
1502 proceed_rx.await.unwrap();
1503 writer2.push_batch(&create_test_batch(10, 10)).unwrap();
1504 // writer2 is dropped here (last clone → true EOF)
1505 });
1506
1507 // Writer1 writes one batch, then drops.
1508 writer1.push_batch(&create_test_batch(0, 10))?;
1509 drop(writer1);
1510
1511 // Read writer1's batch.
1512 let batch1 = reader.next().await.unwrap()?;
1513 assert_eq!(batch1.num_rows(), 10);
1514 let col = batch1
1515 .column(0)
1516 .as_any()
1517 .downcast_ref::<Int32Array>()
1518 .unwrap();
1519 assert_eq!(col.value(0), 0);
1520
1521 // Signal writer2 to write its batch. It will execute when the
1522 // current task yields (i.e. when reader.next() returns Pending).
1523 proceed_tx.send(()).unwrap();
1524
1525 // The reader should wait (Pending) for writer2's data, not EOF.
1526 let batch2 =
1527 tokio::time::timeout(std::time::Duration::from_secs(5), reader.next())
1528 .await
1529 .expect("Reader timed out — should not hang");
1530
1531 assert!(
1532 batch2.is_some(),
1533 "Reader must not return EOF while a writer clone is still alive"
1534 );
1535 let batch2 = batch2.unwrap()?;
1536 assert_eq!(batch2.num_rows(), 10);
1537 let col = batch2
1538 .column(0)
1539 .as_any()
1540 .downcast_ref::<Int32Array>()
1541 .unwrap();
1542 assert_eq!(col.value(0), 10);
1543
1544 writer2_handle.await.unwrap();
1545
1546 // All writers dropped — reader should see real EOF now.
1547 assert!(reader.next().await.is_none());
1548
1549 Ok(())
1550 }
1551
1552 #[tokio::test]
1553 async fn test_disk_usage_decreases_as_files_consumed() -> Result<()> {
1554 use datafusion_execution::runtime_env::RuntimeEnvBuilder;
1555
1556 // Test configuration
1557 const NUM_BATCHES: usize = 3;
1558 const ROWS_PER_BATCH: usize = 100;
1559
1560 // Step 1: Create a test batch and measure its size
1561 let batch = create_test_batch(0, ROWS_PER_BATCH);
1562 let batch_size = batch.get_array_memory_size();
1563
1564 // Step 2: Configure file rotation to approximately 1 batch per file
1565 // Create a custom RuntimeEnv so we can access the DiskManager
1566 let runtime = Arc::new(RuntimeEnvBuilder::default().build()?);
1567 let disk_manager = Arc::clone(&runtime.disk_manager);
1568
1569 let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0);
1570 let schema = create_test_schema();
1571 let spill_manager = Arc::new(SpillManager::new(runtime, metrics.clone(), schema));
1572
1573 let (writer, mut reader) = spsc_channel(batch_size - 1, spill_manager);
1574
1575 // Step 3: Write NUM_BATCHES batches to create approximately NUM_BATCHES files
1576 for i in 0..NUM_BATCHES {
1577 let start = (i * ROWS_PER_BATCH) as i32;
1578 writer.push_batch(&create_test_batch(start, ROWS_PER_BATCH))?;
1579 }
1580
1581 // Check how many files were created (should be at least a few due to file rotation)
1582 let file_count = metrics.spill_file_count.value();
1583 assert_eq!(
1584 file_count, NUM_BATCHES,
1585 "Expected at {NUM_BATCHES} files with rotation, got {file_count}"
1586 );
1587
1588 // Step 4: Verify initial disk usage reflects all files
1589 let initial_disk_usage = disk_manager.used_disk_space();
1590 assert!(
1591 initial_disk_usage > 0,
1592 "Expected disk usage > 0 after writing batches, got {initial_disk_usage}"
1593 );
1594
1595 // Step 5: Read NUM_BATCHES - 1 batches (all but 1)
1596 // As each file is fully consumed, it should be dropped and disk usage should decrease
1597 for i in 0..(NUM_BATCHES - 1) {
1598 let result = reader.next().await.unwrap()?;
1599 assert_eq!(result.num_rows(), ROWS_PER_BATCH);
1600
1601 let col = result
1602 .column(0)
1603 .as_any()
1604 .downcast_ref::<Int32Array>()
1605 .unwrap();
1606 assert_eq!(col.value(0), (i * ROWS_PER_BATCH) as i32);
1607 }
1608
1609 // Step 6: Verify disk usage decreased but is not zero (at least 1 batch remains)
1610 let partial_disk_usage = disk_manager.used_disk_space();
1611 assert!(
1612 partial_disk_usage > 0
1613 && partial_disk_usage < (batch_size * NUM_BATCHES * 2) as u64,
1614 "Disk usage should be > 0 with remaining batches"
1615 );
1616 assert!(
1617 partial_disk_usage < initial_disk_usage,
1618 "Disk usage should have decreased after reading most batches: initial={initial_disk_usage}, partial={partial_disk_usage}"
1619 );
1620
1621 // Step 7: Read the final batch
1622 let result = reader.next().await.unwrap()?;
1623 assert_eq!(result.num_rows(), ROWS_PER_BATCH);
1624
1625 // Step 8: Drop writer first to signal no more data will be written
1626 // The reader has infinite stream semantics and will wait for the writer
1627 // to be dropped before returning None
1628 drop(writer);
1629
1630 // Verify we've read all batches - now the reader should return None
1631 assert!(
1632 reader.next().await.is_none(),
1633 "Should have no more batches to read"
1634 );
1635
1636 // Step 9: Drop reader to release all references
1637 drop(reader);
1638
1639 // Step 10: Verify complete cleanup - disk usage should be 0
1640 let final_disk_usage = disk_manager.used_disk_space();
1641 assert_eq!(
1642 final_disk_usage, 0,
1643 "Disk usage should be 0 after all files dropped, got {final_disk_usage}"
1644 );
1645
1646 Ok(())
1647 }
1648}