Skip to main content

uqa_execution/
spill.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Disk-backed spill buffer for blocking operators (`Sort`,
8//! `HashAggregate`, `Window`).
9//!
10//! The budget is measured in the exact number of bytes each batch occupies in
11//! the spill encoding. [`SpillBuffer::push`] automatically flushes before a
12//! successful push could leave more encoded bytes in memory than the budget.
13//! Draining restores spilled batches first and then any in-memory tail,
14//! preserving input order. The temporary file is removed when the buffer (or
15//! its active drain iterator) is dropped.
16
17use std::fs::File;
18use std::io::{BufReader, Read, Seek, SeekFrom, Write};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22use crate::batch::{Batch, OwnedPhysicalRow, PhysicalRow, RowSchema};
23use crate::physical::ExecResult;
24use tempfile::NamedTempFile;
25
26mod format;
27
28use format::{
29    append_batches, decode_batch, decode_physical_row_record, encode_physical_row_record,
30    encoded_batch_overhead_size, encoded_batch_size, encoded_physical_row_record_size,
31    open_spill_reader, read_bounded_spill_record, spill_error, RECORD_PREFIX_BYTES,
32};
33
34const SPILL_MAGIC: &[u8] = b"UQA-SPILL\x01\n";
35
36/// Incremental exact size of one not-yet-encoded batch. When the first row with lock origins arrives, the binary format adds an empty origin-count field to every preceding origin-free row, so accounting must update those already-buffered rows as well as the new record.
37#[derive(Clone, Copy)]
38pub(crate) struct EncodedBatchSizer {
39    physical_width: usize,
40    bytes: usize,
41    origin_free_rows: usize,
42    has_lock_origins: bool,
43}
44
45impl EncodedBatchSizer {
46    pub(crate) fn new(schema: &RowSchema) -> ExecResult<Self> {
47        Ok(Self {
48            physical_width: schema.physical_width(),
49            bytes: encoded_batch_overhead_size(schema)?,
50            origin_free_rows: 0,
51            has_lock_origins: false,
52        })
53    }
54
55    pub(crate) fn append(&mut self, row: &PhysicalRow) -> ExecResult<()> {
56        let mut additional = encoded_physical_row_record_size(row, self.physical_width)?;
57        if row.lock_origins().is_empty() {
58            self.origin_free_rows = self.origin_free_rows.checked_add(1).ok_or_else(|| {
59                spill_error("incremental spill batch origin-free row count overflow")
60            })?;
61            if self.has_lock_origins {
62                additional = additional.checked_add(8).ok_or_else(|| {
63                    spill_error("incremental spill batch lock-origin size overflow")
64                })?;
65            }
66        } else if !self.has_lock_origins {
67            let preceding_metadata = self.origin_free_rows.checked_mul(8).ok_or_else(|| {
68                spill_error("incremental spill batch lock-origin metadata overflow")
69            })?;
70            additional = additional
71                .checked_add(preceding_metadata)
72                .ok_or_else(|| spill_error("incremental spill batch lock-origin size overflow"))?;
73            self.has_lock_origins = true;
74        }
75        self.bytes = self
76            .bytes
77            .checked_add(additional)
78            .ok_or_else(|| spill_error("incremental spill batch size overflow"))?;
79        Ok(())
80    }
81
82    pub(crate) fn bytes(self) -> usize {
83        self.bytes
84    }
85}
86
87/// Append-only batch buffer with an encoded-byte memory budget.
88///
89/// The budget is exact for the serialized representation and does not claim to
90/// be the Rust allocator's resident-byte accounting. At most one incoming or
91/// decoded batch can itself be larger than the budget; successful pushes do not
92/// retain such an oversized batch in memory.
93pub struct SpillBuffer {
94    schema: Option<RowSchema>,
95    batches: Vec<Batch>,
96    rows: usize,
97    in_memory_rows: usize,
98    in_memory_bytes: usize,
99    max_in_memory_record_bytes: usize,
100    /// Encoded-byte budget. Set to `usize::MAX` to disable spilling.
101    budget_bytes: usize,
102    spill_directory: Option<PathBuf>,
103    spill_file: Option<NamedTempFile>,
104    spilled_batches: usize,
105    spilled_rows: usize,
106    spilled_bytes: usize,
107    max_spilled_record_bytes: usize,
108}
109
110impl SpillBuffer {
111    pub fn new(budget_bytes: usize) -> Self {
112        Self {
113            schema: None,
114            batches: Vec::new(),
115            rows: 0,
116            in_memory_rows: 0,
117            in_memory_bytes: 0,
118            max_in_memory_record_bytes: 0,
119            budget_bytes,
120            spill_directory: None,
121            spill_file: None,
122            spilled_batches: 0,
123            spilled_rows: 0,
124            spilled_bytes: 0,
125            max_spilled_record_bytes: 0,
126        }
127    }
128
129    /// Create a buffer whose temporary spill file will be placed in `directory`.
130    ///
131    /// File creation is deferred until the first spill. This is primarily useful
132    /// when an engine has a dedicated temporary-data volume.
133    pub fn new_in(budget_bytes: usize, directory: impl Into<PathBuf>) -> Self {
134        let mut buffer = Self::new(budget_bytes);
135        buffer.spill_directory = Some(directory.into());
136        buffer
137    }
138
139    pub fn unbounded() -> Self {
140        Self::new(usize::MAX)
141    }
142
143    /// Append a batch, spilling automatically when required by the byte budget.
144    ///
145    /// Returns `true` if this push wrote one or more batches to disk. If disk
146    /// creation, encoding, or writing fails, the new batch and all earlier
147    /// batches remain owned by the buffer and the error is returned.
148    pub fn push(&mut self, batch: Batch) -> ExecResult<bool> {
149        if let Some(schema) = self.schema.as_ref() {
150            if schema != &batch.schema {
151                return Err(spill_error(format!(
152                    "spill buffer schema mismatch: expected {:?}, got {:?}",
153                    schema.columns(),
154                    batch.schema.columns()
155                )));
156            }
157        } else {
158            self.schema = Some(batch.schema.clone());
159        }
160        let batch_rows = batch.rows.len();
161        let next_rows = self
162            .rows
163            .checked_add(batch_rows)
164            .ok_or_else(|| spill_error("spill buffer row count overflow"))?;
165        let batch_bytes = match Self::encoded_size(&batch) {
166            Ok(bytes) => bytes,
167            Err(error) => {
168                // Preserve ownership even when an exotic value or an encoded
169                // size overflow prevents budget accounting. The failed
170                // operator will abort, but no row silently disappears.
171                self.retain_batch(batch, usize::MAX);
172                return Err(error);
173            }
174        };
175        let would_exceed = self
176            .in_memory_bytes
177            .checked_add(batch_bytes)
178            .is_none_or(|bytes| bytes > self.budget_bytes);
179
180        let mut spilled = false;
181        if would_exceed && !self.batches.is_empty() {
182            if let Err(error) = self.spill_pending() {
183                self.retain_batch(batch, batch_bytes);
184                return Err(error);
185            }
186            spilled = true;
187        }
188
189        let next_in_memory_rows = self
190            .in_memory_rows
191            .checked_add(batch_rows)
192            .ok_or_else(|| spill_error("spill buffer in-memory row count overflow"))?;
193        let next_in_memory_bytes = self
194            .in_memory_bytes
195            .checked_add(batch_bytes)
196            .ok_or_else(|| spill_error("spill buffer in-memory byte count overflow"))?;
197        self.rows = next_rows;
198        self.in_memory_rows = next_in_memory_rows;
199        self.in_memory_bytes = next_in_memory_bytes;
200        self.max_in_memory_record_bytes = self.max_in_memory_record_bytes.max(batch_bytes);
201        self.batches.push(batch);
202
203        // A single encoded batch may exceed work_mem. It must pass through
204        // memory once, but a successful push never retains it there.
205        if self.in_memory_bytes > self.budget_bytes {
206            self.spill_pending()?;
207            spilled = true;
208        }
209        Ok(spilled)
210    }
211
212    /// Exact byte count used for budget accounting, including the record
213    /// length prefix written to disk.
214    pub fn encoded_size(batch: &Batch) -> ExecResult<usize> {
215        encoded_batch_size(batch)
216    }
217
218    /// Total buffered rows, including rows already written to disk.
219    pub fn rows(&self) -> usize {
220        self.rows
221    }
222
223    /// Rows currently retained in memory.
224    pub fn in_memory_rows(&self) -> usize {
225        self.in_memory_rows
226    }
227
228    /// Exact encoded bytes currently retained in memory.
229    pub fn in_memory_bytes(&self) -> usize {
230        self.in_memory_bytes
231    }
232
233    pub fn budget_bytes(&self) -> usize {
234        self.budget_bytes
235    }
236
237    pub fn over_budget(&self) -> bool {
238        self.in_memory_bytes > self.budget_bytes
239    }
240
241    pub fn has_spilled(&self) -> bool {
242        self.spill_file.is_some()
243    }
244
245    pub fn spilled_rows(&self) -> usize {
246        self.spilled_rows
247    }
248
249    pub fn spilled_batches(&self) -> usize {
250        self.spilled_batches
251    }
252
253    pub fn spilled_bytes(&self) -> usize {
254        self.spilled_bytes
255    }
256
257    /// Path of the live spill file, if one has been created.
258    ///
259    /// The path is diagnostic only and becomes invalid as soon as the buffer or
260    /// the drain iterator that owns the file is dropped.
261    pub fn spill_path(&self) -> Option<&Path> {
262        self.spill_file.as_ref().map(NamedTempFile::path)
263    }
264
265    /// Flush all pending in-memory batches when the byte budget is exceeded.
266    ///
267    /// Returns `true` when batches were written. A failed append is rolled back
268    /// to the previous file length and the pending batches remain in memory, so
269    /// callers never observe a silent partial spill.
270    pub fn spill_if_over_budget(&mut self) -> ExecResult<bool> {
271        if !self.over_budget() || self.batches.is_empty() {
272            return Ok(false);
273        }
274        self.spill_pending()
275    }
276
277    /// Force all pending in-memory batches to disk regardless of the budget.
278    ///
279    /// This is useful at a blocking-operator phase boundary. It returns `false`
280    /// when there is nothing pending.
281    pub fn spill_pending(&mut self) -> ExecResult<bool> {
282        if self.batches.is_empty() {
283            return Ok(false);
284        }
285
286        // Reject metadata overflow before writing. An error after a successful
287        // append would leave a retry able to duplicate records while the
288        // published counters disagreed with the file.
289        let next_spilled_batches = self
290            .spilled_batches
291            .checked_add(self.batches.len())
292            .ok_or_else(|| spill_error("spill batch count overflow"))?;
293        let next_spilled_rows = self
294            .spilled_rows
295            .checked_add(self.in_memory_rows)
296            .ok_or_else(|| spill_error("spill row count overflow"))?;
297        let next_spilled_bytes = self
298            .spilled_bytes
299            .checked_add(self.in_memory_bytes)
300            .ok_or_else(|| spill_error("spill byte count overflow"))?;
301        let next_max_spilled_record_bytes = self
302            .max_spilled_record_bytes
303            .max(self.max_in_memory_record_bytes);
304
305        if let Some(file) = self.spill_file.as_mut() {
306            append_batches(file.as_file_mut(), &self.batches)?;
307        } else {
308            let mut file = self.create_spill_file()?;
309            append_batches(file.as_file_mut(), &self.batches)?;
310            self.spill_file = Some(file);
311        }
312
313        self.spilled_batches = next_spilled_batches;
314        self.spilled_rows = next_spilled_rows;
315        self.spilled_bytes = next_spilled_bytes;
316        self.max_spilled_record_bytes = next_max_spilled_record_bytes;
317        self.batches.clear();
318        self.in_memory_rows = 0;
319        self.in_memory_bytes = 0;
320        self.max_in_memory_record_bytes = 0;
321        Ok(true)
322    }
323
324    /// Open a repeatable streaming reader without consuming this buffer.
325    ///
326    /// Spilled batches are decoded one at a time. The in-memory tail is cloned
327    /// one batch at a time only when the reader reaches it.
328    pub fn reader(&self) -> ExecResult<SpillReader<'_>> {
329        let reader = self
330            .spill_file
331            .as_ref()
332            .map(open_spill_reader)
333            .transpose()?;
334        let disk_finished = reader.is_none();
335        Ok(SpillReader {
336            reader,
337            memory: self.batches.iter(),
338            disk_finished,
339            failed: false,
340            max_record_bytes: self.max_spilled_record_bytes,
341            expected_schema: self.schema.clone(),
342        })
343    }
344
345    /// Open a repeatable physical-row stream without collecting all batches.
346    pub fn read_rows(&self) -> ExecResult<SpillRows<SpillReader<'_>>> {
347        self.reader().map(SpillRows::new)
348    }
349
350    /// Drain buffered batches in their original input order.
351    ///
352    /// The returned iterator owns the temporary file. Each disk read or decode
353    /// failure is returned as a [`crate::physical::ExecError`], and dropping the
354    /// iterator early still removes the temporary file.
355    pub fn drain(&mut self) -> ExecResult<SpillDrain> {
356        let reader = self
357            .spill_file
358            .as_ref()
359            .map(open_spill_reader)
360            .transpose()?;
361        let spill_file = self.spill_file.take();
362        let memory = std::mem::take(&mut self.batches).into_iter();
363        let expected_schema = self.schema.take();
364
365        self.rows = 0;
366        self.in_memory_rows = 0;
367        self.in_memory_bytes = 0;
368        self.max_in_memory_record_bytes = 0;
369        self.spilled_batches = 0;
370        self.spilled_rows = 0;
371        self.spilled_bytes = 0;
372        let max_record_bytes = std::mem::take(&mut self.max_spilled_record_bytes);
373
374        let disk_finished = reader.is_none();
375        Ok(SpillDrain {
376            reader,
377            spill_file,
378            memory,
379            disk_finished,
380            failed: false,
381            max_record_bytes,
382            expected_schema,
383        })
384    }
385
386    /// Drain and materialize every restored batch.
387    pub fn drain_all(&mut self) -> ExecResult<Vec<Batch>> {
388        self.drain()?.collect()
389    }
390
391    /// Consume the buffer as a physical-row stream without collecting batches.
392    pub fn drain_rows(&mut self) -> ExecResult<SpillRows<SpillDrain>> {
393        self.drain().map(SpillRows::new)
394    }
395
396    /// Discard all buffered data and remove any spill file.
397    pub fn clear(&mut self) {
398        self.schema = None;
399        self.batches.clear();
400        self.spill_file = None;
401        self.rows = 0;
402        self.in_memory_rows = 0;
403        self.in_memory_bytes = 0;
404        self.max_in_memory_record_bytes = 0;
405        self.spilled_batches = 0;
406        self.spilled_rows = 0;
407        self.spilled_bytes = 0;
408        self.max_spilled_record_bytes = 0;
409    }
410
411    /// Seal this buffer as an immutable, cheaply cloneable materialization.
412    /// Batches that fit within the configured byte budget remain in memory;
413    /// once spilling has started, every pending batch is flushed and readers
414    /// reopen the file independently. Both forms support repeatable scans
415    /// without collecting the complete input again.
416    pub fn into_shared(mut self, schema: impl Into<RowSchema>) -> ExecResult<SharedSpill> {
417        let schema = schema.into();
418        if let Some(actual) = self.schema.as_ref() {
419            if actual != &schema {
420                return Err(spill_error(format!(
421                    "shared spill schema mismatch: expected {:?}, got {:?}",
422                    schema.columns(),
423                    actual.columns()
424                )));
425            }
426        }
427        let rows = self.rows;
428        let storage = if self.spill_file.is_none() {
429            let batches = std::mem::take(&mut self.batches);
430            SharedSpillStorage::Memory(batches)
431        } else {
432            self.spill_pending()?;
433            SharedSpillStorage::Disk(
434                self.spill_file
435                    .take()
436                    .expect("spill file exists after flushing shared materialization"),
437            )
438        };
439        Ok(SharedSpill {
440            inner: Arc::new(SharedSpillInner {
441                storage,
442                schema,
443                rows,
444                max_record_bytes: self.max_spilled_record_bytes,
445            }),
446        })
447    }
448
449    fn create_spill_file(&self) -> ExecResult<NamedTempFile> {
450        let mut file = match &self.spill_directory {
451            Some(directory) => NamedTempFile::new_in(directory).map_err(|error| {
452                spill_error(format!(
453                    "failed to create spill file in {}: {error}",
454                    directory.display()
455                ))
456            })?,
457            None => NamedTempFile::new()
458                .map_err(|error| spill_error(format!("failed to create spill file: {error}")))?,
459        };
460        file.as_file_mut()
461            .write_all(SPILL_MAGIC)
462            .map_err(|error| spill_error(format!("failed to initialize spill file: {error}")))?;
463        file.as_file_mut()
464            .flush()
465            .map_err(|error| spill_error(format!("failed to flush spill header: {error}")))?;
466        Ok(file)
467    }
468
469    fn retain_batch(&mut self, batch: Batch, encoded_bytes: usize) {
470        self.rows = self.rows.saturating_add(batch.rows.len());
471        self.in_memory_rows = self.in_memory_rows.saturating_add(batch.rows.len());
472        self.in_memory_bytes = self.in_memory_bytes.saturating_add(encoded_bytes);
473        self.max_in_memory_record_bytes = self.max_in_memory_record_bytes.max(encoded_bytes);
474        self.batches.push(batch);
475    }
476}
477
478enum SharedSpillStorage {
479    Memory(Vec<Batch>),
480    Disk(NamedTempFile),
481}
482
483struct SharedSpillInner {
484    storage: SharedSpillStorage,
485    schema: RowSchema,
486    rows: usize,
487    max_record_bytes: usize,
488}
489
490/// Immutable repeatable row materialization bounded by the source buffer's
491/// memory budget and backed by a temporary file after that budget is exceeded.
492#[derive(Clone)]
493pub struct SharedSpill {
494    inner: Arc<SharedSpillInner>,
495}
496
497impl SharedSpill {
498    pub fn schema(&self) -> &[String] {
499        self.inner.schema.columns()
500    }
501
502    pub fn row_schema(&self) -> &RowSchema {
503        &self.inner.schema
504    }
505
506    pub fn rows(&self) -> usize {
507        self.inner.rows
508    }
509
510    /// Whether this materialization crossed its memory budget and uses disk.
511    pub fn has_spilled(&self) -> bool {
512        matches!(self.inner.storage, SharedSpillStorage::Disk(_))
513    }
514
515    pub fn reader(&self) -> ExecResult<SharedSpillReader> {
516        let source = Arc::clone(&self.inner);
517        Self::reader_from_source(source)
518    }
519
520    /// Consume this materialization into a one-shot reader.
521    ///
522    /// When the in-memory materialization has no other owners, batches move
523    /// directly into the reader instead of being deep-cloned. Shared and disk
524    /// materializations retain the independent-reader behavior of [`Self::reader`].
525    pub fn into_reader(self) -> ExecResult<SharedSpillReader> {
526        match Arc::try_unwrap(self.inner) {
527            Ok(SharedSpillInner {
528                storage: SharedSpillStorage::Memory(batches),
529                schema,
530                max_record_bytes,
531                ..
532            }) => Ok(SharedSpillReader {
533                reader: SharedSpillReaderSource::OwnedMemory(batches.into_iter()),
534                source: None,
535                failed: false,
536                max_record_bytes,
537                expected_schema: Some(schema),
538            }),
539            Ok(inner) => Self::reader_from_source(Arc::new(inner)),
540            Err(source) => Self::reader_from_source(source),
541        }
542    }
543
544    fn reader_from_source(source: Arc<SharedSpillInner>) -> ExecResult<SharedSpillReader> {
545        let reader = match &source.storage {
546            SharedSpillStorage::Memory(_) => SharedSpillReaderSource::Memory { next_batch: 0 },
547            SharedSpillStorage::Disk(file) => {
548                SharedSpillReaderSource::Disk(open_spill_reader(file)?)
549            }
550        };
551        let max_record_bytes = source.max_record_bytes;
552        let expected_schema = Some(source.schema.clone());
553        Ok(SharedSpillReader {
554            reader,
555            source: Some(source),
556            failed: false,
557            max_record_bytes,
558            expected_schema,
559        })
560    }
561
562    /// Open an independent physical-row reader without collecting the spill's
563    /// batches or row count in memory.
564    pub fn read_rows(&self) -> ExecResult<SpillRows<SharedSpillReader>> {
565        self.reader().map(SpillRows::new)
566    }
567}
568
569enum SharedSpillReaderSource {
570    Memory { next_batch: usize },
571    OwnedMemory(std::vec::IntoIter<Batch>),
572    Disk(BufReader<File>),
573}
574
575fn validate_decoded_schema(batch: Batch, expected_schema: Option<&RowSchema>) -> ExecResult<Batch> {
576    if expected_schema.is_none_or(|expected| expected == &batch.schema) {
577        return Ok(batch);
578    }
579    let expected = expected_schema.expect("schema presence checked above");
580    Err(spill_error(format!(
581        "spill batch schema mismatch: expected {:?}, got {:?}",
582        expected.columns(),
583        batch.schema.columns()
584    )))
585}
586
587/// Reader for a [`SharedSpill`]. Independent readers retain the shared source;
588/// a consuming reader may instead own unique in-memory batches directly.
589pub struct SharedSpillReader {
590    reader: SharedSpillReaderSource,
591    source: Option<Arc<SharedSpillInner>>,
592    failed: bool,
593    max_record_bytes: usize,
594    expected_schema: Option<RowSchema>,
595}
596
597impl Iterator for SharedSpillReader {
598    type Item = ExecResult<Batch>;
599
600    fn next(&mut self) -> Option<Self::Item> {
601        if self.failed {
602            return None;
603        }
604        match &mut self.reader {
605            SharedSpillReaderSource::Memory { next_batch } => {
606                let source = self
607                    .source
608                    .as_ref()
609                    .expect("shared memory reader retains its source");
610                let SharedSpillStorage::Memory(batches) = &source.storage else {
611                    unreachable!("shared materialization reader/storage mismatch")
612                };
613                let batch = batches.get(*next_batch)?.clone();
614                *next_batch += 1;
615                Some(validate_decoded_schema(
616                    batch,
617                    self.expected_schema.as_ref(),
618                ))
619            }
620            SharedSpillReaderSource::OwnedMemory(batches) => batches
621                .next()
622                .map(|batch| validate_decoded_schema(batch, self.expected_schema.as_ref())),
623            SharedSpillReaderSource::Disk(reader) => {
624                match read_bounded_spill_record(reader, self.max_record_bytes, "shared spill batch")
625                {
626                    Ok(None) => None,
627                    Ok(Some(record)) => {
628                        let decoded = decode_batch(&record).and_then(|batch| {
629                            validate_decoded_schema(batch, self.expected_schema.as_ref())
630                        });
631                        if decoded.is_err() {
632                            self.failed = true;
633                        }
634                        Some(decoded)
635                    }
636                    Err(error) => {
637                        self.failed = true;
638                        Some(Err(spill_error(format!(
639                            "failed to read shared spill batch: {error}"
640                        ))))
641                    }
642                }
643            }
644        }
645    }
646}
647
648/// Restoring iterator returned by [`SpillBuffer::drain`].
649pub struct SpillDrain {
650    reader: Option<BufReader<File>>,
651    // Keep the named file alive until disk iteration finishes or the iterator
652    // is dropped. Its Drop implementation unlinks the temporary file.
653    spill_file: Option<NamedTempFile>,
654    memory: std::vec::IntoIter<Batch>,
655    disk_finished: bool,
656    failed: bool,
657    max_record_bytes: usize,
658    expected_schema: Option<RowSchema>,
659}
660
661impl Iterator for SpillDrain {
662    type Item = ExecResult<Batch>;
663
664    fn next(&mut self) -> Option<Self::Item> {
665        if self.failed {
666            return None;
667        }
668
669        if !self.disk_finished {
670            let Some(reader) = self.reader.as_mut() else {
671                self.failed = true;
672                self.disk_finished = true;
673                return Some(Err(spill_error(
674                    "spill drain entered disk phase without a reader",
675                )));
676            };
677            match read_bounded_spill_record(reader, self.max_record_bytes, "spill batch") {
678                Ok(None) => {
679                    self.disk_finished = true;
680                    self.reader = None;
681                    self.spill_file = None;
682                }
683                Ok(Some(record)) => {
684                    let decoded = decode_batch(&record).and_then(|batch| {
685                        validate_decoded_schema(batch, self.expected_schema.as_ref())
686                    });
687                    if decoded.is_err() {
688                        self.failed = true;
689                    }
690                    return Some(decoded);
691                }
692                Err(error) => {
693                    self.failed = true;
694                    return Some(Err(spill_error(format!(
695                        "failed to read spill batch: {error}"
696                    ))));
697                }
698            }
699        }
700
701        self.memory
702            .next()
703            .map(|batch| validate_decoded_schema(batch, self.expected_schema.as_ref()))
704    }
705
706    fn size_hint(&self) -> (usize, Option<usize>) {
707        let lower = if self.disk_finished {
708            self.memory.len()
709        } else {
710            0
711        };
712        (lower, None)
713    }
714}
715
716/// Repeatable, non-consuming batch reader returned by [`SpillBuffer::reader`].
717pub struct SpillReader<'a> {
718    reader: Option<BufReader<File>>,
719    memory: std::slice::Iter<'a, Batch>,
720    disk_finished: bool,
721    failed: bool,
722    max_record_bytes: usize,
723    expected_schema: Option<RowSchema>,
724}
725
726impl Iterator for SpillReader<'_> {
727    type Item = ExecResult<Batch>;
728
729    fn next(&mut self) -> Option<Self::Item> {
730        if self.failed {
731            return None;
732        }
733
734        if !self.disk_finished {
735            let Some(reader) = self.reader.as_mut() else {
736                self.failed = true;
737                self.disk_finished = true;
738                return Some(Err(spill_error(
739                    "spill reader entered disk phase without a file reader",
740                )));
741            };
742            match read_bounded_spill_record(reader, self.max_record_bytes, "spill batch") {
743                Ok(None) => {
744                    self.disk_finished = true;
745                    self.reader = None;
746                }
747                Ok(Some(record)) => {
748                    let decoded = decode_batch(&record).and_then(|batch| {
749                        validate_decoded_schema(batch, self.expected_schema.as_ref())
750                    });
751                    if decoded.is_err() {
752                        self.failed = true;
753                    }
754                    return Some(decoded);
755                }
756                Err(error) => {
757                    self.failed = true;
758                    return Some(Err(spill_error(format!(
759                        "failed to read spill batch: {error}"
760                    ))));
761                }
762            }
763        }
764
765        self.memory
766            .next()
767            .cloned()
768            .map(|batch| validate_decoded_schema(batch, self.expected_schema.as_ref()))
769    }
770
771    fn size_hint(&self) -> (usize, Option<usize>) {
772        let lower = if self.disk_finished {
773            self.memory.len()
774        } else {
775            0
776        };
777        (lower, None)
778    }
779}
780
781/// Physical-row flattening adapter for [`SpillReader`] and [`SpillDrain`].
782pub struct SpillRows<I> {
783    batches: I,
784    current_schema: Option<RowSchema>,
785    current: std::vec::IntoIter<PhysicalRow>,
786}
787
788/// Disk-only physical-row store with constant-memory positional lookup.
789///
790/// Each row retains the exact physical layout described by `schema` and is
791/// encoded with the same positional tagged representation as [`SpillBuffer`].
792/// Record offsets live in a second temporary file, so even a partition with
793/// billions of rows does not create an in-memory offset table. A single decoded
794/// physical row is the only input-sized allocation retained by [`Self::get`].
795/// Both files are unlinked by `NamedTempFile` on drop.
796pub struct IndexedSpill {
797    schema: RowSchema,
798    data: NamedTempFile,
799    offsets: NamedTempFile,
800    rows: u64,
801    encoded_bytes: u64,
802}
803
804impl IndexedSpill {
805    pub fn new(input_schema: RowSchema) -> ExecResult<Self> {
806        Ok(Self {
807            schema: input_schema,
808            data: NamedTempFile::new().map_err(|error| {
809                spill_error(format!("failed to create indexed spill data: {error}"))
810            })?,
811            offsets: NamedTempFile::new().map_err(|error| {
812                spill_error(format!("failed to create indexed spill offsets: {error}"))
813            })?,
814            rows: 0,
815            encoded_bytes: 0,
816        })
817    }
818
819    pub fn len(&self) -> u64 {
820        self.rows
821    }
822
823    pub fn is_empty(&self) -> bool {
824        self.rows == 0
825    }
826
827    pub fn encoded_bytes(&self) -> u64 {
828        self.encoded_bytes
829    }
830
831    pub fn row_schema(&self) -> &RowSchema {
832        &self.schema
833    }
834
835    pub(crate) fn encoded_row_size(schema: &RowSchema, row: &PhysicalRow) -> ExecResult<usize> {
836        encoded_physical_row_record_size(row, schema.physical_width())?
837            .checked_add(RECORD_PREFIX_BYTES)
838            .ok_or_else(|| spill_error("indexed spill row size overflow"))
839    }
840
841    /// Append one indivisible row. Failed writes roll both files back to their
842    /// original lengths, so callers never observe a partial index entry.
843    pub fn push(&mut self, row: &PhysicalRow) -> ExecResult<()> {
844        let payload = encode_physical_row_record(row, self.schema.physical_width())?;
845        let length = u64::try_from(payload.len())
846            .map_err(|_| spill_error("indexed spill row is too large"))?;
847        // Validate every piece of metadata before touching either file.  A
848        // counter overflow after the append would otherwise return an error
849        // while leaving a physically visible row whose offset/count was not
850        // published consistently.
851        let next_rows = self
852            .rows
853            .checked_add(1)
854            .ok_or_else(|| spill_error("indexed spill row count overflow"))?;
855        let record_bytes = length
856            .checked_add(8)
857            .ok_or_else(|| spill_error("indexed spill row length overflow"))?;
858        let next_encoded_bytes = self
859            .encoded_bytes
860            .checked_add(record_bytes)
861            .ok_or_else(|| spill_error("indexed spill byte count overflow"))?;
862        let data_length = self
863            .data
864            .as_file_mut()
865            .seek(SeekFrom::End(0))
866            .map_err(|error| spill_error(format!("failed to seek indexed spill data: {error}")))?;
867        let offsets_length =
868            self.offsets
869                .as_file_mut()
870                .seek(SeekFrom::End(0))
871                .map_err(|error| {
872                    spill_error(format!("failed to seek indexed spill offsets: {error}"))
873                })?;
874
875        let write_result = (|| -> std::io::Result<()> {
876            self.data.as_file_mut().write_all(&length.to_le_bytes())?;
877            self.data.as_file_mut().write_all(&payload)?;
878            self.data.as_file_mut().flush()?;
879            self.offsets
880                .as_file_mut()
881                .write_all(&data_length.to_le_bytes())?;
882            self.offsets.as_file_mut().flush()
883        })();
884        if let Err(error) = write_result {
885            let data_rollback = self.data.as_file_mut().set_len(data_length);
886            let offsets_rollback = self.offsets.as_file_mut().set_len(offsets_length);
887            let rollback_error = match (data_rollback, offsets_rollback) {
888                (Ok(()), Ok(())) => None,
889                (Err(data), Ok(())) => Some(format!("data rollback failed: {data}")),
890                (Ok(()), Err(offsets)) => Some(format!("offset rollback failed: {offsets}")),
891                (Err(data), Err(offsets)) => Some(format!(
892                    "data rollback failed: {data}; offset rollback failed: {offsets}"
893                )),
894            };
895            if let Some(rollback) = rollback_error {
896                return Err(spill_error(format!(
897                    "failed to append indexed spill row: {error}; {rollback}"
898                )));
899            }
900            return Err(spill_error(format!(
901                "failed to append indexed spill row: {error}"
902            )));
903        }
904
905        self.rows = next_rows;
906        self.encoded_bytes = next_encoded_bytes;
907        Ok(())
908    }
909
910    /// Decode the row at `index` without loading any other row or index entry.
911    pub fn get(&mut self, index: u64) -> ExecResult<PhysicalRow> {
912        if index >= self.rows {
913            return Err(spill_error(format!(
914                "indexed spill row {index} is outside 0..{}",
915                self.rows
916            )));
917        }
918        let expected_offsets_length = self
919            .rows
920            .checked_mul(8)
921            .ok_or_else(|| spill_error("indexed spill offsets length overflow"))?;
922        let actual_offsets_length = self
923            .offsets
924            .as_file()
925            .metadata()
926            .map_err(|error| {
927                spill_error(format!("failed to inspect indexed spill offsets: {error}"))
928            })?
929            .len();
930        if actual_offsets_length != expected_offsets_length {
931            return Err(spill_error(format!(
932                "indexed spill offsets length {actual_offsets_length} does not match expected {expected_offsets_length}"
933            )));
934        }
935        let data_length = self
936            .data
937            .as_file()
938            .metadata()
939            .map_err(|error| spill_error(format!("failed to inspect indexed spill data: {error}")))?
940            .len();
941        let offset_position = index
942            .checked_mul(8)
943            .ok_or_else(|| spill_error("indexed spill offset overflow"))?;
944        let offset = read_indexed_offset(self.offsets.as_file_mut(), offset_position)?;
945        let record_end = if index
946            .checked_add(1)
947            .ok_or_else(|| spill_error("indexed spill row index overflow"))?
948            < self.rows
949        {
950            read_indexed_offset(
951                self.offsets.as_file_mut(),
952                offset_position
953                    .checked_add(8)
954                    .ok_or_else(|| spill_error("indexed spill next offset overflow"))?,
955            )?
956        } else {
957            data_length
958        };
959        let payload_start = offset
960            .checked_add(8)
961            .ok_or_else(|| spill_error("indexed spill payload offset overflow"))?;
962        if payload_start > record_end || record_end > data_length {
963            return Err(spill_error(format!(
964                "indexed spill record bounds {offset}..{record_end} are outside data length {data_length}"
965            )));
966        }
967        self.data
968            .as_file_mut()
969            .seek(SeekFrom::Start(offset))
970            .map_err(|error| spill_error(format!("failed to seek indexed spill row: {error}")))?;
971        let mut length = [0_u8; 8];
972        self.data
973            .as_file_mut()
974            .read_exact(&mut length)
975            .map_err(|error| {
976                spill_error(format!("failed to read indexed spill length: {error}"))
977            })?;
978        let declared_length = u64::from_le_bytes(length);
979        let available_length = record_end - payload_start;
980        if declared_length != available_length {
981            return Err(spill_error(format!(
982                "indexed spill row length {declared_length} does not match record payload {available_length}"
983            )));
984        }
985        let length = usize::try_from(declared_length)
986            .map_err(|_| spill_error("indexed spill row length is outside address space"))?;
987        let mut payload = Vec::new();
988        payload.try_reserve_exact(length).map_err(|error| {
989            spill_error(format!(
990                "unable to allocate indexed spill row payload of {length} bytes: {error}"
991            ))
992        })?;
993        payload.resize(length, 0);
994        self.data
995            .as_file_mut()
996            .read_exact(&mut payload)
997            .map_err(|error| spill_error(format!("failed to read indexed spill row: {error}")))?;
998        decode_physical_row_record(&payload, self.schema.physical_width())
999    }
1000}
1001
1002fn read_indexed_offset(file: &mut File, position: u64) -> ExecResult<u64> {
1003    file.seek(SeekFrom::Start(position))
1004        .map_err(|error| spill_error(format!("failed to seek indexed spill offset: {error}")))?;
1005    let mut encoded = [0_u8; 8];
1006    file.read_exact(&mut encoded)
1007        .map_err(|error| spill_error(format!("failed to read indexed spill offset: {error}")))?;
1008    Ok(u64::from_le_bytes(encoded))
1009}
1010
1011impl<I> SpillRows<I> {
1012    fn new(batches: I) -> Self {
1013        Self {
1014            batches,
1015            current_schema: None,
1016            current: Vec::new().into_iter(),
1017        }
1018    }
1019}
1020
1021impl<I> Iterator for SpillRows<I>
1022where
1023    I: Iterator<Item = ExecResult<Batch>>,
1024{
1025    type Item = ExecResult<OwnedPhysicalRow>;
1026
1027    fn next(&mut self) -> Option<Self::Item> {
1028        loop {
1029            if let Some(row) = self.current.next() {
1030                let schema = self
1031                    .current_schema
1032                    .as_ref()
1033                    .expect("spill row iterator retains the current batch schema")
1034                    .clone();
1035                return Some(Ok(OwnedPhysicalRow::new(schema, row)));
1036            }
1037            match self.batches.next()? {
1038                Ok(batch) => {
1039                    self.current_schema = Some(batch.schema);
1040                    self.current = batch.rows.into_iter();
1041                }
1042                Err(error) => return Some(Err(error)),
1043            }
1044        }
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests;