Skip to main content

lance_io/object_store/
read_dir.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Paginated listing of a single directory level.
5//!
6//! [`ObjectStore::read_dir_page`] returns one page of the immediate children of a prefix, plus
7//! a token that resumes after it. Where the backend implements `object_store`'s paginated list
8//! API — S3, GCS and Azure do — the page size and the resume position are pushed into the list
9//! request, so a caller that wants the first few children pays for the first few children
10//! rather than for the whole prefix. Everything else lists the level in full and pages
11//! locally, which is correct but costs what the whole directory costs.
12//!
13//! The token is opaque, and a caller only ever hands it back: it is the backend's own
14//! continuation token where there is one, and the last key of the page where there is not.
15//! That is what lets a store without key-ordered listings, such as S3 Express, be paged at
16//! all — nothing outside this module compares one token to another.
17
18use object_store::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore};
19use object_store::{ListResult, ObjectMeta, ObjectStore as OSObjectStore, path::Path};
20use tracing::instrument;
21
22use lance_core::{Error, Result};
23
24use super::ObjectStore;
25
26#[cfg(feature = "metrics")]
27use crate::object_store::metrics::{InFlightGuard, record_outcome};
28#[cfg(feature = "metrics")]
29use std::time::Instant;
30
31/// The path delimiter that separates directory levels.
32const DELIMITER: &str = "/";
33
34/// Operation label for the metrics and IO statistics a paginated listing records.
35const LIST_OP: &str = "list_paginated";
36
37/// Options for [`ObjectStore::read_dir_page`].
38#[derive(Debug, Clone, Default)]
39pub struct ReadDirOptions {
40    /// Resume after the page a previous call returned, using the token it handed back.
41    ///
42    /// A token means something only to the store that minted it, and only for the directory
43    /// it was minted over. Handing one to a different store resumes from the wrong place
44    /// rather than failing.
45    pub page_token: Option<String>,
46    /// The page size to ask the backend for. Must be at least one. `None` lets the backend
47    /// return as much as it will.
48    pub limit: Option<usize>,
49}
50
51impl ObjectStore {
52    /// One page of the immediate children of `dir`, one directory level deep.
53    ///
54    /// On backends with a paginated list API — S3, GCS and Azure — the resume position and the
55    /// page size are pushed into the list request, so the page costs what the page holds.
56    /// Elsewhere the directory is listed in full and paged locally, which is correct but no
57    /// cheaper than [`Self::read_dir`].
58    ///
59    /// Child directories come back as [`ListResult::common_prefixes`] and child objects as
60    /// [`ListResult::objects`], the same split [`Self::list_with_delimiter`] returns.
61    ///
62    /// One page is one request, so a page can hold fewer children than `limit` asked for and
63    /// still be followed by more: with a delimiter a backend spends its page budget on keys it
64    /// collapses away, and it has a cap of its own besides. Walk until
65    /// [`PaginatedListResult::page_token`] is `None` rather than until a page comes back short.
66    ///
67    /// ```
68    /// # use lance_io::object_store::{ObjectStore, ReadDirOptions};
69    /// # async fn example(store: &ObjectStore) -> lance_core::Result<Vec<String>> {
70    /// let mut tables = Vec::new();
71    /// let mut page_token = None;
72    /// loop {
73    ///     let page = store
74    ///         .read_dir_page("my_db", ReadDirOptions { page_token, limit: Some(10) })
75    ///         .await?;
76    ///     // A table is a directory, so a loose object that happens to be named like one is not
77    ///     // a table.
78    ///     tables.extend(page.result.common_prefixes.iter().filter_map(|table| {
79    ///         Some(table.filename()?.strip_suffix(".lance")?.to_string())
80    ///     }));
81    ///     page_token = page.page_token;
82    ///     if page_token.is_none() || tables.len() >= 10 {
83    ///         break;
84    ///     }
85    /// }
86    /// # Ok(tables)
87    /// # }
88    /// ```
89    pub async fn read_dir_page(
90        &self,
91        dir: impl Into<Path>,
92        options: ReadDirOptions,
93    ) -> Result<PaginatedListResult> {
94        let dir = dir.into();
95        // A page of nothing cannot advance a listing, and the two paths below would disagree
96        // about what it means: the pushdown path would report an empty directory while the
97        // full listing ignored the limit and returned everything.
98        if options.limit == Some(0) {
99            return Err(Error::invalid_input(
100                "read_dir_page limit must be at least 1, got 0",
101            ));
102        }
103        match &self.paginated_lister {
104            Some(lister) => self.pushdown_page(lister.as_ref(), &dir, options).await,
105            // Goes through `inner`, so the wrappers around it instrument the request. The
106            // pushdown path talks to the backend directly and instruments itself.
107            None => full_listing_page(self.inner.as_ref(), &dir, options).await,
108        }
109    }
110
111    /// One page from the backend's own paginated list API.
112    ///
113    /// The pushdown path holds the backend directly, so its request never passes through the
114    /// wrappers around [`Self::inner`] that would otherwise record it, and it records itself.
115    #[instrument(level = "debug", skip_all, fields(dir = %dir))]
116    async fn pushdown_page(
117        &self,
118        lister: &dyn PaginatedListStore,
119        dir: &Path,
120        options: ReadDirOptions,
121    ) -> Result<PaginatedListResult> {
122        let prefix = list_prefix(dir);
123        self.io_tracker.record_read(LIST_OP, dir.clone(), 0, None);
124        #[cfg(feature = "metrics")]
125        let _in_flight = InFlightGuard::new(&self.store_prefix, LIST_OP);
126        #[cfg(feature = "metrics")]
127        let start = Instant::now();
128
129        let page = lister
130            .list_paginated(
131                prefix.as_deref(),
132                PaginatedListOptions {
133                    delimiter: Some(DELIMITER.into()),
134                    max_keys: options.limit,
135                    page_token: options.page_token,
136                    // `offset` is left unset: a continuation token is a position of its own,
137                    // and a caller-supplied key means something different on every store —
138                    // S3 excludes it, Azure includes it.
139                    ..Default::default()
140                },
141            )
142            .await;
143
144        #[cfg(feature = "metrics")]
145        record_outcome(&self.store_prefix, LIST_OP, start, 0, page.is_err());
146        let mut page = page?;
147
148        retain_children(&mut page.result, prefix.as_deref());
149        Ok(page)
150    }
151}
152
153/// The prefix to list under, carrying the trailing delimiter that the paginated API expects.
154/// `None` for the root of the store, which has no prefix at all.
155fn list_prefix(dir: &Path) -> Option<String> {
156    let dir = dir.as_ref();
157    (!dir.is_empty()).then(|| format!("{dir}{DELIMITER}"))
158}
159
160/// One page of a directory on a store with no paginated list API: list the level in full and
161/// page it locally.
162///
163/// The page has to be the smallest `limit` children past the token rather than any `limit` of
164/// them, since the next call lists the same directory again and keeps only what sorts after
165/// the key this page hands back. That means putting the listing in key order, which
166/// `list_with_delimiter` does not promise — a sort over children already in memory, costing no
167/// extra request.
168async fn full_listing_page(
169    store: &dyn OSObjectStore,
170    dir: &Path,
171    options: ReadDirOptions,
172) -> Result<PaginatedListResult> {
173    let mut listed = store.list_with_delimiter(Some(dir)).await?;
174    let extensions = std::mem::take(&mut listed.extensions);
175    let mut children = keyed_children(listed, list_prefix(dir).as_deref());
176    if let Some(resume) = &options.page_token {
177        children.retain(|child| child.key > *resume);
178    }
179    let total = children.len();
180    children.truncate(options.limit.unwrap_or(total).min(total));
181    // The last key this page took, so a page that took nothing ends the listing rather than
182    // resuming from a position no page ever reached.
183    let page_token = match children.last() {
184        Some(last) if children.len() < total => Some(last.key.clone()),
185        _ => None,
186    };
187
188    let mut result = ListResult {
189        common_prefixes: Vec::new(),
190        objects: Vec::new(),
191        extensions,
192    };
193    for child in children {
194        match child.child {
195            Child::Directory(location) => result.common_prefixes.push(location),
196            Child::File(meta) => result.objects.push(meta),
197        }
198    }
199    Ok(PaginatedListResult { result, page_token })
200}
201
202/// Drop everything in `listed` that is not a child of the level being listed.
203///
204/// This covers the marker object some stores keep for a directory: it lists as an object whose
205/// location is the directory's own prefix.
206fn retain_children(listed: &mut ListResult, prefix: Option<&str>) {
207    listed
208        .common_prefixes
209        .retain(|location| relative_key(prefix, location).is_some());
210    listed
211        .objects
212        .retain(|object| relative_key(prefix, &object.location).is_some());
213}
214
215/// A child of the directory being listed, with the key the backend listed it under.
216struct KeyedChild {
217    /// The key relative to the directory, which is what a full-listing token names. A child
218    /// directory keeps its trailing delimiter, since that is the prefix its keys share and so
219    /// where it sits in the listing; a child file is its name.
220    key: String,
221    child: Child,
222}
223
224enum Child {
225    Directory(Path),
226    File(ObjectMeta),
227}
228
229/// The children of `prefix` in `listed`, in key order, each with the key it was listed under.
230///
231/// Stores report common prefixes and objects as two separate lists, so the two are put back
232/// into one order here — the order a full-listing token pages through. Anything that is not a
233/// child of this level is dropped, as in [`retain_children`].
234fn keyed_children(listed: ListResult, prefix: Option<&str>) -> Vec<KeyedChild> {
235    let ListResult {
236        common_prefixes,
237        objects,
238        ..
239    } = listed;
240    let directories = common_prefixes.into_iter().filter_map(|location| {
241        let key = format!("{}{DELIMITER}", relative_key(prefix, &location)?);
242        Some(KeyedChild {
243            key,
244            child: Child::Directory(location),
245        })
246    });
247    let files = objects.into_iter().filter_map(|meta| {
248        let key = relative_key(prefix, &meta.location)?.to_string();
249        Some(KeyedChild {
250            key,
251            child: Child::File(meta),
252        })
253    });
254    let mut children: Vec<KeyedChild> = directories.chain(files).collect();
255    children.sort_unstable_by(|left, right| left.key.cmp(&right.key));
256    children
257}
258
259/// Where a listed location sits inside the directory being listed, which is the space
260/// full-listing tokens live in, or `None` if it is not a child of that directory at all.
261fn relative_key<'a>(prefix: Option<&str>, location: &'a Path) -> Option<&'a str> {
262    let location = location.as_ref();
263    let relative = match prefix {
264        // Both halves of the prefix, so a location that merely starts with the directory's
265        // name — `dbx/y` against `db/` — is reported as not being under it, and so is the
266        // directory's own marker, whose location is `db`.
267        Some(prefix) => location.strip_prefix(prefix)?,
268        None => location,
269    };
270    (!relative.is_empty()).then_some(relative)
271}
272
273#[cfg(test)]
274mod tests {
275    use std::sync::{Arc, Mutex};
276
277    use super::*;
278    use crate::object_store::{ObjectStoreParams, ObjectStoreRegistry};
279    use chrono::Utc;
280    use object_store::memory::InMemory;
281    use object_store::{ObjectStoreExt, PutPayload};
282    use rstest::rstest;
283
284    /// How the store under test resolves a listing.
285    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
286    enum Backend {
287        /// No paginated API: list the whole directory and page it locally.
288        FullListing,
289        /// A paginated API, as the native S3, GCS and Azure stores have.
290        Pushdown,
291    }
292    use Backend::{FullListing, Pushdown};
293
294    /// One list request, as the backend saw it.
295    #[derive(Debug, Clone)]
296    struct ListRequest {
297        prefix: Option<String>,
298        opts: PaginatedListOptions,
299    }
300
301    /// A stand-in for a store with a paginated list API.
302    ///
303    /// Keys are listed in the order they were given, a delimiter collapses each level, and the
304    /// continuation token is a position in that listing order — which is what a real token is:
305    /// exact, and never compared against a key. `page_bound` is the store's own cap, which is
306    /// why a page can come back holding less than it was asked for.
307    #[derive(Debug)]
308    struct FakeListStore {
309        keys: Vec<String>,
310        page_bound: usize,
311        requests: Arc<Mutex<Vec<ListRequest>>>,
312    }
313
314    #[async_trait::async_trait]
315    impl PaginatedListStore for FakeListStore {
316        async fn list_paginated(
317            &self,
318            prefix: Option<&str>,
319            opts: PaginatedListOptions,
320        ) -> object_store::Result<PaginatedListResult> {
321            self.requests.lock().unwrap().push(ListRequest {
322                prefix: prefix.map(String::from),
323                opts: opts.clone(),
324            });
325            let prefix = prefix.unwrap_or("");
326            let budget = opts
327                .max_keys
328                .unwrap_or(self.page_bound)
329                .min(self.page_bound);
330            let mut result = ListResult {
331                common_prefixes: Vec::new(),
332                objects: Vec::new(),
333                extensions: Default::default(),
334            };
335            let mut idx: usize = match &opts.page_token {
336                Some(token) => token.parse().expect("a token this store minted"),
337                None => 0,
338            };
339
340            while idx < self.keys.len() {
341                if result.common_prefixes.len() + result.objects.len() >= budget {
342                    return Ok(PaginatedListResult {
343                        result,
344                        page_token: Some(idx.to_string()),
345                    });
346                }
347                // `Path::parse`, so that a key holding a character `Path::from` would encode
348                // is reported under the name it was stored with. This is what the S3, GCS and
349                // Azure clients do.
350                let key = self.keys[idx].clone();
351                idx += 1;
352                let Some(rest) = key.strip_prefix(prefix) else {
353                    continue;
354                };
355                match rest.find(DELIMITER) {
356                    // A collapsed prefix, and everything behind it: a store reports the child
357                    // directory once and skips the keys it stands for.
358                    Some(end) => {
359                        let child = format!("{prefix}{}", &rest[..=end]);
360                        result.common_prefixes.push(Path::parse(&child).unwrap());
361                        while idx < self.keys.len() && self.keys[idx].starts_with(&child) {
362                            idx += 1;
363                        }
364                    }
365                    None => result.objects.push(ObjectMeta {
366                        location: Path::parse(&key).unwrap(),
367                        last_modified: Utc::now(),
368                        size: 1,
369                        e_tag: None,
370                        version: None,
371                    }),
372                }
373            }
374
375            Ok(PaginatedListResult {
376                result,
377                page_token: None,
378            })
379        }
380    }
381
382    struct TestStore {
383        store: ObjectStore,
384        requests: Arc<Mutex<Vec<ListRequest>>>,
385    }
386
387    impl TestStore {
388        /// Every child of `dir`, taken a page at a time, which is how a caller walks a
389        /// directory: the token ends the walk, never a short page.
390        async fn walk(&self, dir: &str, limit: Option<usize>) -> Result<Vec<String>> {
391            let mut names = Vec::new();
392            let mut page_token = None;
393            for _ in 0..100 {
394                let page = self
395                    .store
396                    .read_dir_page(Path::from(dir), ReadDirOptions { page_token, limit })
397                    .await?;
398                names.extend(page_names(&page));
399                page_token = page.page_token;
400                if page_token.is_none() {
401                    return Ok(names);
402                }
403            }
404            panic!("the walk is not making progress: {names:?}")
405        }
406
407        async fn names(&self, dir: &str, limit: Option<usize>) -> Vec<String> {
408            self.walk(dir, limit).await.unwrap()
409        }
410
411        /// The first page only, as a caller wanting a bounded number of children would take it.
412        async fn first_page(&self, dir: &str, limit: Option<usize>) -> PaginatedListResult {
413            self.store
414                .read_dir_page(
415                    Path::from(dir),
416                    ReadDirOptions {
417                        page_token: None,
418                        limit,
419                    },
420                )
421                .await
422                .unwrap()
423        }
424    }
425
426    /// The names of every child in a page, directories and files alike.
427    fn page_names(page: &PaginatedListResult) -> Vec<String> {
428        page.result
429            .common_prefixes
430            .iter()
431            .chain(page.result.objects.iter().map(|object| &object.location))
432            .map(|location| location.filename().unwrap().to_string())
433            .collect()
434    }
435
436    async fn test_store(backend: Backend, keys: &[&str]) -> TestStore {
437        paged_test_store(backend, keys, usize::MAX).await
438    }
439
440    /// A store over `keys`, listing them in the order given. `page_bound` is the store's own
441    /// cap on a page, which only the pushdown backend has.
442    async fn paged_test_store(backend: Backend, keys: &[&str], page_bound: usize) -> TestStore {
443        let inner = Arc::new(InMemory::new());
444        for key in keys {
445            // `Path::parse`, so that a key holding a character `Path::from` would encode is
446            // stored under the name it was given.
447            inner
448                .put(&Path::parse(key).unwrap(), PutPayload::from_static(b"x"))
449                .await
450                .unwrap();
451        }
452        #[allow(deprecated)]
453        let params = ObjectStoreParams {
454            object_store: Some((inner, url::Url::parse("memory:///").unwrap())),
455            // Set because the deprecated hand-built path assumes nothing about the store it
456            // was given, and set conservatively: nothing on the `read_dir_page` path reads it,
457            // since the fallback sorts what it listed and the pushdown never compares keys.
458            list_is_lexically_ordered: Some(false),
459            ..Default::default()
460        };
461        let (store, _) = ObjectStore::from_uri_and_params(
462            Arc::new(ObjectStoreRegistry::default()),
463            "memory:///",
464            &params,
465        )
466        .await
467        .unwrap();
468        let mut store = Arc::try_unwrap(store).unwrap();
469
470        let requests = Arc::new(Mutex::new(Vec::new()));
471        if backend == Pushdown {
472            store.paginated_lister = Some(Arc::new(FakeListStore {
473                keys: keys.iter().map(|key| key.to_string()).collect(),
474                page_bound,
475                requests: requests.clone(),
476            }));
477        }
478        TestStore { store, requests }
479    }
480
481    const TABLES: &[&str] = &[
482        "db/a.lance/_versions/1.manifest",
483        "db/a.lance/data/1.lance",
484        "db/b.lance/data/1.lance",
485        "db/c.lance/data/1.lance",
486        "db/loose.txt",
487        "other/d.lance/data/1.lance",
488    ];
489
490    #[tokio::test]
491    async fn test_full_listing_page_preserves_response_extensions() {
492        let mut store = crate::testing::MockObjectStore::new();
493        store.expect_list_with_delimiter().once().returning(|_| {
494            let mut extensions = object_store::Extensions::new();
495            extensions.insert(String::from("listing-request-id"));
496            Ok(ListResult {
497                common_prefixes: vec![Path::from("db/b"), Path::from("db/a")],
498                objects: Vec::new(),
499                extensions,
500            })
501        });
502
503        let page = full_listing_page(
504            &store,
505            &Path::from("db"),
506            ReadDirOptions {
507                limit: Some(1),
508                ..Default::default()
509            },
510        )
511        .await
512        .unwrap();
513
514        assert_eq!(page.result.common_prefixes, vec![Path::from("db/a")]);
515        assert_eq!(page.page_token.as_deref(), Some("a/"));
516        assert_eq!(
517            page.result.extensions.get::<String>().map(String::as_str),
518            Some("listing-request-id")
519        );
520    }
521
522    /// Walking a directory hands back every child exactly once, however the store resolves the
523    /// listing and however small the pages are. That it holds whatever order the store lists
524    /// in is [`test_an_unordered_store_is_still_paged`].
525    #[rstest]
526    #[case::whole_directory(TABLES, "db", vec!["a.lance", "b.lance", "c.lance", "loose.txt"])]
527    #[case::empty_directory(TABLES, "nonexistent", vec![])]
528    // A directory and the sibling that follows it: `foo/` and `foo0` are adjacent in key order
529    // with nothing between them, so resuming past `foo`'s contents must not swallow `foo0`.
530    #[case::the_sibling_after_a_directory(&["db/foo/inside", "db/foo0"], "db", vec!["foo", "foo0"])]
531    // Siblings where one name is a prefix of another, which is where a page boundary is easiest
532    // to get wrong: `foo/` and `foo-bar/` differ at `/` against `-`.
533    #[case::a_prefix_shaped_sibling(&["db/foo/inside", "db/foo-bar/inside", "db/zzz.txt"], "db", vec!["foo", "foo-bar", "zzz.txt"])]
534    // A store that keeps a marker object for a directory reports the directory itself when
535    // that directory is listed. Dropping the marker must not also drop the progress the page
536    // made, or a page holding nothing but the marker reads as the end of the listing.
537    #[case::a_directory_marker(&["db/marked/", "db/marked/a.txt", "db/marked/b.txt"], "db/marked", vec!["a.txt", "b.txt"])]
538    // A name holding a character `Path::from` would percent-encode is still reported, and
539    // sorted, under the name it was stored with.
540    #[case::an_encodable_name(&["db/az", "db/a~"], "db", vec!["az", "a~"])]
541    #[tokio::test]
542    async fn test_walking_a_directory_is_complete(
543        #[values(FullListing, Pushdown)] backend: Backend,
544        #[values(None, Some(1), Some(2), Some(3))] limit: Option<usize>,
545        #[case] keys: &[&str],
546        #[case] dir: &str,
547        #[case] expected: Vec<&str>,
548    ) {
549        let store = test_store(backend, keys).await;
550
551        // The order children come back in is the store's, so the walk is checked for holding
552        // every child once rather than for holding them in one particular order.
553        let mut listed = store.names(dir, limit).await;
554        let seen = listed.clone();
555        listed.sort();
556        assert_eq!(listed, expected, "from {seen:?}");
557    }
558
559    /// A store that lists in no particular order — S3 Express — is still paged, because a
560    /// continuation token is never compared to anything. This is the case a token spelled as a
561    /// key could not serve.
562    #[tokio::test]
563    async fn test_an_unordered_store_is_still_paged() {
564        let reversed: Vec<&str> = TABLES.iter().rev().copied().collect();
565        let store = test_store(Pushdown, &reversed).await;
566
567        let mut names = store.names("db", Some(1)).await;
568        names.sort();
569
570        assert_eq!(names, vec!["a.lance", "b.lance", "c.lance", "loose.txt"]);
571        assert!(
572            !store.requests.lock().unwrap().is_empty(),
573            "the paginated lister should have been used"
574        );
575    }
576
577    /// The point of the pushdown: a caller that wants one child of a directory makes one
578    /// request, for one child, one level deep. A listing that quietly fell back to reading the
579    /// whole directory would answer the same thing, so what tells the two apart is the request.
580    #[tokio::test]
581    async fn test_a_bounded_page_is_one_request_for_that_page() {
582        let store = test_store(Pushdown, TABLES).await;
583
584        let page = store.first_page("db", Some(1)).await;
585
586        assert_eq!(page_names(&page).len(), 1);
587        assert!(page.page_token.is_some());
588        let requests = store.requests.lock().unwrap();
589        assert_eq!(requests.len(), 1);
590        assert_eq!(requests[0].prefix.as_deref(), Some("db/"));
591        assert_eq!(requests[0].opts.max_keys, Some(1));
592        // Without a delimiter the listing would be recursive rather than one level deep.
593        assert_eq!(requests[0].opts.delimiter.as_deref(), Some(DELIMITER));
594        // A continuation token is a position of its own, so no offset goes with it.
595        assert_eq!(requests[0].opts.offset, None);
596    }
597
598    /// A backend that caps its pages below what was asked for hands back a short page with
599    /// more to come. Only the token ends a walk, so a caller that stopped at a short page
600    /// would report a directory as smaller than it is.
601    #[tokio::test]
602    async fn test_a_short_page_is_not_the_end_of_the_listing() {
603        let store = paged_test_store(Pushdown, TABLES, 1).await;
604
605        let page = store.first_page("db", Some(3)).await;
606
607        assert_eq!(page_names(&page).len(), 1);
608        assert!(
609            page.page_token.is_some(),
610            "the directory holds four children"
611        );
612        assert_eq!(
613            store.names("db", Some(3)).await.len(),
614            4,
615            "the walk should still reach every child"
616        );
617    }
618
619    /// A page of nothing is rejected rather than left to mean whatever the backend makes of it:
620    /// pushing it down reports an empty directory, and a full listing ignores it. The rejection
621    /// comes before the store is consulted, so one backend covers it.
622    #[tokio::test]
623    async fn test_zero_limit_is_rejected() {
624        let store = test_store(Pushdown, TABLES).await;
625
626        let err = store.walk("db", Some(0)).await.unwrap_err();
627
628        assert!(matches!(err, Error::InvalidInput { .. }), "{err:?}");
629        assert!(err.to_string().contains("limit must be at least 1"));
630        assert!(store.requests.lock().unwrap().is_empty());
631    }
632
633    /// Child directories and child objects stay in the two lists a listing reports them in, so
634    /// a caller that wants only one of the two — tables are directories — can take it.
635    #[rstest]
636    #[tokio::test]
637    async fn test_directories_and_files_stay_apart(
638        #[values(FullListing, Pushdown)] backend: Backend,
639    ) {
640        let store = test_store(backend, TABLES).await;
641
642        let page = store.first_page("db", None).await;
643
644        let mut directories: Vec<&str> = page
645            .result
646            .common_prefixes
647            .iter()
648            .map(|location| location.filename().unwrap())
649            .collect();
650        directories.sort();
651        assert_eq!(directories, vec!["a.lance", "b.lance", "c.lance"]);
652        let files: Vec<&str> = page
653            .result
654            .objects
655            .iter()
656            .map(|object| object.location.filename().unwrap())
657            .collect();
658        assert_eq!(files, vec!["loose.txt"]);
659        // The metadata a listing reports for a child object survives the page.
660        assert_eq!(page.result.objects[0].size, 1);
661    }
662
663    /// The pushdown path holds the backend directly, so it has to record its own IO. A listing
664    /// invisible to `io_tracker` would also be invisible to the metrics and tracing layers that
665    /// sit in the same chain.
666    #[rstest]
667    #[tokio::test]
668    async fn test_listing_is_recorded_in_io_stats(
669        #[values(FullListing, Pushdown)] backend: Backend,
670    ) {
671        let store = test_store(backend, TABLES).await;
672        assert_eq!(store.store.io_tracker().stats().read_iops, 0);
673
674        let _ = store.first_page("db", Some(2)).await;
675
676        // The full listing reaches the store through its wrappers, which record it there.
677        assert_eq!(store.store.io_tracker().stats().read_iops, 1);
678    }
679}