You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add SpillStore trait with local-disk implementation #7300
Index builds need a uniform place to put temporary, reclaimable intermediate state (posting-list segments, shuffle runs, btree pages) that can tier beyond memory when a machine is starved.
Add a SpillStore trait, available on Session, providing temporary scratch with an RAII lifecycle (handles delete on drop) and trafficking in opaque byte streams.
Provide a local-disk implementation that writes to temp dirs and returns a clean error when a configured size cap is exceeded (similar to DataFusion's DiskManager).
This is the foundation for memory-budgeted index builds: builders react to memory pressure (see the MemoryPool threading work) by spilling intermediate state through SpillStore. The trait should be a clean seam so alternative implementations can be injected via Session.
Acceptance
SpillStore trait + local-disk impl
RAII cleanup of scratch handles
Clean, typed error on disk-cap exhaustion (no panic)
Design: SpillStore — generic RAII scratch for index builds
Supersedes the earlier byte-stream / MeteredObjectStore sketches in this thread. This is the design we're building.
Scope
Ship the generic mechanism: SpillStore/SpillFile traits + local-disk impl + per-file RAII + disk cap + Session integration + tests. Consumer migration (IVF first) is a follow-up — the mechanism stays uncoupled.
FTS is excluded.InvertedIndexBuilder::flush() writes finalized partitions via builder.write(self.dest_store), where dest_store is the dataset's object store at indices_dir/uuid. Those partitions are the final index — never read back during the build, never deleted. FTS has no local-disk scratch tier; its overflow lever is worker_memory_limit_bytes (the MemoryPool tier, not SpillStore). BTree/Bitmap remain on DataFusion's DiskManager.
Crate layering
Lives in lance-io — the trait traffics in lance-io's Reader/Writer, and lance-core doesn't depend on lance-io. The DiskCapExceeded error variant stays in lance-core. Session (in lance) holds Arc<dyn SpillStore>.
The interface
pubtraitSpillStore:Send + Sync + 'static{/// One empty scratch file; deleted when the handle drops.fncreate_spill_file(&self) -> Result<Box<dynSpillFile>>;}pubtraitSpillFile:Send + Sync{asyncfnwriter(&self) -> Result<Box<dynWriter>>;// cap-enforcing LocalWriterasyncfnreader(&self) -> Result<Box<dynReader>>;}
No ObjectStore is exposed — tighter encapsulation, simpler RAII. writer() feeds FileWriter::try_new / PreviousFileWriter::with_object_writer directly (both already take Box<dyn Writer>, and ObjectStore::create() already returns one). reader() feeds a v2 FileReader via the new bridge below.
LocalSpillStore + per-file RAII
Owns: Arc<TempDir> (shared backstop), one shared base ObjectStore::local() carrying the DiskQuota, an atomic file counter.
create_spill_file() → unique path under the temp dir → LocalSpillFile { store: <shared>, path, quota }.
LocalSpillFile::Drop is the reclaimer: stat file size → quota.release(size) → remove_file(path). The shared TempDir mops up anything leaked at Session end.
No per-file ObjectStore clone, no inner wrapper, no into_parts — the shared store mints readers/writers; the handle owns cleanup.
Disk cap (lance-io LocalWriter hook)
The local write fast-path bypasses the object_store::ObjectStore trait (create() for the file scheme returns a LocalWriter that writes via tokio::fs and renames on shutdown — it never calls the wrapped inner). So the cap is enforced inLocalWriter, not via a wrapping store:
DiskQuota { cap_bytes, Arc<AtomicU64> used } — shared via the base store, so the cap is Session-level across all live scratch.
ObjectStore gains disk_quota: Option<DiskQuota> (a general "write byte budget"); create() threads it into LocalWriter.
LocalWriter::poll_write reserves incrementally; over cap → typed Error::DiskCapExceeded.
lance_core's From<io::Error> gains downcast-recovery so the typed error survives the io::Error boundary (additive, safe).
Accounting: reserve-on-write + release-on-SpillFile-drop (by stat). Exact for the documented contract (one SpillFile, written once, then read). Aborted-at-cap writes leak their reservation until the store drops (error path); rewrites double-count. Documented, not engineered around. Cloud/ObjectWriter (Enterprise remote) accounts for itself later.
The read bridge (new lance-io API)
A v2 FileReader consumes a FileScheduler (= Arc<dyn Reader> + Arc<ScanScheduler>), not a bare Reader, and the only scheduler-free EncodingsIo (BufferScheduler) is in-memory-only. So we add:
implScanScheduler{/// Open a FileScheduler over an already-open Reader (no path/store lookup).pubfnopen_reader(self:&Arc<Self>,reader:Arc<dynReader>) -> FileScheduler;}
~10 lines, mirroring open_file minus the path-open. A consumer turns spill_file.reader() into a v2 FileReader using its own ScanScheduler; the spill ObjectStore is never exposed.
v1 legacy reads (PreviousFileReader::try_new_self_described(&ObjectStore, path)) stay store-based — v2 is the migration target.
Usage contract
Hold the SpillFile alive for the file's lifetime; its reader()/writer() reference the backing file, which the handle deletes on Drop. The TempDir is the backstop.
lance-core: From<io::Error> downcast recovery (DiskCapExceeded variant already added); remove the old utils/spill.rs.
lance: Session integration.
Tests for each.
Follow-up: migrate the IVF v3 shuffler (lance-index/src/vector/v3/shuffler.rs) to SpillFile; it already has the (object_store, output_dir) shape and keeps a TempStdDir alive for cleanup, so it's the natural first consumer.
Activity
wjones127 commentedon Jun 17, 2026
Design:
SpillStore— generic RAII scratch for index buildsScope
Ship the generic mechanism:
SpillStore/SpillFiletraits + local-disk impl + per-file RAII + disk cap +Sessionintegration + tests. Consumer migration (IVF first) is a follow-up — the mechanism stays uncoupled.FTS is excluded.
InvertedIndexBuilder::flush()writes finalized partitions viabuilder.write(self.dest_store), wheredest_storeis the dataset's object store atindices_dir/uuid. Those partitions are the final index — never read back during the build, never deleted. FTS has no local-disk scratch tier; its overflow lever isworker_memory_limit_bytes(the MemoryPool tier, not SpillStore). BTree/Bitmap remain on DataFusion'sDiskManager.Crate layering
Lives in
lance-io— the trait traffics in lance-io'sReader/Writer, andlance-coredoesn't depend onlance-io. TheDiskCapExceedederror variant stays inlance-core.Session(inlance) holdsArc<dyn SpillStore>.The interface
No
ObjectStoreis exposed — tighter encapsulation, simpler RAII.writer()feedsFileWriter::try_new/PreviousFileWriter::with_object_writerdirectly (both already takeBox<dyn Writer>, andObjectStore::create()already returns one).reader()feeds a v2FileReadervia the new bridge below.LocalSpillStore+ per-file RAIIArc<TempDir>(shared backstop), one shared baseObjectStore::local()carrying theDiskQuota, an atomic file counter.create_spill_file()→ unique path under the temp dir →LocalSpillFile { store: <shared>, path, quota }.LocalSpillFile::Dropis the reclaimer: stat file size →quota.release(size)→remove_file(path). The sharedTempDirmops up anything leaked atSessionend.ObjectStoreclone, noinnerwrapper, nointo_parts— the shared store mints readers/writers; the handle owns cleanup.Disk cap (lance-io
LocalWriterhook)The local write fast-path bypasses the
object_store::ObjectStoretrait (create()for thefilescheme returns aLocalWriterthat writes viatokio::fsand renames on shutdown — it never calls the wrappedinner). So the cap is enforced inLocalWriter, not via a wrapping store:DiskQuota { cap_bytes, Arc<AtomicU64> used }— shared via the base store, so the cap is Session-level across all live scratch.ObjectStoregainsdisk_quota: Option<DiskQuota>(a general "write byte budget");create()threads it intoLocalWriter.LocalWriter::poll_writereserves incrementally; over cap → typedError::DiskCapExceeded.lance_core'sFrom<io::Error>gains downcast-recovery so the typed error survives theio::Errorboundary (additive, safe).Accounting: reserve-on-write + release-on-
SpillFile-drop (by stat). Exact for the documented contract (oneSpillFile, written once, then read). Aborted-at-cap writes leak their reservation until the store drops (error path); rewrites double-count. Documented, not engineered around. Cloud/ObjectWriter(Enterprise remote) accounts for itself later.The read bridge (new lance-io API)
A v2
FileReaderconsumes aFileScheduler(=Arc<dyn Reader>+Arc<ScanScheduler>), not a bareReader, and the only scheduler-freeEncodingsIo(BufferScheduler) is in-memory-only. So we add:~10 lines, mirroring
open_fileminus the path-open. A consumer turnsspill_file.reader()into a v2FileReaderusing its ownScanScheduler; the spillObjectStoreis never exposed.v1 legacy reads (
PreviousFileReader::try_new_self_described(&ObjectStore, path)) stay store-based — v2 is the migration target.Usage contract
Hold the
SpillFilealive for the file's lifetime; itsreader()/writer()reference the backing file, which the handle deletes onDrop. TheTempDiris the backstop.What lands in this PR
lance-io:DiskQuota;ObjectStore.disk_quota+LocalWriterenforcement;ScanScheduler::open_reader;SpillStore/SpillFile+LocalSpillStore.lance-core:From<io::Error>downcast recovery (DiskCapExceededvariant already added); remove the oldutils/spill.rs.lance:Sessionintegration.Follow-up: migrate the IVF v3 shuffler (
lance-index/src/vector/v3/shuffler.rs) toSpillFile; it already has the(object_store, output_dir)shape and keeps aTempStdDiralive for cleanup, so it's the natural first consumer.feat: add SpillStore for generic RAII scratch space
feat: add SpillStore for generic RAII scratch space
feat: add SpillStore trait with local-disk implementation (lance-form…