use std::sync::Arc;
use bytes::Bytes;
use delta_kernel::object_store::path::Path;
use delta_kernel::object_store::{self, DynObjectStore, ObjectStoreExt as _, PutMode};
use delta_kernel::{CancellationTokenRef, DeltaResult, Error, FileMeta, FileSlice, StorageHandler};
use futures::stream::{self, BoxStream, StreamExt, TryStreamExt};
use itertools::Itertools;
use url::Url;
use crate::executor::TaskExecutor;
use crate::UrlExt;
#[derive(Debug)]
pub struct ObjectStoreStorageHandler<E: TaskExecutor> {
inner: Arc<DynObjectStore>,
task_executor: Arc<E>,
readahead: usize,
}
impl<E: TaskExecutor> ObjectStoreStorageHandler<E> {
pub(crate) fn new(store: Arc<DynObjectStore>, task_executor: Arc<E>) -> Self {
Self {
inner: store,
task_executor,
readahead: 10,
}
}
pub fn with_readahead(mut self, readahead: usize) -> Self {
self.readahead = readahead;
self
}
}
async fn list_from_impl(
store: Arc<DynObjectStore>,
path: Url,
) -> DeltaResult<BoxStream<'static, DeltaResult<FileMeta>>> {
let offset = Path::from_url_path(path.path())?;
let prefix = if path.path().ends_with('/') {
offset.clone()
} else {
let mut parts = offset.parts().collect_vec();
if parts.pop().is_none() {
return Err(Error::Generic(format!(
"Offset path must not be a root directory. Got: '{path}'",
)));
}
Path::from_iter(parts)
};
let has_ordered_listing = supports_ordered_listing(&path);
let stream = store
.list_with_offset(Some(&prefix), &offset)
.map(move |meta| {
let meta = meta?;
let mut location = path.clone();
location.set_path(&format!("/{}", meta.location.as_ref()));
Ok(FileMeta {
location,
last_modified: meta.last_modified.timestamp_millis(),
size: meta.size,
})
});
if !has_ordered_listing {
let mut items: Vec<_> = stream.try_collect().await?;
items.sort_unstable();
Ok(Box::pin(stream::iter(
items.into_iter().map(Ok::<FileMeta, delta_kernel::Error>),
)))
} else {
Ok(Box::pin(stream))
}
}
async fn read_files_impl(
store: Arc<DynObjectStore>,
files: Vec<FileSlice>,
readahead: usize,
) -> DeltaResult<BoxStream<'static, DeltaResult<Bytes>>> {
let files = stream::iter(files).map(move |(url, range)| {
let store = store.clone();
async move {
let path = if url.scheme() == "file" {
let file_path = url
.to_file_path()
.map_err(|_| Error::InvalidTableLocation(format!("Invalid file URL: {url}")))?;
Path::from_absolute_path(file_path)
.map_err(|e| Error::InvalidTableLocation(format!("Invalid file path: {e}")))?
} else {
Path::from(url.path())
};
if url.is_presigned() {
Ok::<bytes::Bytes, Error>(reqwest::get(url).await?.bytes().await?)
} else if let Some(rng) = range {
Ok(store.get_range(&path, rng).await?)
} else {
let result = store.get(&path).await?;
Ok(result.bytes().await?)
}
}
});
Ok(Box::pin(files.buffered(readahead)))
}
async fn copy_atomic_impl(
store: Arc<DynObjectStore>,
src_path: Path,
dest_path: Path,
) -> DeltaResult<()> {
let data = store.get(&src_path).await?.bytes().await?;
store
.put_opts(&dest_path, data.into(), PutMode::Create.into())
.await
.map_err(|e| match e {
object_store::Error::AlreadyExists { .. } => Error::FileAlreadyExists(dest_path.into()),
e => e.into(),
})?;
Ok(())
}
async fn put_impl(
store: Arc<DynObjectStore>,
path: Path,
data: Bytes,
overwrite: bool,
) -> DeltaResult<()> {
let put_mode = if overwrite {
PutMode::Overwrite
} else {
PutMode::Create
};
let result = store.put_opts(&path, data.into(), put_mode.into()).await;
result.map_err(|e| match e {
object_store::Error::AlreadyExists { .. } => Error::FileAlreadyExists(path.into()),
e => e.into(),
})?;
Ok(())
}
async fn delete_impl(store: Arc<DynObjectStore>, path: Path) -> DeltaResult<()> {
match store.delete(&path).await {
Ok(()) => Ok(()),
Err(object_store::Error::NotFound { .. }) => Ok(()),
Err(e) => Err(e.into()),
}
}
async fn head_impl(store: Arc<DynObjectStore>, url: Url) -> DeltaResult<FileMeta> {
let meta = store.head(&Path::from_url_path(url.path())?).await?;
Ok(FileMeta {
location: url,
last_modified: meta.last_modified.timestamp_millis(),
size: meta.size,
})
}
impl<E: TaskExecutor> StorageHandler for ObjectStoreStorageHandler<E> {
fn list_from(
&self,
path: &Url,
) -> DeltaResult<Box<dyn Iterator<Item = DeltaResult<FileMeta>>>> {
self.list_from_with_cancellation(path, None)
}
fn list_from_with_cancellation(
&self,
path: &Url,
cancellation_token: Option<CancellationTokenRef>,
) -> DeltaResult<Box<dyn Iterator<Item = DeltaResult<FileMeta>>>> {
let future = list_from_impl(self.inner.clone(), path.clone());
let iter = super::stream_future_to_cancellable_iter(
self.task_executor.clone(),
future,
cancellation_token,
)?;
Ok(iter) }
fn read_files(
&self,
files: Vec<FileSlice>,
) -> DeltaResult<Box<dyn Iterator<Item = DeltaResult<Bytes>>>> {
self.read_files_with_cancellation(files, None)
}
fn read_files_with_cancellation(
&self,
files: Vec<FileSlice>,
cancellation_token: Option<CancellationTokenRef>,
) -> DeltaResult<Box<dyn Iterator<Item = DeltaResult<Bytes>>>> {
let future = read_files_impl(self.inner.clone(), files, self.readahead);
let iter = super::stream_future_to_cancellable_iter(
self.task_executor.clone(),
future,
cancellation_token,
)?;
Ok(iter) }
fn put(&self, path: &Url, data: Bytes, overwrite: bool) -> DeltaResult<()> {
let path = Path::from_url_path(path.path())?;
self.task_executor
.block_on(put_impl(self.inner.clone(), path, data, overwrite))
}
fn copy_atomic(&self, src: &Url, dest: &Url) -> DeltaResult<()> {
let src_path = Path::from_url_path(src.path())?;
let dest_path = Path::from_url_path(dest.path())?;
let future = copy_atomic_impl(self.inner.clone(), src_path, dest_path);
self.task_executor.block_on(future)
}
fn head(&self, path: &Url) -> DeltaResult<FileMeta> {
let future = head_impl(self.inner.clone(), path.clone());
self.task_executor.block_on(future)
}
fn delete(&self, path: &Url) -> DeltaResult<()> {
let path = Path::from_url_path(path.path())?;
self.task_executor
.block_on(delete_impl(self.inner.clone(), path))
}
}
fn supports_ordered_listing(url: &Url) -> bool {
!((url.scheme() == "file")
|| url.domain().map(|d| d.contains("--x-s3")).unwrap_or(false)
|| url.domain().map(|d| d.contains("-xa-s3")).unwrap_or(false))
}
#[cfg(test)]
mod tests {
use std::ops::Range;
use std::time::Duration;
use delta_kernel::object_store::local::LocalFileSystem;
use delta_kernel::object_store::memory::InMemory;
use delta_kernel::Engine as _;
use delta_kernel_default_engine_test_utils::current_time_duration;
use itertools::Itertools;
use test_utils::delta_path_for_version;
use super::*;
use crate::executor::tokio::TokioBackgroundExecutor;
use crate::DefaultEngineBuilder;
fn setup_test() -> (
tempfile::TempDir,
Arc<LocalFileSystem>,
ObjectStoreStorageHandler<TokioBackgroundExecutor>,
) {
let tmp = tempfile::tempdir().unwrap();
let store = Arc::new(LocalFileSystem::new());
let executor = Arc::new(TokioBackgroundExecutor::new());
let handler = ObjectStoreStorageHandler::new(store.clone(), executor);
(tmp, store, handler)
}
#[test]
fn test_ordered_listing_for_url() {
for (u, expected) in &[
(Url::parse("file:///dev/null").unwrap(), false),
(Url::parse("s3://robbert").unwrap(), true),
(Url::parse("s3://robbert/likes/paths").unwrap(), true),
(Url::parse("s3://robbie-one-zone--x-s3").unwrap(), false),
(
Url::parse("https://robbie-one-zone-xa-s3.us-east-2.amazonaws.biz").unwrap(),
false,
),
] {
assert_eq!(
*expected,
supports_ordered_listing(u),
"expected {expected} on {u:?}"
);
}
}
#[tokio::test]
async fn test_read_files() {
let tmp = tempfile::tempdir().unwrap();
let tmp_store = LocalFileSystem::new_with_prefix(tmp.path()).unwrap();
let data = Bytes::from("kernel-data");
tmp_store
.put(&Path::from("a"), data.clone().into())
.await
.unwrap();
tmp_store
.put(&Path::from("b"), data.clone().into())
.await
.unwrap();
tmp_store
.put(&Path::from("c"), data.clone().into())
.await
.unwrap();
let mut url = Url::from_directory_path(tmp.path()).unwrap();
let store = Arc::new(LocalFileSystem::new());
let executor = Arc::new(TokioBackgroundExecutor::new());
let storage = ObjectStoreStorageHandler::new(store, executor);
let mut slices: Vec<FileSlice> = Vec::new();
let mut url1 = url.clone();
url1.set_path(&format!("{}/b", url.path()));
slices.push((url1.clone(), Some(Range { start: 0, end: 6 })));
slices.push((url1, Some(Range { start: 7, end: 11 })));
url.set_path(&format!("{}/c", url.path()));
slices.push((url, Some(Range { start: 4, end: 9 })));
dbg!("Slices are: {}", &slices);
let data: Vec<Bytes> = storage.read_files(slices).unwrap().try_collect().unwrap();
assert_eq!(data.len(), 3);
assert_eq!(data[0], Bytes::from("kernel"));
assert_eq!(data[1], Bytes::from("data"));
assert_eq!(data[2], Bytes::from("el-da"));
}
#[tokio::test]
async fn test_file_meta_is_correct() {
let store = Arc::new(InMemory::new());
let begin_time = current_time_duration().unwrap();
let data = Bytes::from("kernel-data");
let name = delta_path_for_version(1, "json");
store.put(&name, data.clone().into()).await.unwrap();
let table_root = Url::parse("memory:///").expect("valid url");
let engine = DefaultEngineBuilder::new(store).build();
let files: Vec<_> = engine
.storage_handler()
.list_from(&table_root.join("_delta_log").unwrap().join("0").unwrap())
.unwrap()
.try_collect()
.unwrap();
assert!(!files.is_empty());
for meta in files.into_iter() {
let meta_time = Duration::from_millis(meta.last_modified.try_into().unwrap());
assert!(meta_time.abs_diff(begin_time) < Duration::from_secs(10));
}
}
#[tokio::test]
async fn test_default_engine_listing() {
let tmp = tempfile::tempdir().unwrap();
let tmp_store = LocalFileSystem::new_with_prefix(tmp.path()).unwrap();
let data = Bytes::from("kernel-data");
let expected_names: Vec<Path> =
(0..10).map(|i| delta_path_for_version(i, "json")).collect();
for name in expected_names.iter().rev() {
tmp_store.put(name, data.clone().into()).await.unwrap();
}
let url = Url::from_directory_path(tmp.path()).unwrap();
let store = Arc::new(LocalFileSystem::new());
let engine = DefaultEngineBuilder::new(store).build();
let files = engine
.storage_handler()
.list_from(&url.join("_delta_log").unwrap().join("0").unwrap())
.unwrap();
let mut len = 0;
for (file, expected) in files.zip(expected_names.iter()) {
assert!(
file.as_ref()
.unwrap()
.location
.path()
.ends_with(expected.as_ref()),
"{} does not end with {}",
file.unwrap().location.path(),
expected
);
len += 1;
}
assert_eq!(len, 10, "list_from should have returned 10 files");
}
#[tokio::test]
async fn test_copy() {
let (tmp, store, handler) = setup_test();
let data = Bytes::from("test-data");
let src_path = Path::from_absolute_path(tmp.path().join("src.txt")).unwrap();
store.put(&src_path, data.clone().into()).await.unwrap();
let src_url = Url::from_file_path(tmp.path().join("src.txt")).unwrap();
let dest_url = Url::from_file_path(tmp.path().join("dest.txt")).unwrap();
assert!(handler.copy_atomic(&src_url, &dest_url).is_ok());
let dest_path = Path::from_absolute_path(tmp.path().join("dest.txt")).unwrap();
assert_eq!(
store.get(&dest_path).await.unwrap().bytes().await.unwrap(),
data
);
assert!(matches!(
handler.copy_atomic(&src_url, &dest_url),
Err(Error::FileAlreadyExists(_))
));
let missing_url = Url::from_file_path(tmp.path().join("missing.txt")).unwrap();
let new_dest_url = Url::from_file_path(tmp.path().join("new_dest.txt")).unwrap();
assert!(handler.copy_atomic(&missing_url, &new_dest_url).is_err());
}
#[tokio::test]
async fn test_head() {
let (tmp, store, handler) = setup_test();
let data = Bytes::from("test-content");
let file_path = Path::from_absolute_path(tmp.path().join("test.txt")).unwrap();
let write_time = current_time_duration().unwrap();
store.put(&file_path, data.clone().into()).await.unwrap();
let file_url = Url::from_file_path(tmp.path().join("test.txt")).unwrap();
let file_meta = handler.head(&file_url).unwrap();
assert_eq!(file_meta.location, file_url);
assert_eq!(file_meta.size, data.len() as u64);
let meta_time = Duration::from_millis(file_meta.last_modified as u64);
assert!(
meta_time.abs_diff(write_time) < Duration::from_millis(100),
"last_modified timestamp should be around {} ms, but was {} ms",
write_time.as_millis(),
meta_time.as_millis()
);
}
#[tokio::test]
async fn test_head_non_existent() {
let (tmp, _store, handler) = setup_test();
let missing_url = Url::from_file_path(tmp.path().join("missing.txt")).unwrap();
let result = handler.head(&missing_url);
assert!(matches!(result, Err(Error::FileNotFound(_))));
}
#[test]
fn test_put() {
let (tmp, _store, handler) = setup_test();
let data = Bytes::from("put-test-data");
let file_url = Url::from_file_path(tmp.path().join("put.txt")).unwrap();
handler.put(&file_url, data.clone(), false).unwrap();
let read_back: Vec<Bytes> = handler
.read_files(vec![(file_url, None)])
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(read_back.len(), 1);
assert_eq!(read_back[0], data);
}
#[test]
fn test_put_already_exists() {
let (tmp, _store, handler) = setup_test();
let data = Bytes::from("original");
let file_url = Url::from_file_path(tmp.path().join("put.txt")).unwrap();
handler.put(&file_url, data, false).unwrap();
let new_data = Bytes::from("updated");
assert!(matches!(
handler.put(&file_url, new_data.clone(), false),
Err(Error::FileAlreadyExists(_))
));
handler.put(&file_url, new_data.clone(), true).unwrap();
let read_back: Vec<Bytes> = handler
.read_files(vec![(file_url, None)])
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(read_back.len(), 1);
assert_eq!(read_back[0], new_data);
}
#[test]
fn test_delete() {
let (tmp, _store, handler) = setup_test();
let data = Bytes::from("delete-test-data");
let file_url = Url::from_file_path(tmp.path().join("delete.txt")).unwrap();
handler.put(&file_url, data, false).unwrap();
handler.delete(&file_url).unwrap();
assert!(matches!(
handler.head(&file_url),
Err(Error::FileNotFound(_))
));
}
#[test]
fn test_delete_nonexistent_is_ok() {
let (tmp, _store, handler) = setup_test();
let missing_url = Url::from_file_path(tmp.path().join("missing.txt")).unwrap();
assert!(matches!(
handler.head(&missing_url),
Err(Error::FileNotFound(_))
));
handler.delete(&missing_url).unwrap();
}
#[test]
fn precancelled_token_short_circuits_list_and_read() {
let (tempdir, _store, handler) = setup_test();
let url = Url::from_directory_path(tempdir.path()).unwrap();
let token: CancellationTokenRef = Arc::new(test_utils::TestCancellationToken::cancelled());
let listed = handler.list_from_with_cancellation(&url, Some(token.clone()));
assert!(matches!(listed, Err(Error::Cancelled)));
let read = handler.read_files_with_cancellation(vec![(url, None)], Some(token));
assert!(matches!(read, Err(Error::Cancelled)));
}
}