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