Skip to main content

vortex_io/object_store/
filesystem.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! [`FileSystem`] implementation backed by an [`ObjectStore`].
5
6use std::fmt::Debug;
7use std::fmt::Formatter;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use futures::StreamExt;
12use futures::stream::BoxStream;
13use object_store::ObjectStore;
14use object_store::ObjectStoreExt;
15use object_store::path::Path;
16use vortex_error::VortexResult;
17use vortex_error::vortex_err;
18
19use crate::VortexReadAt;
20use crate::filesystem::FileListing;
21use crate::filesystem::FileSystem;
22use crate::object_store::ObjectStoreReadAt;
23use crate::runtime::Handle;
24
25/// A [`FileSystem`] backed by an [`ObjectStore`].
26// TODO(ngates): we could consider spawning a driver task inside this file system such that we can
27//  apply concurrency limits to the overall object store, rather than on a per-file basis.
28pub struct ObjectStoreFileSystem {
29    store: Arc<dyn ObjectStore>,
30    handle: Handle,
31}
32
33impl Debug for ObjectStoreFileSystem {
34    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
35        f.debug_struct("ObjectStoreFileSystem")
36            .field("store", &self.store)
37            .finish()
38    }
39}
40
41impl ObjectStoreFileSystem {
42    /// Create a new filesystem backed by the given object store and runtime handle.
43    pub fn new(store: Arc<dyn ObjectStore>, handle: Handle) -> Self {
44        Self { store, handle }
45    }
46
47    /// Create a new filesystem backed by a local file system object store and the given runtime
48    /// handle.
49    pub fn local(handle: Handle) -> Self {
50        Self::new(
51            Arc::new(object_store::local::LocalFileSystem::new()),
52            handle,
53        )
54    }
55}
56
57/// Convert a literal filesystem path into an object-store [`Path`] (key).
58///
59/// Object stores key their objects *literally*: a file named `a~b.vortex` has the key
60/// `a~b.vortex`, and `LocalFileSystem` likewise surfaces real filenames verbatim. [`Path::parse`]
61/// preserves those characters, whereas [`Path::from`] percent-encodes `~`, `%`, `[`, `]`, `#`,
62/// etc. — turning `a~b.vortex` into the key `a%7Eb.vortex`, which no real object has. Using
63/// `parse` keeps inputs and the keys returned by [`list`](FileSystem::list) on the same literal
64/// representation, so a path from `list`/`head` round-trips back through `open_read` unchanged.
65///
66/// `parse` rejects empty, `.`, and `..` segments; for those we fall back to [`Path::from`], which
67/// normalizes them (this never applies to a key `list` produced, so it cannot break a round-trip).
68fn to_object_path(path: &str) -> Path {
69    Path::parse(path).unwrap_or_else(|_| Path::from(path))
70}
71
72fn listing_from_meta(location: &Path, size: u64) -> FileListing {
73    FileListing {
74        path: location.to_string(),
75        size: Some(size),
76    }
77}
78
79#[async_trait]
80impl FileSystem for ObjectStoreFileSystem {
81    fn list(&self, prefix: &str) -> BoxStream<'_, VortexResult<FileListing>> {
82        let path = if prefix.is_empty() {
83            None
84        } else {
85            Some(to_object_path(prefix))
86        };
87        self.store
88            .list(path.as_ref())
89            .map(|result| {
90                result
91                    .map(|meta| listing_from_meta(&meta.location, meta.size))
92                    .map_err(Into::into)
93            })
94            .boxed()
95    }
96
97    async fn head(&self, path: &str) -> VortexResult<Option<FileListing>> {
98        // `head` issues a single metadata lookup (e.g. an S3 HEAD) for the exact key, unlike
99        // `list`, which enumerates by path-segment prefix and never returns the key itself.
100        match self.store.head(&to_object_path(path)).await {
101            Ok(meta) => Ok(Some(listing_from_meta(&meta.location, meta.size))),
102            Err(object_store::Error::NotFound { .. }) => Ok(None),
103            Err(e) => Err(e.into()),
104        }
105    }
106
107    async fn open_read(&self, path: &str) -> VortexResult<Arc<dyn VortexReadAt>> {
108        Ok(Arc::new(ObjectStoreReadAt::new(
109            Arc::clone(&self.store),
110            to_object_path(path),
111            self.handle.clone(),
112        )))
113    }
114
115    async fn delete(&self, path: &str) -> VortexResult<()> {
116        self.store
117            .delete(
118                &Path::from_url_path(path)
119                    .map_err(|_| vortex_err!("invalid path for url {path}"))?,
120            )
121            .await?;
122        Ok(())
123    }
124}
125
126// Exercises the fix against a real object store, whose `list` excludes the exact-path match.
127// `Handle::find` only resolves a runtime under the `tokio` feature, so gate these tests on it.
128#[cfg(test)]
129#[cfg(feature = "tokio")]
130mod tests {
131    use futures::TryStreamExt;
132    use object_store::ObjectStoreExt;
133    use object_store::local::LocalFileSystem;
134    use object_store::memory::InMemory;
135    use rstest::rstest;
136
137    use super::*;
138    use crate::filesystem::FileSystem;
139    use crate::runtime::Handle;
140
141    /// Build an [`ObjectStoreFileSystem`] over an in-memory store seeded with `(path, size)` files.
142    ///
143    /// Keys are written with [`to_object_path`] so the store holds the same literal keys a real
144    /// backend would (e.g. `a~b.vortex`, not the percent-encoded `a%7Eb.vortex`).
145    async fn memory_fs(files: &[(&str, usize)]) -> VortexResult<ObjectStoreFileSystem> {
146        let store = Arc::new(InMemory::new()) as Arc<dyn ObjectStore>;
147        for &(path, size) in files {
148            store
149                .put(&to_object_path(path), vec![0u8; size].into())
150                .await?;
151        }
152        let handle = Handle::find().expect("tokio runtime available within #[tokio::test]");
153        Ok(ObjectStoreFileSystem::new(store, handle))
154    }
155
156    /// Regression test for #6599: globbing an exact path that exists must return that one file.
157    /// `ObjectStore::list` never yields the prefix itself, so this would return nothing if the
158    /// exact-path branch used `list`.
159    #[tokio::test]
160    async fn test_glob_exact_existing_path() -> VortexResult<()> {
161        let fs = memory_fs(&[("data/file.vortex", 1024)]).await?;
162        let fs_dyn: &dyn FileSystem = &fs;
163        let results: Vec<FileListing> = fs_dyn.glob("data/file.vortex")?.try_collect().await?;
164        assert_eq!(results.len(), 1);
165        assert_eq!(results[0].path, "data/file.vortex");
166        assert_eq!(results[0].size, Some(1024));
167        Ok(())
168    }
169
170    #[tokio::test]
171    async fn test_glob_exact_missing_path_is_empty() -> VortexResult<()> {
172        let fs = memory_fs(&[("data/other.vortex", 1)]).await?;
173        let fs_dyn: &dyn FileSystem = &fs;
174        let results: Vec<FileListing> = fs_dyn.glob("data/missing.vortex")?.try_collect().await?;
175        assert!(results.is_empty());
176        Ok(())
177    }
178
179    /// `list("foo.vortex")` would surface the prefix-sibling `foo.vortex.backup`; `head` does not.
180    #[tokio::test]
181    async fn test_glob_exact_path_ignores_prefix_siblings() -> VortexResult<()> {
182        let fs = memory_fs(&[("foo.vortex", 10), ("foo.vortex.backup", 20)]).await?;
183        let fs_dyn: &dyn FileSystem = &fs;
184        let results: Vec<FileListing> = fs_dyn.glob("foo.vortex")?.try_collect().await?;
185        assert_eq!(results.len(), 1);
186        assert_eq!(results[0].path, "foo.vortex");
187        assert_eq!(results[0].size, Some(10));
188        Ok(())
189    }
190
191    /// Paths containing characters that `object_store` percent-encodes (`~ % [ ] # { } ^`, …)
192    /// must round-trip on a single literal-path convention: `head` returns the literal key, and
193    /// that exact string must reopen the file via `open_read` (what multi-file scan does).
194    /// Previously these were converted with `Path::from`, which encoded them into a key no real
195    /// object has, so the file was silently lost.
196    #[tokio::test]
197    #[rstest]
198    #[case::tilde("dir/a~b.vortex")]
199    #[case::percent("dir/a%20b.vortex")]
200    #[case::brackets("dir/a[1].vortex")]
201    #[case::hash("dir/a#b.vortex")]
202    #[case::braces("dir/a{x}.vortex")]
203    #[case::caret("dir/a^b.vortex")]
204    #[case::backslash_tilde("dir/a\\~b.vortex")]
205    #[case::space("dir/a b.vortex")]
206    async fn test_head_open_read_round_trip_special_chars(#[case] path: &str) -> VortexResult<()> {
207        let fs = memory_fs(&[(path, 5)]).await?;
208        // `head` returns the literal key, matching the caller's input.
209        assert_eq!(
210            fs.head(path).await?,
211            Some(FileListing {
212                path: path.to_string(),
213                size: Some(5),
214            })
215        );
216        // and that literal path reopens the file (`size()` issues a `head` under the hood).
217        assert_eq!(fs.open_read(path).await?.size().await?, 5);
218        Ok(())
219    }
220
221    /// Glob (both branches) over paths with encoded—but not glob-metacharacter—characters returns
222    /// the literal path, which must reopen the file. (`*`, `?`, `[` are excluded here: they make
223    /// the input a pattern rather than an exact path.)
224    #[tokio::test]
225    #[rstest]
226    #[case::tilde("dir/a~b.vortex")]
227    #[case::percent("dir/a%20b.vortex")]
228    #[case::hash("dir/a#b.vortex")]
229    #[case::backslash_tilde("dir/a\\~b.vortex")]
230    #[case::space("dir/a b.vortex")]
231    #[case::plain("dir/plain.vortex")]
232    async fn test_glob_round_trip_special_chars(#[case] path: &str) -> VortexResult<()> {
233        let fs = memory_fs(&[(path, 5)]).await?;
234        let fs_dyn: &dyn FileSystem = &fs;
235        let expected = FileListing {
236            path: path.to_string(),
237            size: Some(5),
238        };
239
240        // Exact-path glob returns the literal path.
241        let exact: Vec<FileListing> = fs_dyn.glob(path)?.try_collect().await?;
242        assert_eq!(exact, vec![expected.clone()]);
243
244        // Wildcard glob over the directory lists the same literal path (not an encoded one).
245        let wild: Vec<FileListing> = fs_dyn.glob("dir/*.vortex")?.try_collect().await?;
246        assert!(
247            wild.contains(&expected),
248            "wildcard glob should list {path:?}, got {wild:?}"
249        );
250
251        // The returned path reopens the file.
252        assert_eq!(fs.open_read(path).await?.size().await?, 5);
253        Ok(())
254    }
255
256    /// The same round-trip against the real local-filesystem backend: a `~` in an on-disk filename
257    /// must be addressed literally (converting it with `Path::from` would percent-encode it and
258    /// miss the file). Confirms the literal-path convention holds across backends, not just for the
259    /// in-memory store.
260    #[tokio::test]
261    async fn test_local_filesystem_special_char_round_trip() -> anyhow::Result<()> {
262        let dir = tempfile::tempdir()?;
263        std::fs::write(dir.path().join("a~b.vortex"), [0u8; 5])?;
264        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?) as Arc<dyn ObjectStore>;
265        let fs = ObjectStoreFileSystem::new(store, Handle::find().expect("tokio runtime"));
266        let fs_dyn: &dyn FileSystem = &fs;
267        let expected = FileListing {
268            path: "a~b.vortex".to_string(),
269            size: Some(5),
270        };
271
272        let wild: Vec<FileListing> = fs_dyn.glob("*.vortex")?.try_collect().await?;
273        assert!(wild.contains(&expected), "wildcard glob got {wild:?}");
274
275        let exact: Vec<FileListing> = fs_dyn.glob("a~b.vortex")?.try_collect().await?;
276        assert_eq!(exact, vec![expected]);
277
278        assert_eq!(fs.open_read("a~b.vortex").await?.size().await?, 5);
279        Ok(())
280    }
281
282    #[tokio::test]
283    async fn test_head_existing_and_missing() -> VortexResult<()> {
284        let fs = memory_fs(&[("a/b.vortex", 7)]).await?;
285        assert_eq!(
286            fs.head("a/b.vortex").await?,
287            Some(FileListing {
288                path: "a/b.vortex".to_string(),
289                size: Some(7),
290            })
291        );
292        assert_eq!(fs.head("a/missing.vortex").await?, None);
293        Ok(())
294    }
295}