Skip to main content

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}