Skip to main content

lance_io/
local.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Optimized local I/Os
5
6use std::collections::HashSet;
7use std::fs::File;
8use std::io::{ErrorKind, Read, SeekFrom};
9use std::ops::Range;
10use std::sync::Arc;
11
12// TODO: Clean up windows/unix stuff
13#[cfg(unix)]
14use std::os::unix::fs::FileExt;
15#[cfg(windows)]
16use std::os::windows::fs::FileExt;
17
18use async_trait::async_trait;
19use bytes::{Bytes, BytesMut};
20use chrono::{DateTime, Utc};
21use futures::future::BoxFuture;
22use lance_core::deepsize::DeepSizeOf;
23use lance_core::{Error, Result};
24use object_store::path::Path;
25use tokio::io::AsyncSeekExt;
26use tokio::sync::OnceCell;
27use tracing::instrument;
28
29use crate::object_reader::stream_local_range;
30use crate::object_store::DEFAULT_LOCAL_IO_PARALLELISM;
31use crate::object_writer::WriteResult;
32use crate::traits::{ByteStream, Reader, Writer};
33use crate::utils::tracking_store::IOTracker;
34
35/// Convert an [`object_store::path::Path`] to a [`std::path::Path`].
36pub fn to_local_path(path: &Path) -> String {
37    if cfg!(windows) {
38        path.to_string()
39    } else {
40        format!("/{path}")
41    }
42}
43
44/// Recursively remove a directory, specified by [`object_store::path::Path`].
45pub fn remove_dir_all(path: &Path) -> Result<()> {
46    std::fs::remove_dir_all(to_local_path(path)).map_err(|err| match err.kind() {
47        ErrorKind::NotFound => Error::not_found(path.to_string()),
48        _ => Error::from(err),
49    })?;
50    Ok(())
51}
52
53/// Remove eligible empty directories below `root` without following symbolic links.
54pub(crate) fn remove_empty_dirs(
55    root: &Path,
56    retained_dirs: &HashSet<Path>,
57    verified_dirs: &HashSet<Path>,
58    unmodified_since: Option<DateTime<Utc>>,
59) -> Result<()> {
60    let root_path = std::path::PathBuf::from(to_local_path(root));
61    let root_metadata = match std::fs::symlink_metadata(&root_path) {
62        Ok(metadata) => metadata,
63        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
64        Err(err) => return Err(Error::from(err)),
65    };
66    if !root_metadata.file_type().is_dir() {
67        return Ok(());
68    }
69    let canonical_root = std::fs::canonicalize(&root_path)?;
70
71    let mut pending_dirs = vec![(root_path, root.clone(), None::<Path>)];
72    let mut discovered_dirs = Vec::new();
73    let mut file_bearing_roots = HashSet::new();
74    let mut first_error = None;
75    while let Some((local_dir, object_store_dir, index_root)) = pending_dirs.pop() {
76        let entries = match std::fs::read_dir(&local_dir) {
77            Ok(entries) => entries,
78            Err(err) if err.kind() == ErrorKind::NotFound => continue,
79            Err(err) => return Err(Error::from(err)),
80        };
81        for entry in entries {
82            let entry = entry?;
83            // DirEntry::file_type does not follow symbolic links. Non-directories, including
84            // symlinks, remain in place and prevent their parent from being removed as empty.
85            if !entry.file_type()?.is_dir() {
86                if let Some(index_root) = &index_root {
87                    file_bearing_roots.insert(index_root.clone());
88                }
89                continue;
90            }
91            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
92                continue;
93            };
94            let child = object_store_dir.clone().join(name);
95            if retained_dirs.contains(&child) {
96                continue;
97            }
98            let local_child = entry.path();
99            let index_root = index_root.clone().unwrap_or_else(|| child.clone());
100            // Removing a child changes its parent's mtime, so capture age eligibility before
101            // deepest-first deletion starts.
102            let is_old_enough = match std::fs::symlink_metadata(&local_child) {
103                Ok(metadata) => unmodified_since.is_none_or(|threshold| {
104                    metadata
105                        .modified()
106                        .ok()
107                        .map(DateTime::<Utc>::from)
108                        .is_some_and(|modified| modified < threshold)
109                }),
110                Err(err) if err.kind() == ErrorKind::NotFound => continue,
111                Err(err) => {
112                    first_error.get_or_insert_with(|| Error::from(err));
113                    false
114                }
115            };
116            discovered_dirs.push((
117                local_child.clone(),
118                child.clone(),
119                index_root.clone(),
120                is_old_enough,
121            ));
122            pending_dirs.push((local_child, child, Some(index_root)));
123        }
124    }
125
126    discovered_dirs.sort_unstable_by_key(|(_, path, _, _)| std::cmp::Reverse(path.parts_count()));
127    for (local_dir, object_store_dir, index_root, is_old_enough) in discovered_dirs {
128        if file_bearing_roots.contains(&index_root) {
129            continue;
130        }
131        match std::fs::symlink_metadata(&local_dir) {
132            Ok(metadata) if metadata.file_type().is_dir() => {}
133            Ok(_) => continue,
134            Err(err) if err.kind() == ErrorKind::NotFound => continue,
135            Err(err) => {
136                first_error.get_or_insert_with(|| Error::from(err));
137                continue;
138            }
139        }
140        let is_verified = verified_dirs.contains(&object_store_dir);
141        if !is_verified && !is_old_enough {
142            continue;
143        }
144
145        // Canonicalization rejects any directory reached through a symlink outside the supplied
146        // root. Removing the canonical path also avoids trusting prefixes returned by an object
147        // store listing.
148        let canonical_dir = match std::fs::canonicalize(&local_dir) {
149            Ok(path) if path != canonical_root && path.starts_with(&canonical_root) => path,
150            Ok(_) => continue,
151            Err(err) if err.kind() == ErrorKind::NotFound => continue,
152            Err(err) => {
153                first_error.get_or_insert_with(|| Error::from(err));
154                continue;
155            }
156        };
157        if let Err(err) = std::fs::remove_dir(canonical_dir)
158            && !matches!(
159                err.kind(),
160                ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty
161            )
162        {
163            first_error.get_or_insert_with(|| Error::from(err));
164        }
165    }
166
167    if let Some(error) = first_error {
168        Err(error)
169    } else {
170        Ok(())
171    }
172}
173
174/// Copy a file from one location to another, supporting cross-filesystem copies.
175///
176/// Unlike hard links, this function works across filesystem boundaries.
177pub fn copy_file(from: &Path, to: &Path) -> Result<()> {
178    let from_path = to_local_path(from);
179    let to_path = to_local_path(to);
180
181    // Ensure the parent directory exists
182    if let Some(parent) = std::path::Path::new(&to_path).parent() {
183        std::fs::create_dir_all(parent).map_err(Error::from)?;
184    }
185
186    std::fs::copy(&from_path, &to_path).map_err(|err| match err.kind() {
187        ErrorKind::NotFound => Error::not_found(from.to_string()),
188        _ => Error::from(err),
189    })?;
190    Ok(())
191}
192
193/// Await a filesystem operation running on a blocking thread, flattening the
194/// join and IO errors into a single `object_store` error.
195///
196/// Deliberately not written as `handle.await?` at the call sites: a `JoinError`
197/// means the operation panicked, and short-circuiting on it would skip the
198/// caller's metrics recording for exactly the failure worth counting.
199pub(crate) async fn join_local_io<T>(
200    handle: tokio::task::JoinHandle<std::io::Result<T>>,
201) -> object_store::Result<T> {
202    match handle.await {
203        Ok(result) => result.map_err(|err| object_store::Error::Generic {
204            store: "LocalFileSystem",
205            source: err.into(),
206        }),
207        Err(err) => Err(err.into()),
208    }
209}
210
211/// Object reader for local file system.
212#[derive(Debug)]
213pub struct LocalObjectReader {
214    /// File handler.
215    file: Arc<File>,
216
217    /// Fie path.
218    path: Path,
219
220    /// Known size of the file. This is either passed in on construction or
221    /// cached on the first metadata call.
222    size: OnceCell<usize>,
223
224    /// Block size, in bytes.
225    block_size: usize,
226
227    /// IO tracker for monitoring read operations.
228    io_tracker: Arc<IOTracker>,
229}
230
231impl DeepSizeOf for LocalObjectReader {
232    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
233        // Skipping `file` as it should just be a file handle
234        self.path.as_ref().deep_size_of_children(context)
235    }
236}
237
238impl LocalObjectReader {
239    pub async fn open_local_path(
240        path: impl AsRef<std::path::Path>,
241        block_size: usize,
242        known_size: Option<usize>,
243    ) -> Result<Box<dyn Reader>> {
244        let path = path.as_ref().to_owned();
245        let object_store_path = Path::from_filesystem_path(&path)?;
246        Self::open(&object_store_path, block_size, known_size).await
247    }
248
249    /// Open a local object reader, with default prefetch size.
250    ///
251    /// For backward compatibility with existing code that doesn't need tracking.
252    #[instrument(level = "debug")]
253    pub async fn open(
254        path: &Path,
255        block_size: usize,
256        known_size: Option<usize>,
257    ) -> Result<Box<dyn Reader>> {
258        Self::open_with_tracker(path, block_size, known_size, Default::default()).await
259    }
260
261    /// Open a local object reader with optional IO tracking.
262    #[instrument(level = "debug")]
263    pub(crate) async fn open_with_tracker(
264        path: &Path,
265        block_size: usize,
266        known_size: Option<usize>,
267        io_tracker: Arc<IOTracker>,
268    ) -> Result<Box<dyn Reader>> {
269        let path = path.clone();
270        let local_path = to_local_path(&path);
271        tokio::task::spawn_blocking(move || {
272            let file = File::open(&local_path).map_err(|e| match e.kind() {
273                ErrorKind::NotFound => Error::not_found(path.to_string()),
274                _ => e.into(),
275            })?;
276            let size = OnceCell::new_with(known_size);
277            Ok(Box::new(Self {
278                file: Arc::new(file),
279                block_size,
280                size,
281                path,
282                io_tracker,
283            }) as Box<dyn Reader>)
284        })
285        .await?
286    }
287}
288
289impl Reader for LocalObjectReader {
290    fn path(&self) -> &Path {
291        &self.path
292    }
293
294    fn block_size(&self) -> usize {
295        self.block_size
296    }
297
298    fn io_parallelism(&self) -> usize {
299        DEFAULT_LOCAL_IO_PARALLELISM
300    }
301
302    /// Returns the file size.
303    fn size(&self) -> BoxFuture<'_, object_store::Result<usize>> {
304        Box::pin(async move {
305            let file = self.file.clone();
306            self.size
307                .get_or_try_init(|| async move {
308                    // The metadata lookup is this reader's equivalent of the HEAD
309                    // request a cloud reader makes to learn the object size.
310                    let metrics = self.io_tracker.begin_io("head");
311                    let result =
312                        join_local_io(tokio::task::spawn_blocking(move || file.metadata())).await;
313                    metrics.record(&result, 0);
314                    Ok(result?.len() as usize)
315                })
316                .await
317                .cloned()
318        })
319    }
320
321    /// Reads a range of data.
322    #[instrument(level = "debug", skip(self))]
323    fn get_range(&self, range: Range<usize>) -> BoxFuture<'static, object_store::Result<Bytes>> {
324        let file = self.file.clone();
325        let io_tracker = self.io_tracker.clone();
326        let path = self.path.clone();
327        let num_bytes = range.len() as u64;
328        let range_u64 = (range.start as u64)..(range.end as u64);
329
330        Box::pin(async move {
331            let metrics = io_tracker.begin_io("get");
332            let result = join_local_io(tokio::task::spawn_blocking(move || {
333                let mut buf = BytesMut::with_capacity(range.len());
334                // Safety: `buf` is set with appropriate capacity above. It is
335                // written to below and we check all data is initialized at that point.
336                unsafe { buf.set_len(range.len()) };
337                #[cfg(unix)]
338                file.read_exact_at(buf.as_mut(), range.start as u64)?;
339                #[cfg(windows)]
340                read_exact_at(file, buf.as_mut(), range.start as u64)?;
341
342                Ok(buf.freeze())
343            }))
344            .await;
345
346            metrics.record(&result, num_bytes);
347            if result.is_ok() {
348                io_tracker.record_read("get_range", path, num_bytes, Some(range_u64));
349            }
350
351            result
352        })
353    }
354
355    /// Reads the entire file.
356    #[instrument(level = "debug", skip(self))]
357    fn get_all(&self) -> BoxFuture<'_, object_store::Result<Bytes>> {
358        Box::pin(async move {
359            let mut file = self.file.clone();
360            let io_tracker = self.io_tracker.clone();
361            let path = self.path.clone();
362
363            let metrics = io_tracker.begin_io("get");
364            let result = join_local_io(tokio::task::spawn_blocking(move || {
365                let mut buf = Vec::new();
366                file.read_to_end(buf.as_mut())?;
367                Ok(Bytes::from(buf))
368            }))
369            .await;
370
371            let num_bytes = result.as_ref().map_or(0, |bytes| bytes.len() as u64);
372            metrics.record(&result, num_bytes);
373            if let Ok(bytes) = &result {
374                io_tracker.record_read("get_all", path, bytes.len() as u64, None);
375            }
376
377            result
378        })
379    }
380
381    fn get_stream(&self) -> BoxFuture<'_, object_store::Result<ByteStream>> {
382        Box::pin(async move {
383            let size = self.size().await?;
384            Ok(stream_local_range(
385                self.file.clone(),
386                self.path.clone(),
387                self.io_tracker.clone(),
388                0..size,
389                self.block_size.max(8 * 1024),
390            ))
391        })
392    }
393
394    fn get_range_stream(
395        &self,
396        range: Range<usize>,
397    ) -> BoxFuture<'_, object_store::Result<ByteStream>> {
398        let file = self.file.clone();
399        let path = self.path.clone();
400        let io_tracker = self.io_tracker.clone();
401        let chunk_size = self.block_size.max(8 * 1024);
402        Box::pin(async move {
403            Ok(stream_local_range(
404                file, path, io_tracker, range, chunk_size,
405            ))
406        })
407    }
408}
409
410#[cfg(windows)]
411pub(crate) fn read_exact_at(
412    file: Arc<File>,
413    mut buf: &mut [u8],
414    mut offset: u64,
415) -> std::io::Result<()> {
416    let expected_len = buf.len();
417    while !buf.is_empty() {
418        match file.seek_read(buf, offset) {
419            Ok(0) => break,
420            Ok(n) => {
421                let tmp = buf;
422                buf = &mut tmp[n..];
423                offset += n as u64;
424            }
425            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
426            Err(e) => return Err(e),
427        }
428    }
429    if !buf.is_empty() {
430        Err(std::io::Error::new(
431            std::io::ErrorKind::UnexpectedEof,
432            format!(
433                "failed to fill whole buffer. Expected {} bytes, got {}",
434                expected_len, offset
435            ),
436        ))
437    } else {
438        Ok(())
439    }
440}
441
442#[async_trait]
443impl Writer for tokio::fs::File {
444    async fn tell(&mut self) -> Result<usize> {
445        Ok(self.seek(SeekFrom::Current(0)).await? as usize)
446    }
447
448    async fn shutdown(&mut self) -> Result<WriteResult> {
449        let size = self.seek(SeekFrom::Current(0)).await? as usize;
450        tokio::io::AsyncWriteExt::shutdown(self).await?;
451        Ok(WriteResult { size, e_tag: None })
452    }
453}