Skip to main content

lance_io/
spill.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Reclaimable scratch storage.
5//!
6//! A [`SpillStore`] hands out scratch space for temporary state that is too
7//! large to keep in memory and is read back later in the same process (for
8//! example, posting lists or shuffle runs accumulated while building an index).
9//! The backing storage is reclaimed automatically when the handle is dropped.
10//!
11//! [`SpillStore::new_spill`] returns a [`Writer`] paired with a [`Spill`]
12//! handle: the writer is the byte sink (feed it to `FileWriter::try_new`, or
13//! write to it directly); the [`Spill`] reads the bytes back (via
14//! [`crate::scheduler::ScanScheduler::open_reader`] for a v2 `FileReader`) and
15//! owns the file's lifetime.
16//!
17//! # Lifecycle
18//!
19//! - **Write-once.** The only way to obtain a writer is `new_spill`, and each
20//!   call allocates a fresh unit of storage, so a single spill cannot be
21//!   written twice — there is no second-writer path to guard against.
22//! - **Write-before-read.** [`Spill::reader`] fails until the writer has been
23//!   shut down, so partially written bytes are never read back.
24//! - **RAII.** Dropping the [`Spill`] deletes the file and releases its bytes
25//!   back to the store's disk budget. The store's temp directory is created on
26//!   first use and is the backstop for anything leaked if a handle is forgotten.
27//!
28//! # Disk cap
29//!
30//! [`LocalSpillStore::with_cap`] enforces a byte budget shared across all live
31//! handles, returning a typed [`lance_core::Error::DiskCapExceeded`] rather than
32//! silently filling the disk. Accounting is reserve-on-write + release-on-drop
33//! (by stat), which is exact for the write-once contract. Two minor
34//! inexactnesses are not engineered around: a write aborted at the cap leaks its
35//! reservation until the store is dropped, and a file whose size cannot be
36//! stat-ed on drop is not released.
37
38use std::io;
39use std::path::PathBuf;
40use std::pin::Pin;
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::{Arc, Mutex};
43use std::task::{Context, Poll};
44use std::time::Duration;
45
46use async_trait::async_trait;
47use object_store::path::Path;
48use tokio::io::AsyncWrite;
49
50use lance_core::{Error, Result};
51
52use crate::object_store::ObjectStore;
53use crate::object_writer::WriteResult;
54use crate::traits::{Reader, Writer};
55
56/// A factory for scratch storage.
57///
58/// The trait is object-safe and `Send + Sync` so it can be held behind an
59/// `Arc<dyn SpillStore>` (e.g. inside a `Session`). Implementations need not be
60/// backed by local files (e.g. in-memory buffers, remote object stores).
61#[async_trait]
62pub trait SpillStore: Send + Sync + 'static {
63    /// Allocate a unit of scratch storage.
64    ///
65    /// Returns the byte sink to write it with and a [`Spill`] handle to read it
66    /// back. For a capped store, writes that would exceed the cap fail with
67    /// [`lance_core::Error::DiskCapExceeded`]. The storage is reclaimed when the
68    /// [`Spill`] is dropped.
69    async fn new_spill(&self) -> Result<(Box<dyn Writer>, Box<dyn Spill>)>;
70}
71
72/// The readable half of a spill, and the owner of its backing storage.
73///
74/// Dropping it reclaims the storage. The trait is object-safe so it can be
75/// returned as `Box<dyn Spill>` from [`SpillStore::new_spill`].
76#[async_trait]
77pub trait Spill: Send + Sync {
78    /// Open a reader over the spilled bytes.
79    ///
80    /// Fails until the paired writer has been shut down, since the bytes are not
81    /// complete before then.
82    async fn reader(&self) -> Result<Box<dyn Reader>>;
83}
84
85/// A shared, cloneable byte budget.
86///
87/// Cloning produces another handle to the *same* underlying counter, so a quota
88/// shared across many writers enforces a single combined cap.
89#[derive(Debug, Clone)]
90struct DiskQuota {
91    cap_bytes: u64,
92    used: Arc<Mutex<u64>>,
93}
94
95impl DiskQuota {
96    fn new(cap_bytes: u64) -> Self {
97        Self {
98            cap_bytes,
99            used: Arc::new(Mutex::new(0)),
100        }
101    }
102
103    /// Try to reserve `n` bytes, failing with [`Error::DiskCapExceeded`] if the
104    /// reservation would push total usage past the cap.
105    fn try_reserve(&self, n: u64) -> Result<()> {
106        // The lock is held only for a couple of arithmetic ops and never across
107        // an `.await`, so a std `Mutex` is the simplest correct choice.
108        let mut used = self.used.lock().unwrap();
109        let next = used.saturating_add(n);
110        if next > self.cap_bytes {
111            return Err(Error::disk_cap_exceeded(self.cap_bytes, *used));
112        }
113        *used = next;
114        Ok(())
115    }
116
117    /// Release `n` previously reserved bytes back to the budget.
118    fn release(&self, n: u64) {
119        // Saturating sub keeps a stray double-release from underflowing.
120        let mut used = self.used.lock().unwrap();
121        *used = used.saturating_sub(n);
122    }
123}
124
125/// The byte sink handed out by [`SpillStore::new_spill`].
126///
127/// It optionally reserves a [`DiskQuota`] as bytes are written (keeping cap
128/// enforcement inside the spill store rather than in [`ObjectStore`], and
129/// working for any backend the store opens), and flips a shared `finished` flag
130/// on shutdown so the paired [`Spill`] knows the bytes are complete.
131struct SpillWriter {
132    inner: Box<dyn Writer>,
133    quota: Option<DiskQuota>,
134    finished: Arc<AtomicBool>,
135}
136
137impl AsyncWrite for SpillWriter {
138    fn poll_write(
139        self: Pin<&mut Self>,
140        cx: &mut Context<'_>,
141        buf: &[u8],
142    ) -> Poll<io::Result<usize>> {
143        let this = self.get_mut();
144        let Some(quota) = &this.quota else {
145            return Pin::new(this.inner.as_mut()).poll_write(cx, buf);
146        };
147        // Reserve up-front for the bytes we intend to write, then release the
148        // remainder the inner writer did not accept so the reservation tracks
149        // bytes actually buffered (and, for a write-once file, the file size).
150        if let Err(e) = quota.try_reserve(buf.len() as u64) {
151            return Poll::Ready(Err(io::Error::other(e)));
152        }
153        let poll = Pin::new(this.inner.as_mut()).poll_write(cx, buf);
154        match &poll {
155            Poll::Ready(Ok(n)) => quota.release((buf.len() - *n) as u64),
156            _ => quota.release(buf.len() as u64),
157        }
158        poll
159    }
160
161    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
162        Pin::new(self.get_mut().inner.as_mut()).poll_flush(cx)
163    }
164
165    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
166        let this = self.get_mut();
167        let poll = Pin::new(this.inner.as_mut()).poll_shutdown(cx);
168        if matches!(poll, Poll::Ready(Ok(()))) {
169            // Mirrors `Writer::shutdown` so the flag is set whichever shutdown
170            // surface the consumer drives (`AsyncWrite` vs the `Writer` trait).
171            this.finished.store(true, Ordering::Relaxed);
172        }
173        poll
174    }
175}
176
177#[async_trait]
178impl Writer for SpillWriter {
179    async fn tell(&mut self) -> Result<usize> {
180        self.inner.tell().await
181    }
182
183    async fn shutdown(&mut self) -> Result<WriteResult> {
184        let result = self.inner.shutdown().await?;
185        // Signal the paired `Spill` that the bytes are now complete. `Relaxed`
186        // is sufficient: this only flags that shutdown happened; the file
187        // contents are synchronized through the filesystem, not this flag.
188        self.finished.store(true, Ordering::Relaxed);
189        Ok(result)
190    }
191}
192
193/// A [`SpillStore`] that writes temporary files to a local temp directory.
194///
195/// By default there is no disk cap. Use [`LocalSpillStore::with_cap`] to
196/// configure one shared across every handle this store produces.
197///
198/// The scratch directory is created on the first [`SpillStore::new_spill`]
199/// call, not at construction, so opening a dataset does not require a writable
200/// temp directory. It is deleted when the store is dropped, cleaning up any
201/// files whose handles have already been dropped.
202pub struct LocalSpillStore {
203    store: Arc<ObjectStore>,
204    /// Created on first spill. Backstop cleanup removes the directory on drop.
205    temp_dir: Mutex<Option<Arc<tempfile::TempDir>>>,
206    file_counter: Arc<AtomicU64>,
207    /// Byte budget shared across every handle, enforced while writing.
208    quota: Option<DiskQuota>,
209}
210
211/// Create a unique scratch directory, retrying `PermissionDenied`.
212///
213/// Windows GitHub runners (and machines with antivirus) can reject a newly
214/// created `%TEMP%` subdirectory with `ERROR_ACCESS_DENIED`. Each attempt uses
215/// a fresh random name, so a short retry loop is the usual workaround.
216fn create_temp_dir() -> Result<tempfile::TempDir> {
217    const ATTEMPTS: u32 = 5;
218    let mut attempt = 0;
219    loop {
220        match tempfile::tempdir() {
221            Ok(dir) => return Ok(dir),
222            Err(err) if err.kind() == io::ErrorKind::PermissionDenied && attempt + 1 < ATTEMPTS => {
223                attempt += 1;
224                std::thread::sleep(Duration::from_millis(10 * attempt as u64));
225            }
226            Err(err) => return Err(err.into()),
227        }
228    }
229}
230
231impl LocalSpillStore {
232    /// Create a store with no disk cap.
233    ///
234    /// Construction does not touch the filesystem; the scratch directory is
235    /// created on the first [`SpillStore::new_spill`] call.
236    pub fn new() -> Result<Self> {
237        Ok(Self::default())
238    }
239
240    /// Create a store that returns [`lance_core::Error::DiskCapExceeded`] once
241    /// total bytes written across all live handles would exceed `cap_bytes`.
242    pub fn with_cap(cap_bytes: u64) -> Result<Self> {
243        Ok(Self {
244            quota: Some(DiskQuota::new(cap_bytes)),
245            ..Self::default()
246        })
247    }
248
249    fn ensure_temp_dir(&self) -> Result<Arc<tempfile::TempDir>> {
250        // The lock is held only for init (and the rare PermissionDenied retry)
251        // and never across an `.await`.
252        let mut slot = self.temp_dir.lock().unwrap();
253        if let Some(dir) = slot.as_ref() {
254            return Ok(dir.clone());
255        }
256        let dir = Arc::new(create_temp_dir()?);
257        *slot = Some(dir.clone());
258        Ok(dir)
259    }
260}
261
262impl Default for LocalSpillStore {
263    fn default() -> Self {
264        Self {
265            store: Arc::new(ObjectStore::local()),
266            temp_dir: Mutex::new(None),
267            file_counter: Arc::new(AtomicU64::new(0)),
268            quota: None,
269        }
270    }
271}
272
273#[async_trait]
274impl SpillStore for LocalSpillStore {
275    async fn new_spill(&self) -> Result<(Box<dyn Writer>, Box<dyn Spill>)> {
276        let temp_dir = self.ensure_temp_dir()?;
277        let idx = self.file_counter.fetch_add(1, Ordering::Relaxed);
278        let fs_path = temp_dir.path().join(format!("spill_{idx:06}.bin"));
279        let os_path = Path::from_absolute_path(&fs_path)?;
280        let finished = Arc::new(AtomicBool::new(false));
281
282        let writer = Box::new(SpillWriter {
283            inner: self.store.create(&os_path).await?,
284            quota: self.quota.clone(),
285            finished: finished.clone(),
286        });
287        let spill = Box::new(LocalSpill {
288            store: self.store.clone(),
289            os_path,
290            fs_path,
291            quota: self.quota.clone(),
292            finished,
293            _temp_dir: temp_dir,
294        });
295        Ok((writer, spill))
296    }
297}
298
299/// The readable half of a [`LocalSpillStore`] spill; reclaims the file on drop.
300struct LocalSpill {
301    store: Arc<ObjectStore>,
302    os_path: Path,
303    fs_path: PathBuf,
304    quota: Option<DiskQuota>,
305    /// Set by the paired [`SpillWriter`] once it has been shut down.
306    finished: Arc<AtomicBool>,
307    /// Keep the store's temp directory alive for at least this file's lifetime.
308    _temp_dir: Arc<tempfile::TempDir>,
309}
310
311#[async_trait]
312impl Spill for LocalSpill {
313    async fn reader(&self) -> Result<Box<dyn Reader>> {
314        // `Relaxed` is sufficient: the flag only gates "has the writer shut
315        // down"; the bytes themselves are synchronized through the filesystem,
316        // not this load.
317        if !self.finished.load(Ordering::Relaxed) {
318            return Err(Error::invalid_input(
319                "spill reader requested before the writer was shut down",
320            ));
321        }
322        self.store.open(&self.os_path).await
323    }
324}
325
326impl Drop for LocalSpill {
327    fn drop(&mut self) {
328        // Release the bytes this file occupied back to the budget. We stat the
329        // persisted file rather than tracking writes, which is exact for the
330        // write-once contract.
331        if let Some(quota) = &self.quota
332            && let Ok(metadata) = std::fs::metadata(&self.fs_path)
333        {
334            quota.release(metadata.len());
335        }
336        // Best-effort removal; the temp dir is the backstop.
337        let _ = std::fs::remove_file(&self.fs_path);
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use tokio::io::AsyncWriteExt;
345
346    /// Write `data` to a fresh writer and shut it down.
347    async fn finish_writer(mut writer: Box<dyn Writer>, data: &[u8]) -> Result<()> {
348        writer.write_all(data).await?;
349        Writer::shutdown(writer.as_mut()).await?;
350        Ok(())
351    }
352
353    #[test]
354    fn test_disk_quota_reserve_release() {
355        let quota = DiskQuota::new(100);
356        quota.try_reserve(60).unwrap();
357        assert!(quota.try_reserve(60).is_err());
358        quota.release(60);
359        quota.try_reserve(60).unwrap();
360        // Reserving exactly up to the cap succeeds; one byte past it fails.
361        quota.try_reserve(40).unwrap();
362        assert!(quota.try_reserve(1).is_err());
363    }
364
365    #[tokio::test]
366    async fn test_write_then_read() {
367        let store = LocalSpillStore::new().unwrap();
368        let (writer, spill) = store.new_spill().await.unwrap();
369
370        let data = b"hello spill world";
371        finish_writer(writer, data).await.unwrap();
372
373        let reader = spill.reader().await.unwrap();
374        let read_back = reader.get_all().await.unwrap();
375        assert_eq!(read_back.as_ref(), data);
376    }
377
378    #[tokio::test]
379    async fn test_reader_requires_finished_writer() {
380        let store = LocalSpillStore::new().unwrap();
381        let (mut writer, spill) = store.new_spill().await.unwrap();
382        writer.write_all(b"partial").await.unwrap();
383
384        // Reading before the writer is shut down is rejected.
385        let Err(err) = spill.reader().await else {
386            panic!("reader before shutdown should be rejected");
387        };
388        assert!(
389            matches!(err, Error::InvalidInput { .. }),
390            "expected InvalidInput, got {err:?}"
391        );
392
393        // After shutdown the reader sees the bytes.
394        Writer::shutdown(writer.as_mut()).await.unwrap();
395        let reader = spill.reader().await.unwrap();
396        assert_eq!(reader.get_all().await.unwrap().as_ref(), b"partial");
397    }
398
399    #[tokio::test]
400    async fn test_reader_ready_after_async_shutdown() {
401        // Shutting down through the `AsyncWrite` surface (not the `Writer`
402        // trait) must also mark the spill readable — covers poll_shutdown's
403        // flag set, the path the `Writer::shutdown` tests don't reach.
404        let store = LocalSpillStore::new().unwrap();
405        let (mut writer, spill) = store.new_spill().await.unwrap();
406        writer.write_all(b"async").await.unwrap();
407        AsyncWriteExt::shutdown(&mut writer).await.unwrap();
408
409        let reader = spill.reader().await.unwrap();
410        assert_eq!(reader.get_all().await.unwrap().as_ref(), b"async");
411    }
412
413    #[tokio::test]
414    async fn test_empty_spill() {
415        // A spill written with no bytes round-trips empty, and the capped path
416        // handles the zero-byte reserve/stat without error.
417        let store = LocalSpillStore::with_cap(100).unwrap();
418        let (writer, spill) = store.new_spill().await.unwrap();
419        finish_writer(writer, b"").await.unwrap();
420
421        let reader = spill.reader().await.unwrap();
422        assert!(reader.get_all().await.unwrap().is_empty());
423    }
424
425    #[test]
426    fn test_new_does_not_create_temp_dir() {
427        // Session construction must not touch the filesystem; Windows CI panics
428        // when thousands of tests each eagerly create a `%TEMP%` subdirectory.
429        let store = LocalSpillStore::new().unwrap();
430        assert!(
431            store.temp_dir.lock().unwrap().is_none(),
432            "temp directory should be created lazily on first spill"
433        );
434        assert!(
435            LocalSpillStore::default()
436                .temp_dir
437                .lock()
438                .unwrap()
439                .is_none(),
440            "Default should also defer temp directory creation"
441        );
442    }
443
444    #[tokio::test]
445    async fn test_raii_cleanup() {
446        let store = LocalSpillStore::new().unwrap();
447        let (writer, spill) = store.new_spill().await.unwrap();
448        finish_writer(writer, b"some bytes").await.unwrap();
449
450        // The first spill gets a deterministic name under the store's temp dir.
451        let path = store
452            .temp_dir
453            .lock()
454            .unwrap()
455            .as_ref()
456            .expect("new_spill creates the temp dir")
457            .path()
458            .join("spill_000000.bin");
459        assert!(path.exists());
460        drop(spill);
461        assert!(!path.exists(), "spill file should be deleted on drop");
462    }
463
464    #[tokio::test]
465    async fn test_cap_exceeded() {
466        let store = LocalSpillStore::with_cap(100).unwrap();
467        let (writer, _spill) = store.new_spill().await.unwrap();
468        let err = finish_writer(writer, &[0u8; 101]).await.unwrap_err();
469        assert!(
470            matches!(err, Error::DiskCapExceeded { cap_bytes: 100, .. }),
471            "expected DiskCapExceeded, got {err:?}"
472        );
473    }
474
475    #[tokio::test]
476    async fn test_cap_shared_across_files() {
477        let store = LocalSpillStore::with_cap(100).unwrap();
478        let (writer_a, _spill_a) = store.new_spill().await.unwrap();
479        let (writer_b, _spill_b) = store.new_spill().await.unwrap();
480
481        finish_writer(writer_a, &[0u8; 60]).await.unwrap();
482        // 60 already reserved by `a`; writing 60 more would reach 120 > 100.
483        let err = finish_writer(writer_b, &[0u8; 60]).await.unwrap_err();
484        assert!(
485            matches!(err, Error::DiskCapExceeded { cap_bytes: 100, .. }),
486            "expected DiskCapExceeded, got {err:?}"
487        );
488    }
489
490    #[tokio::test]
491    async fn test_cap_freed_on_drop() {
492        let store = LocalSpillStore::with_cap(100).unwrap();
493
494        {
495            let (writer, spill) = store.new_spill().await.unwrap();
496            finish_writer(writer, &[0u8; 80]).await.unwrap();
497            // `spill` drops at the end of this block, releasing its 80 bytes.
498            drop(spill);
499        }
500
501        let (writer, _spill) = store.new_spill().await.unwrap();
502        // Succeeds because the cap is no longer under pressure.
503        finish_writer(writer, &[0u8; 80]).await.unwrap();
504    }
505
506    #[tokio::test]
507    async fn test_custom_implementation() {
508        // A custom store can satisfy the traits without a local file.
509        struct MemStore;
510        struct MemSpill;
511
512        #[async_trait]
513        impl Spill for MemSpill {
514            async fn reader(&self) -> Result<Box<dyn Reader>> {
515                ObjectStore::memory().open(&Path::from("/mem")).await
516            }
517        }
518
519        #[async_trait]
520        impl SpillStore for MemStore {
521            async fn new_spill(&self) -> Result<(Box<dyn Writer>, Box<dyn Spill>)> {
522                let writer = ObjectStore::memory().create(&Path::from("/mem")).await?;
523                Ok((writer, Box::new(MemSpill)))
524            }
525        }
526
527        let store = MemStore;
528        // Exercise the factory + trait objects; the in-memory store is a fresh
529        // instance per call so we don't round-trip data here.
530        let (_writer, _spill) = store.new_spill().await.unwrap();
531    }
532
533    /// A [`Writer`] whose `poll_write` accepts a fixed number of bytes per call,
534    /// or fails, so we can drive the [`SpillWriter`] release arms that the local
535    /// backend (which accepts every write in full) never hits.
536    struct ControlledWriter {
537        outcome: Poll<io::Result<usize>>,
538    }
539
540    impl AsyncWrite for ControlledWriter {
541        fn poll_write(
542            self: Pin<&mut Self>,
543            _cx: &mut Context<'_>,
544            buf: &[u8],
545        ) -> Poll<io::Result<usize>> {
546            match &self.outcome {
547                Poll::Ready(Ok(n)) => Poll::Ready(Ok((*n).min(buf.len()))),
548                Poll::Ready(Err(e)) => Poll::Ready(Err(io::Error::new(e.kind(), e.to_string()))),
549                Poll::Pending => Poll::Pending,
550            }
551        }
552        fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
553            Poll::Ready(Ok(()))
554        }
555        fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
556            Poll::Ready(Ok(()))
557        }
558    }
559
560    #[async_trait]
561    impl Writer for ControlledWriter {
562        async fn tell(&mut self) -> Result<usize> {
563            Ok(0)
564        }
565        async fn shutdown(&mut self) -> Result<WriteResult> {
566            Ok(WriteResult::default())
567        }
568    }
569
570    #[tokio::test]
571    async fn test_spill_writer_releases_unaccepted_bytes() {
572        // Short write: the inner writer accepts only 10 of the 40 reserved bytes,
573        // so the 30-byte remainder must be returned to the budget.
574        let quota = DiskQuota::new(100);
575        let mut writer = SpillWriter {
576            inner: Box::new(ControlledWriter {
577                outcome: Poll::Ready(Ok(10)),
578            }),
579            quota: Some(quota.clone()),
580            finished: Arc::new(AtomicBool::new(false)),
581        };
582        let n = writer.write(&[0u8; 40]).await.unwrap();
583        assert_eq!(n, 10);
584        assert_eq!(
585            *quota.used.lock().unwrap(),
586            10,
587            "only the accepted bytes should remain reserved"
588        );
589
590        // Failed write: the full reservation must be released.
591        let quota = DiskQuota::new(100);
592        let mut writer = SpillWriter {
593            inner: Box::new(ControlledWriter {
594                outcome: Poll::Ready(Err(io::Error::other("boom"))),
595            }),
596            quota: Some(quota.clone()),
597            finished: Arc::new(AtomicBool::new(false)),
598        };
599        writer.write(&[0u8; 40]).await.unwrap_err();
600        assert_eq!(
601            *quota.used.lock().unwrap(),
602            0,
603            "a failed write should release its entire reservation"
604        );
605    }
606}