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 ¶ms,
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}