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