lance-format / lance-format/lance
feature: unified FileSystem abstraction over local and object storage
@wjones127 is already working on this.
Since Sep 3, 2026.
- Dominant language
- Rust
- Stars
- 7.1k
- Forks
- 852
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 272
Description
RFC: Unified FileSystem abstraction over local and object storage
Status: Proposal
Area:lance-io
Related:file-object-store://scheme,WrappingObjectStore,IOTracker
Problem
Lance's I/O layer hardcodes the choice between local and object-storage access
in several places. ObjectStore::open, open_with_size, and create branch
on the URI scheme at runtime:
// lance-io/src/object_store.rs:847
pub async fn open(&self, path: &Path) -> Result<Box<dyn Reader>> {
match self.scheme.as_str() {
"file" if self.has_direct_local_paths() => {
LocalObjectReader::open_with_tracker(...) // direct std::fs
}
"file+uring" => { UringReader::open(...) } // Linux io_uring
_ => { CloudObjectReader::new(...) } // object store
}
}
The same match self.scheme / has_direct_local_paths() pattern is repeated
in at least six places (object_store.rs:848, 910, 973, 1230, 1433, 1509),
covering open, open_with_size, create, copy_via_stream, copy_impl,
and remove_dir_all.
Why this is a problem
-
file-object-store://is a workaround for this inconsistency. The
filescheme deliberately bypasses theObjectStoretrait for performance
(LocalObjectReader/LocalWritergo straight tostd::fs). This means
WrappingObjectStorewrappers (e.g.IOTrackingStore,FailingProxyStore,
ThrottledStoreWrapper) cannot intercept local I/O. To work around this,
Lance ships a second scheme,file-object-store://, that routes local I/O
through theObjectStoreAPI. See the comment in
lance-io/src/object_store/providers.rs:358-367:// The "file" scheme has special optimized code paths that bypass // the ObjectStore API for better performance. However, this can make it // hard to test when using ObjectStore wrappers, such as IOTrackingStore. // So we provide a "file-object-store" scheme that uses the ObjectStore API.The cost: users must use
file-object-store://to exercise wrappers, but
production usesfile://, so test and production behavior diverge. The
scheme also leaks into special cases throughoutobject_store.rs
(:1527,:1567). -
Fault injection and observability cannot be unified.
IOTrackercan
observe local I/O (viarecord_read/record_write), but cannot
inject faults into it. Injecting latency or errors into local reads and
writes requires thefile-object-store://workaround. There is no single
interception point that covers both local and cloud I/O. -
Adding a backend means editing every branch. Each new local
acceleration or cloud backend must touch all sixmatch self.scheme
sites, and theopen/open_with_sizebranches are near-duplicates.
Proposal
Introduce a unified FileSystem trait (the Lance analogue of DuckDB's
FileSystem interface) that abstracts "open a file for read/write" over both
local disk and object storage. The concrete implementation is chosen at
construction time, not by a runtime match on the scheme string.
// lance-io/src/file_system.rs (new)
#[async_trait]
pub trait FileSystem: Send + Sync + Debug {
async fn open(&self, path: &Path) -> Result<Box<dyn Reader>>;
async fn open_with_size(&self, path: &Path, known_size: usize) -> Result<Box<dyn Reader>>;
async fn create(&self, path: &Path) -> Result<Box<dyn Writer>>;
async fn read_range(&self, path: &Path, range: Range<usize>) -> Result<Bytes>;
async fn read_all(&self, path: &Path) -> Result<Bytes>;
async fn size(&self, path: &Path) -> Result<u64>;
fn is_local(&self) -> bool;
}
Two implementations:
LocalFileSystem— wraps the existingLocalObjectReader/
LocalWriter(directstd::fs), preserving the performance optimization.CloudFileSystem— wraps the existingCloudObjectReader/
ObjectWriter/SmallReaderthrough theObjectStoretrait.
ObjectStore holds an Arc<dyn FileSystem> chosen in its constructor, and
open / open_with_size / create delegate to it instead of branching on
self.scheme.
Fault injection becomes uniform
A FaultInjectingFileSystem wrapper can intercept both local and cloud
I/O through one FileSystem implementation, eliminating the need for
file-object-store://:
#[async_trait]
impl FileSystem for FaultInjectingFileSystem {
async fn open(&self, path: &Path) -> Result<Box<dyn Reader>> {
self.injector.before("open", path)?; // inject delay / error
self.inner.open(path).await
}
// ...
}
Scope
This is a progressive change. The first step covers the core read/write
path only:
- New
FileSystemtrait +LocalFileSystem+CloudFileSystem(pure
additions, no behavior change). ObjectStoreholds aFileSystemand delegatesopen/open_with_size/
createto it.- The local optimizations in
copy_via_stream,copy_impl, and
remove_dir_allare left onis_local()for now, migrated in a follow-up.
Performance
The local path keeps its direct std::fs access — LocalFileSystem still
returns LocalObjectReader / LocalWriter. The only change is that the
runtime match self.scheme becomes a construction-time choice plus one
virtual dispatch. No measurable overhead is expected; this must be verified
with a benchmark before merge.
Risks and mitigations
| Risk | Mitigation |
|---|---|
| Touches core I/O layer | Progressive: pure-additive first PR, then migration, then wrapper |
| Local performance regression | LocalFileSystem keeps direct std::fs; benchmark before merge |
| Test coverage (file / file-object-store / uring) | Keep existing #[case("file")] / #[case("file-object-store")] tests; add uring coverage |
ObjectStore::local() / memory() direct constructors |
Adapt them to build the matching FileSystem |
Open questions
- Should
FileSystemalso absorbcopyandremove_dir_all, or keep those
onObjectStorewith anis_local()fast path? - Should
file-object-store://be deprecated once the abstraction lands, or
kept for backward compatibility? - Does the
Reader/Writerhandle abstraction already cover enough, such
that we only need to abstract the selection logic rather than a full
FileSystemtrait?
Alternatives considered
- Factory function that centralizes the
match self.schemeinto one
helper. Lower effort (~100-200 lines) but does not remove the hardcoding or
enable uniform fault injection. - Route local I/O through
ObjectStore(drop the bypass). Simplest, but
loses the local performance optimization that motivated the bypass. - Status quo +
file-object-store://. Zero code change, but keeps the
workaround and the test/production divergence.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.