use object_store::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore};
use object_store::{ListResult, ObjectMeta, ObjectStore as OSObjectStore, path::Path};
use tracing::instrument;
use lance_core::{Error, Result};
use super::ObjectStore;
#[cfg(feature = "metrics")]
use crate::object_store::metrics::{InFlightGuard, record_outcome};
#[cfg(feature = "metrics")]
use std::time::Instant;
const DELIMITER: &str = "/";
const LIST_OP: &str = "list_paginated";
#[derive(Debug, Clone, Default)]
pub struct ReadDirOptions {
pub page_token: Option<String>,
pub limit: Option<usize>,
}
impl ObjectStore {
pub async fn read_dir_page(
&self,
dir: impl Into<Path>,
options: ReadDirOptions,
) -> Result<PaginatedListResult> {
let dir = dir.into();
if options.limit == Some(0) {
return Err(Error::invalid_input(
"read_dir_page limit must be at least 1, got 0",
));
}
match &self.paginated_lister {
Some(lister) => self.pushdown_page(lister.as_ref(), &dir, options).await,
None => full_listing_page(self.inner.as_ref(), &dir, options).await,
}
}
#[instrument(level = "debug", skip_all, fields(dir = %dir))]
async fn pushdown_page(
&self,
lister: &dyn PaginatedListStore,
dir: &Path,
options: ReadDirOptions,
) -> Result<PaginatedListResult> {
let prefix = list_prefix(dir);
self.io_tracker.record_read(LIST_OP, dir.clone(), 0, None);
#[cfg(feature = "metrics")]
let _in_flight = InFlightGuard::new(&self.store_prefix, LIST_OP);
#[cfg(feature = "metrics")]
let start = Instant::now();
let page = lister
.list_paginated(
prefix.as_deref(),
PaginatedListOptions {
delimiter: Some(DELIMITER.into()),
max_keys: options.limit,
page_token: options.page_token,
..Default::default()
},
)
.await;
#[cfg(feature = "metrics")]
record_outcome(&self.store_prefix, LIST_OP, start, 0, page.is_err());
let mut page = page?;
retain_children(&mut page.result, prefix.as_deref());
Ok(page)
}
}
fn list_prefix(dir: &Path) -> Option<String> {
let dir = dir.as_ref();
(!dir.is_empty()).then(|| format!("{dir}{DELIMITER}"))
}
async fn full_listing_page(
store: &dyn OSObjectStore,
dir: &Path,
options: ReadDirOptions,
) -> Result<PaginatedListResult> {
let mut listed = store.list_with_delimiter(Some(dir)).await?;
let extensions = std::mem::take(&mut listed.extensions);
let mut children = keyed_children(listed, list_prefix(dir).as_deref());
if let Some(resume) = &options.page_token {
children.retain(|child| child.key > *resume);
}
let total = children.len();
children.truncate(options.limit.unwrap_or(total).min(total));
let page_token = match children.last() {
Some(last) if children.len() < total => Some(last.key.clone()),
_ => None,
};
let mut result = ListResult {
common_prefixes: Vec::new(),
objects: Vec::new(),
extensions,
};
for child in children {
match child.child {
Child::Directory(location) => result.common_prefixes.push(location),
Child::File(meta) => result.objects.push(meta),
}
}
Ok(PaginatedListResult { result, page_token })
}
fn retain_children(listed: &mut ListResult, prefix: Option<&str>) {
listed
.common_prefixes
.retain(|location| relative_key(prefix, location).is_some());
listed
.objects
.retain(|object| relative_key(prefix, &object.location).is_some());
}
struct KeyedChild {
key: String,
child: Child,
}
enum Child {
Directory(Path),
File(ObjectMeta),
}
fn keyed_children(listed: ListResult, prefix: Option<&str>) -> Vec<KeyedChild> {
let ListResult {
common_prefixes,
objects,
..
} = listed;
let directories = common_prefixes.into_iter().filter_map(|location| {
let key = format!("{}{DELIMITER}", relative_key(prefix, &location)?);
Some(KeyedChild {
key,
child: Child::Directory(location),
})
});
let files = objects.into_iter().filter_map(|meta| {
let key = relative_key(prefix, &meta.location)?.to_string();
Some(KeyedChild {
key,
child: Child::File(meta),
})
});
let mut children: Vec<KeyedChild> = directories.chain(files).collect();
children.sort_unstable_by(|left, right| left.key.cmp(&right.key));
children
}
fn relative_key<'a>(prefix: Option<&str>, location: &'a Path) -> Option<&'a str> {
let location = location.as_ref();
let relative = match prefix {
Some(prefix) => location.strip_prefix(prefix)?,
None => location,
};
(!relative.is_empty()).then_some(relative)
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::*;
use crate::object_store::{ObjectStoreParams, ObjectStoreRegistry};
use chrono::Utc;
use object_store::memory::InMemory;
use object_store::{ObjectStoreExt, PutPayload};
use rstest::rstest;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Backend {
FullListing,
Pushdown,
}
use Backend::{FullListing, Pushdown};
#[derive(Debug, Clone)]
struct ListRequest {
prefix: Option<String>,
opts: PaginatedListOptions,
}
#[derive(Debug)]
struct FakeListStore {
keys: Vec<String>,
page_bound: usize,
requests: Arc<Mutex<Vec<ListRequest>>>,
}
#[async_trait::async_trait]
impl PaginatedListStore for FakeListStore {
async fn list_paginated(
&self,
prefix: Option<&str>,
opts: PaginatedListOptions,
) -> object_store::Result<PaginatedListResult> {
self.requests.lock().unwrap().push(ListRequest {
prefix: prefix.map(String::from),
opts: opts.clone(),
});
let prefix = prefix.unwrap_or("");
let budget = opts
.max_keys
.unwrap_or(self.page_bound)
.min(self.page_bound);
let mut result = ListResult {
common_prefixes: Vec::new(),
objects: Vec::new(),
extensions: Default::default(),
};
let mut idx: usize = match &opts.page_token {
Some(token) => token.parse().expect("a token this store minted"),
None => 0,
};
while idx < self.keys.len() {
if result.common_prefixes.len() + result.objects.len() >= budget {
return Ok(PaginatedListResult {
result,
page_token: Some(idx.to_string()),
});
}
let key = self.keys[idx].clone();
idx += 1;
let Some(rest) = key.strip_prefix(prefix) else {
continue;
};
match rest.find(DELIMITER) {
Some(end) => {
let child = format!("{prefix}{}", &rest[..=end]);
result.common_prefixes.push(Path::parse(&child).unwrap());
while idx < self.keys.len() && self.keys[idx].starts_with(&child) {
idx += 1;
}
}
None => result.objects.push(ObjectMeta {
location: Path::parse(&key).unwrap(),
last_modified: Utc::now(),
size: 1,
e_tag: None,
version: None,
}),
}
}
Ok(PaginatedListResult {
result,
page_token: None,
})
}
}
struct TestStore {
store: ObjectStore,
requests: Arc<Mutex<Vec<ListRequest>>>,
}
impl TestStore {
async fn walk(&self, dir: &str, limit: Option<usize>) -> Result<Vec<String>> {
let mut names = Vec::new();
let mut page_token = None;
for _ in 0..100 {
let page = self
.store
.read_dir_page(Path::from(dir), ReadDirOptions { page_token, limit })
.await?;
names.extend(page_names(&page));
page_token = page.page_token;
if page_token.is_none() {
return Ok(names);
}
}
panic!("the walk is not making progress: {names:?}")
}
async fn names(&self, dir: &str, limit: Option<usize>) -> Vec<String> {
self.walk(dir, limit).await.unwrap()
}
async fn first_page(&self, dir: &str, limit: Option<usize>) -> PaginatedListResult {
self.store
.read_dir_page(
Path::from(dir),
ReadDirOptions {
page_token: None,
limit,
},
)
.await
.unwrap()
}
}
fn page_names(page: &PaginatedListResult) -> Vec<String> {
page.result
.common_prefixes
.iter()
.chain(page.result.objects.iter().map(|object| &object.location))
.map(|location| location.filename().unwrap().to_string())
.collect()
}
async fn test_store(backend: Backend, keys: &[&str]) -> TestStore {
paged_test_store(backend, keys, usize::MAX).await
}
async fn paged_test_store(backend: Backend, keys: &[&str], page_bound: usize) -> TestStore {
let inner = Arc::new(InMemory::new());
for key in keys {
inner
.put(&Path::parse(key).unwrap(), PutPayload::from_static(b"x"))
.await
.unwrap();
}
#[allow(deprecated)]
let params = ObjectStoreParams {
object_store: Some((inner, url::Url::parse("memory:///").unwrap())),
list_is_lexically_ordered: Some(false),
..Default::default()
};
let (store, _) = ObjectStore::from_uri_and_params(
Arc::new(ObjectStoreRegistry::default()),
"memory:///",
¶ms,
)
.await
.unwrap();
let mut store = Arc::try_unwrap(store).unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
if backend == Pushdown {
store.paginated_lister = Some(Arc::new(FakeListStore {
keys: keys.iter().map(|key| key.to_string()).collect(),
page_bound,
requests: requests.clone(),
}));
}
TestStore { store, requests }
}
const TABLES: &[&str] = &[
"db/a.lance/_versions/1.manifest",
"db/a.lance/data/1.lance",
"db/b.lance/data/1.lance",
"db/c.lance/data/1.lance",
"db/loose.txt",
"other/d.lance/data/1.lance",
];
#[tokio::test]
async fn test_full_listing_page_preserves_response_extensions() {
let mut store = crate::testing::MockObjectStore::new();
store.expect_list_with_delimiter().once().returning(|_| {
let mut extensions = object_store::Extensions::new();
extensions.insert(String::from("listing-request-id"));
Ok(ListResult {
common_prefixes: vec![Path::from("db/b"), Path::from("db/a")],
objects: Vec::new(),
extensions,
})
});
let page = full_listing_page(
&store,
&Path::from("db"),
ReadDirOptions {
limit: Some(1),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(page.result.common_prefixes, vec![Path::from("db/a")]);
assert_eq!(page.page_token.as_deref(), Some("a/"));
assert_eq!(
page.result.extensions.get::<String>().map(String::as_str),
Some("listing-request-id")
);
}
#[rstest]
#[case::whole_directory(TABLES, "db", vec!["a.lance", "b.lance", "c.lance", "loose.txt"])]
#[case::empty_directory(TABLES, "nonexistent", vec![])]
#[case::the_sibling_after_a_directory(&["db/foo/inside", "db/foo0"], "db", vec!["foo", "foo0"])]
#[case::a_prefix_shaped_sibling(&["db/foo/inside", "db/foo-bar/inside", "db/zzz.txt"], "db", vec!["foo", "foo-bar", "zzz.txt"])]
#[case::a_directory_marker(&["db/marked/", "db/marked/a.txt", "db/marked/b.txt"], "db/marked", vec!["a.txt", "b.txt"])]
#[case::an_encodable_name(&["db/az", "db/a~"], "db", vec!["az", "a~"])]
#[tokio::test]
async fn test_walking_a_directory_is_complete(
#[values(FullListing, Pushdown)] backend: Backend,
#[values(None, Some(1), Some(2), Some(3))] limit: Option<usize>,
#[case] keys: &[&str],
#[case] dir: &str,
#[case] expected: Vec<&str>,
) {
let store = test_store(backend, keys).await;
let mut listed = store.names(dir, limit).await;
let seen = listed.clone();
listed.sort();
assert_eq!(listed, expected, "from {seen:?}");
}
#[tokio::test]
async fn test_an_unordered_store_is_still_paged() {
let reversed: Vec<&str> = TABLES.iter().rev().copied().collect();
let store = test_store(Pushdown, &reversed).await;
let mut names = store.names("db", Some(1)).await;
names.sort();
assert_eq!(names, vec!["a.lance", "b.lance", "c.lance", "loose.txt"]);
assert!(
!store.requests.lock().unwrap().is_empty(),
"the paginated lister should have been used"
);
}
#[tokio::test]
async fn test_a_bounded_page_is_one_request_for_that_page() {
let store = test_store(Pushdown, TABLES).await;
let page = store.first_page("db", Some(1)).await;
assert_eq!(page_names(&page).len(), 1);
assert!(page.page_token.is_some());
let requests = store.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].prefix.as_deref(), Some("db/"));
assert_eq!(requests[0].opts.max_keys, Some(1));
assert_eq!(requests[0].opts.delimiter.as_deref(), Some(DELIMITER));
assert_eq!(requests[0].opts.offset, None);
}
#[tokio::test]
async fn test_a_short_page_is_not_the_end_of_the_listing() {
let store = paged_test_store(Pushdown, TABLES, 1).await;
let page = store.first_page("db", Some(3)).await;
assert_eq!(page_names(&page).len(), 1);
assert!(
page.page_token.is_some(),
"the directory holds four children"
);
assert_eq!(
store.names("db", Some(3)).await.len(),
4,
"the walk should still reach every child"
);
}
#[tokio::test]
async fn test_zero_limit_is_rejected() {
let store = test_store(Pushdown, TABLES).await;
let err = store.walk("db", Some(0)).await.unwrap_err();
assert!(matches!(err, Error::InvalidInput { .. }), "{err:?}");
assert!(err.to_string().contains("limit must be at least 1"));
assert!(store.requests.lock().unwrap().is_empty());
}
#[rstest]
#[tokio::test]
async fn test_directories_and_files_stay_apart(
#[values(FullListing, Pushdown)] backend: Backend,
) {
let store = test_store(backend, TABLES).await;
let page = store.first_page("db", None).await;
let mut directories: Vec<&str> = page
.result
.common_prefixes
.iter()
.map(|location| location.filename().unwrap())
.collect();
directories.sort();
assert_eq!(directories, vec!["a.lance", "b.lance", "c.lance"]);
let files: Vec<&str> = page
.result
.objects
.iter()
.map(|object| object.location.filename().unwrap())
.collect();
assert_eq!(files, vec!["loose.txt"]);
assert_eq!(page.result.objects[0].size, 1);
}
#[rstest]
#[tokio::test]
async fn test_listing_is_recorded_in_io_stats(
#[values(FullListing, Pushdown)] backend: Backend,
) {
let store = test_store(backend, TABLES).await;
assert_eq!(store.store.io_tracker().stats().read_iops, 0);
let _ = store.first_page("db", Some(2)).await;
assert_eq!(store.store.io_tracker().stats().read_iops, 1);
}
}