This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 66f655b3 feat(io): support URI-aware FileIO providers (#846)
66f655b3 is described below
commit 66f655b30a3861a31b5a0abafd25a8633574049c
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 15 22:49:48 2026 +0800
feat(io): support URI-aware FileIO providers (#846)
---
crates/paimon/src/io/file_io.rs | 299 ++++++++++-----
crates/paimon/src/io/file_io/provider_tests.rs | 489 +++++++++++++++++++++++++
docs/src/getting-started.md | 71 ++++
3 files changed, 762 insertions(+), 97 deletions(-)
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 1257f632..720225eb 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -28,7 +28,7 @@ use bytes::Bytes;
use chrono::{DateTime, Utc};
use futures::stream::BoxStream;
use futures::{StreamExt, TryStreamExt};
-use opendal::raw::normalize_root;
+use opendal::raw::{normalize_path, normalize_root};
use opendal::Operator;
use snafu::ResultExt;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
@@ -37,6 +37,9 @@ use url::Url;
use super::cache::{CachedFileReader, LocalCache};
use super::Storage;
+#[cfg(all(test, feature = "storage-memory"))]
+mod provider_tests;
+
/// An externally managed block cache used by [`FileIO`].
///
/// Implementations store immutable file ranges. Cache failures must be handled
@@ -56,24 +59,44 @@ pub trait FileBlockCache: std::fmt::Debug + Send + Sync +
'static {
async fn invalidate_prefix(&self, prefix: &str);
}
+/// Resolves original paths to application-managed OpenDAL operators.
+///
+/// Providers own scheme/bucket routing, operator reuse, and credential
refresh.
+/// Errors propagate without falling back to built-in storage or credentials.
+/// Already-open readers and writers retain their operator, so its backend must
+/// refresh credentials internally if those handles need to outlive
credentials.
+///
+/// Custom services must report distinct `(scheme, name, root)` storage
identities
+/// for different namespaces, since these identities are used by the file
cache.
#[async_trait::async_trait]
-pub(crate) trait FileIOProvider: std::fmt::Debug + Send + Sync {
+pub trait FileIOProvider: std::fmt::Debug + Send + Sync + 'static {
+ /// Return an operator and its relative path, preserving literal object
keys.
+ ///
+ /// Empty paths and `/` denote the operator root. For directory listings,
the
+ /// relative path must be an unchanged suffix of the original path starting
+ /// at a component boundary; this permits reconstruction of reusable full
URIs.
+ /// Object paths that OpenDAL would trim or collapse are rejected by
FileIO.
+ /// Rename requires both paths to resolve to the same shared service
instance.
async fn create(&self, path: &str) -> crate::Result<(Operator, String)>;
}
+#[derive(Clone, Debug)]
+enum FileIOBackend {
+ Storage(Arc<Storage>),
+ Provider(Arc<dyn FileIOProvider>),
+}
+
#[derive(Clone)]
pub struct FileIO {
- storage: Arc<Storage>,
+ backend: FileIOBackend,
cache: Option<Arc<LocalCache>>,
- provider: Option<Arc<dyn FileIOProvider>>,
}
impl std::fmt::Debug for FileIO {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileIO")
- .field("storage", &self.storage)
+ .field("backend", &self.backend)
.field("cache", &self.cache)
- .field("provider", &self.provider)
.finish()
}
}
@@ -97,20 +120,44 @@ impl FileIO {
Ok(self)
}
- pub(crate) fn with_provider(mut self, provider: Arc<dyn FileIOProvider>)
-> Self {
- self.provider = Some(provider);
+ /// Replace the storage backend with a provider, retaining the file cache.
+ ///
+ /// Resolution is deferred to async operations, including for file handles
+ /// subsequently created by [`Self::new_input`] and [`Self::new_output`].
+ pub fn with_provider(mut self, provider: Arc<dyn FileIOProvider>) -> Self {
+ self.backend = FileIOBackend::Provider(provider);
self
}
pub(crate) fn create_static(&self, path: &str) -> crate::Result<(Operator,
String)> {
- let (op, relative_path) = self.storage.create(path)?;
+ let FileIOBackend::Storage(storage) = &self.backend else {
+ return Err(Error::IoUnsupported {
+ message: "A FileIOProvider requires async path
resolution".to_string(),
+ });
+ };
+ let (op, relative_path) = storage.create(path)?;
Ok((op, relative_path.into_owned()))
}
async fn create(&self, path: &str) -> crate::Result<(Operator, String)> {
- match &self.provider {
- Some(provider) => provider.create(path).await,
- None => self.create_static(path),
+ match &self.backend {
+ FileIOBackend::Provider(provider) =>
resolve_provider(provider.as_ref(), path).await,
+ FileIOBackend::Storage(_) => self.create_static(path),
+ }
+ }
+
+ fn file_source(&self, path: &str) -> crate::Result<FileSource> {
+ match &self.backend {
+ FileIOBackend::Provider(provider) =>
Ok(FileSource::Provider(provider.clone())),
+ FileIOBackend::Storage(_) => {
+ let (op, relative_path) = self.create_static(path)?;
+ let cache_path = cache_object_path(&op, &relative_path);
+ Ok(FileSource::Static {
+ op,
+ relative_path,
+ cache_path,
+ })
+ }
}
}
@@ -157,42 +204,34 @@ impl FileIO {
}
/// Create a new input file to read data.
+ /// With a provider, path resolution and its errors are deferred to async
IO.
///
/// Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L76>
pub fn new_input(&self, path: &str) -> crate::Result<InputFile> {
- let (op, relative_path) = self.storage.create(path)?;
- let cache_path = cache_object_path(&op, relative_path.as_ref());
Ok(InputFile {
- op,
+ source: self.file_source(path)?,
path: path.to_string(),
- relative_path: relative_path.into_owned(),
- cache_path,
cache: self
.cache
.as_ref()
.filter(|cache| cache.is_cacheable(path))
.cloned(),
- provider: self.provider.clone(),
})
}
/// Create a new output file to write data.
+ /// With a provider, path resolution and its errors are deferred to async
IO.
///
/// Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L87>
pub fn new_output(&self, path: &str) -> Result<OutputFile> {
- let (op, relative_path) = self.storage.create(path)?;
- let cache_path = cache_object_path(&op, relative_path.as_ref());
Ok(OutputFile {
- op,
+ source: self.file_source(path)?,
path: path.to_string(),
- relative_path: relative_path.into_owned(),
- cache_path,
cache: self
.cache
.as_ref()
.filter(|cache| cache.is_cacheable(path))
.cloned(),
- provider: self.provider.clone(),
})
}
@@ -225,12 +264,7 @@ impl FileIO {
/// FIXME: how to handle large dir? Better to return a stream instead?
pub async fn list_status(&self, path: &str) -> Result<Vec<FileStatus>> {
let (op, relative_path) = self.create(path).await?;
- // `relative_path` is a byte-suffix of `path` for object stores and
POSIX
- // local paths, so this recovers the scheme/root prefix. For a Windows
- // local path the relative form only swaps `\`->`/`
(length-preserving),
- // so this is `""` and entries are reported in opendal's normalized
- // `/C:/...` form — which still round-trips back through `create`.
- let base_path = &path[..path.len() - relative_path.len()];
+ let base_path = listing_base_path(path, &relative_path)?;
// Opendal list() expects directory path to end with `/`.
// use normalize_root to make sure it end with `/`.
let list_path = normalize_root(relative_path.as_ref());
@@ -243,6 +277,9 @@ impl FileIO {
let list_path_normalized = list_path.trim_start_matches('/');
for entry in entries {
let entry_path = entry.path();
+ if matches!(self.backend, FileIOBackend::Provider(_)) {
+ validate_provider_path(path, entry_path)?;
+ }
if entry_path.trim_start_matches('/') == list_path_normalized {
continue;
}
@@ -250,7 +287,7 @@ impl FileIO {
statuses.push(FileStatus {
size: meta.content_length(),
is_dir: meta.is_dir(),
- path: status_path(base_path, entry_path),
+ path: status_path(&base_path, entry_path),
last_modified: meta
.last_modified()
.map(|v| DateTime::<Utc>::from(SystemTime::from(v))),
@@ -286,9 +323,8 @@ impl FileIO {
}
let (op, relative_path) = self.create(path).await?;
- // See `list_status`: `relative_path` is a byte-suffix of `path` except
- // for Windows local paths, where it only swaps separators (same
length).
- let base_path = path[..path.len() - relative_path.len()].to_string();
+ let base_path = listing_base_path(path, &relative_path)?;
+ let has_provider = matches!(self.backend, FileIOBackend::Provider(_));
let list_path = normalize_root(relative_path.as_ref());
let entries =
@@ -308,6 +344,9 @@ impl FileIO {
message: format!("Failed to list files recursively in
'{path}'"),
})? {
let entry_path = entry.path();
+ if has_provider {
+ validate_provider_path(&path, entry_path)?;
+ }
if entry_path.trim_start_matches('/') == list_path_normalized {
continue;
}
@@ -426,6 +465,14 @@ impl FileIO {
pub async fn rename(&self, src: &str, dst: &str) -> Result<()> {
let (op_src, relative_path_src) = self.create(src).await?;
let (op_dst, relative_path_dst) = self.create(dst).await?;
+ if matches!(self.backend, FileIOBackend::Provider(_))
+ && !Arc::ptr_eq(op_src.service(), op_dst.service())
+ {
+ return Err(Error::IoUnsupported {
+ message: "Rename through a FileIOProvider requires the same
shared storage service"
+ .to_string(),
+ });
+ }
let cache_path_src = cache_object_path(&op_src,
relative_path_src.as_ref());
let cache_path_dst = cache_object_path(&op_dst,
relative_path_dst.as_ref());
@@ -444,6 +491,57 @@ impl FileIO {
}
}
+async fn resolve_provider(
+ provider: &dyn FileIOProvider,
+ path: &str,
+) -> crate::Result<(Operator, String)> {
+ let (op, relative_path) = provider.create(path).await?;
+ validate_provider_path(path, &relative_path)?;
+ Ok((op, relative_path))
+}
+
+fn validate_provider_path(path: &str, relative_path: &str) -> Result<()> {
+ // Filesystem paths retain their existing separator normalization. Object
+ // keys must not silently resolve to another object through OpenDAL's path
+ // normalization (e.g. `a//b` -> `a/b` or `key ` -> `key`).
+ if path.contains("://")
+ && !path.starts_with("file:/")
+ && !relative_path.is_empty()
+ && normalize_path(relative_path) != relative_path
+ {
+ return Err(Error::ConfigInvalid {
+ message: "FileIOProvider returned an object path that OpenDAL
would normalize"
+ .to_string(),
+ });
+ }
+ Ok(())
+}
+
+fn listing_base_path(path: &str, relative_path: &str) -> Result<String> {
+ if relative_path.is_empty() || relative_path == "/" {
+ return Ok(path.to_string());
+ }
+ if let Some(base) = path.strip_suffix(relative_path) {
+ if base.is_empty() || base.ends_with('/') {
+ return Ok(base.to_string());
+ }
+ }
+ // Windows filesystem paths only change separators. Try the original path
+ // first, since backslashes are literal filename characters on POSIX.
+ if looks_like_windows_drive_path(path) || (cfg!(windows) &&
path.starts_with("file:/")) {
+ let normalized = path.replace('\\', "/");
+ if let Some(base) = normalized.strip_suffix(relative_path) {
+ if base.is_empty() || base.ends_with('/') {
+ return Ok(base.to_string());
+ }
+ }
+ }
+ Err(Error::ConfigInvalid {
+ message: "Cannot list a path whose resolved relative path is not a
component suffix"
+ .to_string(),
+ })
+}
+
fn status_path(base_path: &str, entry_path: &str) -> String {
if base_path.ends_with('/') || entry_path.starts_with('/') {
format!("{base_path}{entry_path}")
@@ -478,6 +576,7 @@ pub struct FileIOBuilder {
props: HashMap<String, String>,
cache: Option<Arc<LocalCache>>,
operator: Option<Operator>,
+ provider: Option<Arc<dyn FileIOProvider>>,
}
impl FileIOBuilder {
@@ -487,6 +586,7 @@ impl FileIOBuilder {
props: HashMap::default(),
cache: None,
operator: None,
+ provider: None,
}
}
@@ -510,6 +610,16 @@ impl FileIOBuilder {
self
}
+ /// Use an application-managed provider instead of built-in storage.
+ ///
+ /// The provider receives original paths, regardless of the builder's
scheme.
+ /// Storage properties are not parsed and no built-in storage feature is
+ /// required. Combining this with [`Self::with_fs_operator`] is an error.
+ pub fn with_provider(mut self, provider: Arc<dyn FileIOProvider>) -> Self {
+ self.provider = Some(provider);
+ self
+ }
+
pub fn with_prop(mut self, key: impl ToString, value: impl ToString) ->
Self {
self.props.insert(key.to_string(), value.to_string());
self
@@ -529,14 +639,19 @@ impl FileIOBuilder {
self
}
- pub fn build(self) -> crate::Result<FileIO> {
+ pub fn build(mut self) -> crate::Result<FileIO> {
let cache = self.cache.clone();
- let storage = Storage::build(self)?;
- Ok(FileIO {
- storage: Arc::new(storage),
- cache,
- provider: None,
- })
+ let backend = if let Some(provider) = self.provider.take() {
+ if self.operator.is_some() {
+ return Err(Error::ConfigInvalid {
+ message: "with_provider and with_fs_operator cannot be
combined".to_string(),
+ });
+ }
+ FileIOBackend::Provider(provider)
+ } else {
+ FileIOBackend::Storage(Arc::new(Storage::build(self)?))
+ };
+ Ok(FileIO { backend, cache })
}
}
@@ -670,45 +785,52 @@ pub struct FileStatus {
pub last_modified: Option<DateTime<Utc>>,
}
-#[derive(Debug)]
-pub struct InputFile {
- op: Operator,
- path: String,
- /// The opendal-relative path (see [`FileIO::new_input`]); not necessarily
a
- /// suffix of `path`, since local paths are separator-normalized.
- relative_path: String,
- cache_path: String,
- cache: Option<Arc<LocalCache>>,
- provider: Option<Arc<dyn FileIOProvider>>,
+#[derive(Clone, Debug)]
+enum FileSource {
+ Static {
+ op: Operator,
+ relative_path: String,
+ cache_path: String,
+ },
+ Provider(Arc<dyn FileIOProvider>),
}
-impl InputFile {
- async fn resolve(&self) -> crate::Result<(Operator, String, String)> {
- match &self.provider {
- Some(provider) => {
- let (op, relative_path) = provider.create(&self.path).await?;
+impl FileSource {
+ async fn resolve(&self, path: &str) -> crate::Result<(Operator, String,
String)> {
+ match self {
+ Self::Provider(provider) => {
+ let (op, relative_path) = resolve_provider(provider.as_ref(),
path).await?;
let cache_path = cache_object_path(&op, &relative_path);
Ok((op, relative_path, cache_path))
}
- None => Ok((
- self.op.clone(),
- self.relative_path.clone(),
- self.cache_path.clone(),
- )),
+ Self::Static {
+ op,
+ relative_path,
+ cache_path,
+ } => Ok((op.clone(), relative_path.clone(), cache_path.clone())),
}
}
+}
+
+#[derive(Debug)]
+pub struct InputFile {
+ source: FileSource,
+ path: String,
+ cache: Option<Arc<LocalCache>>,
+}
+impl InputFile {
pub fn location(&self) -> &str {
&self.path
}
pub async fn exists(&self) -> crate::Result<bool> {
- let (op, relative_path, _) = self.resolve().await?;
+ let (op, relative_path, _) = self.source.resolve(&self.path).await?;
Ok(op.exists(&relative_path).await?)
}
pub async fn metadata(&self) -> crate::Result<FileStatus> {
- let (op, relative_path, _) = self.resolve().await?;
+ let (op, relative_path, _) = self.source.resolve(&self.path).await?;
let meta = op.stat(&relative_path).await?;
Ok(FileStatus {
@@ -722,7 +844,7 @@ impl InputFile {
}
pub async fn read(&self) -> crate::Result<Bytes> {
- let (op, relative_path, cache_path) = self.resolve().await?;
+ let (op, relative_path, cache_path) =
self.source.resolve(&self.path).await?;
let Some(cache) = &self.cache else {
return Ok(op.read(&relative_path).await?.to_bytes());
};
@@ -741,7 +863,7 @@ impl InputFile {
}
pub async fn reader(&self) -> crate::Result<impl FileRead> {
- let (op, relative_path, cache_path) = self.resolve().await?;
+ let (op, relative_path, cache_path) =
self.source.resolve(&self.path).await?;
let reader = op.reader(&relative_path).await?;
let Some(cache) = &self.cache else {
return Ok(InputFileReader::Direct(reader));
@@ -766,50 +888,27 @@ impl InputFile {
#[derive(Debug, Clone)]
pub struct OutputFile {
- op: Operator,
+ source: FileSource,
path: String,
- /// The opendal-relative path (see [`FileIO::new_output`]); not
necessarily a
- /// suffix of `path`, since local paths are separator-normalized.
- relative_path: String,
- cache_path: String,
cache: Option<Arc<LocalCache>>,
- provider: Option<Arc<dyn FileIOProvider>>,
}
impl OutputFile {
- async fn resolve(&self) -> crate::Result<(Operator, String, String)> {
- match &self.provider {
- Some(provider) => {
- let (op, relative_path) = provider.create(&self.path).await?;
- let cache_path = cache_object_path(&op, &relative_path);
- Ok((op, relative_path, cache_path))
- }
- None => Ok((
- self.op.clone(),
- self.relative_path.clone(),
- self.cache_path.clone(),
- )),
- }
- }
-
pub fn location(&self) -> &str {
&self.path
}
pub async fn exists(&self) -> crate::Result<bool> {
- let (op, relative_path, _) = self.resolve().await?;
+ let (op, relative_path, _) = self.source.resolve(&self.path).await?;
Ok(op.exists(&relative_path).await?)
}
pub fn to_input_file(self) -> InputFile {
let cache = self.cache.filter(|cache| cache.is_cacheable(&self.path));
InputFile {
- op: self.op,
+ source: self.source,
path: self.path,
- relative_path: self.relative_path,
- cache_path: self.cache_path,
cache,
- provider: self.provider,
}
}
@@ -820,7 +919,7 @@ impl OutputFile {
}
pub async fn writer(&self) -> crate::Result<Box<dyn FileWrite>> {
- let (op, relative_path, cache_path) = self.resolve().await?;
+ let (op, relative_path, cache_path) =
self.source.resolve(&self.path).await?;
let writer: Box<dyn FileWrite> = Box::new(
op.writer_with(&relative_path)
.chunk(8 * 1024 * 1024)
@@ -838,7 +937,7 @@ impl OutputFile {
/// Get an async streaming writer for format-level writes (e.g. parquet).
pub(crate) async fn async_writer(&self) -> crate::Result<Box<dyn
AsyncFileWrite>> {
- let (op, relative_path, cache_path) = self.resolve().await?;
+ let (op, relative_path, cache_path) =
self.source.resolve(&self.path).await?;
let writer: Box<dyn AsyncFileWrite> = Box::new(
op.writer_with(&relative_path)
.chunk(8 * 1024 * 1024)
@@ -1332,14 +1431,20 @@ mod object_storage_path_test {
fn assert_relative_paths(file_io: &FileIO, path: &str,
expected_relative_path: &str) {
let input = file_io.new_input(path).unwrap();
assert_eq!(input.location(), path);
- assert_eq!(input.relative_path, expected_relative_path);
+ let FileSource::Static { relative_path, .. } = input.source else {
+ panic!("expected static input")
+ };
+ assert_eq!(relative_path, expected_relative_path);
let output = file_io.new_output(path).unwrap();
assert_eq!(output.location(), path);
- assert_eq!(output.relative_path, expected_relative_path);
+ let FileSource::Static { relative_path, .. } = output.source else {
+ panic!("expected static output")
+ };
+ assert_eq!(relative_path, expected_relative_path);
- let (_op, relative_path) = file_io.storage.create(path).unwrap();
- assert_eq!(relative_path.as_ref(), expected_relative_path);
+ let (_op, relative_path) = file_io.create_static(path).unwrap();
+ assert_eq!(relative_path, expected_relative_path);
let base_path = &path[..path.len() - relative_path.len()];
assert_eq!(format!("{base_path}{relative_path}"), path);
diff --git a/crates/paimon/src/io/file_io/provider_tests.rs
b/crates/paimon/src/io/file_io/provider_tests.rs
new file mode 100644
index 00000000..270dfcf9
--- /dev/null
+++ b/crates/paimon/src/io/file_io/provider_tests.rs
@@ -0,0 +1,489 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::*;
+use std::collections::BTreeSet;
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+
+#[derive(Debug)]
+struct PrefixProvider {
+ routes: Vec<(String, Operator)>,
+ calls: AtomicUsize,
+ reject: AtomicBool,
+}
+
+#[async_trait::async_trait]
+impl FileIOProvider for PrefixProvider {
+ async fn create(&self, path: &str) -> Result<(Operator, String)> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ if self.reject.load(Ordering::SeqCst) {
+ return RejectingProvider.create(path).await;
+ }
+ for (prefix, op) in &self.routes {
+ if path == prefix.trim_end_matches('/') {
+ return Ok((op.clone(), String::new()));
+ }
+ if let Some(relative) = path.strip_prefix(prefix) {
+ return Ok((op.clone(), relative.to_string()));
+ }
+ }
+ RejectingProvider.create(path).await
+ }
+}
+
+fn memory_operator() -> Operator {
+ Operator::from_config(opendal::services::MemoryConfig::default()).unwrap()
+}
+
+fn provider_io(routes: Vec<(String, Operator)>) -> (FileIO,
Arc<PrefixProvider>) {
+ let provider = Arc::new(PrefixProvider {
+ routes,
+ calls: AtomicUsize::new(0),
+ reject: AtomicBool::new(false),
+ });
+ let io = FileIOBuilder::new("application-storage")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ (io, provider)
+}
+
+#[derive(Debug)]
+struct RejectingProvider;
+
+#[async_trait::async_trait]
+impl FileIOProvider for RejectingProvider {
+ async fn create(&self, _path: &str) -> Result<(Operator, String)> {
+ Err(Error::ConfigInvalid {
+ message: "provider denied access".to_string(),
+ })
+ }
+}
+
+#[tokio::test]
+async fn provider_handles_defer_resolution_without_using_static_storage() {
+ let operator =
Operator::from_config(opendal::services::MemoryConfig::default()).unwrap();
+ let io = FileIOBuilder::new("file")
+ .with_fs_operator(operator)
+ .build()
+ .unwrap()
+ .with_provider(Arc::new(RejectingProvider));
+
+ let input = io.new_input("s3://bucket/key").unwrap();
+ let output = io.new_output("s3://bucket/key").unwrap();
+ assert_denied(input.read().await);
+ assert_denied(output.write(Bytes::from_static(b"data")).await);
+}
+
+fn assert_denied<T>(result: Result<T>) {
+ assert!(matches!(
+ result,
+ Err(Error::ConfigInvalid { message }) if message == "provider denied
access"
+ ));
+}
+
+#[tokio::test]
+async fn provider_routes_buckets_and_preserves_literal_keys() {
+ let first = memory_operator();
+ let second = memory_operator();
+ let (io, provider) = provider_io(vec![
+ ("s3://first/".to_string(), first.clone()),
+ ("oss://second/".to_string(), second.clone()),
+ ]);
+ let key = "table/中文 a%2Fb?part=1#fragment.parquet";
+ let first_path = format!("s3://first/{key}");
+ let second_path = format!("oss://second/{key}");
+ let input = io.new_input(&first_path).unwrap();
+ let output = io.new_output(&first_path).unwrap();
+ assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
+
+ output
+ .write(Bytes::from_static(b"first bucket"))
+ .await
+ .unwrap();
+ io.clone()
+ .new_output(&second_path)
+ .unwrap()
+ .write(Bytes::from_static(b"second bucket"))
+ .await
+ .unwrap();
+ // Check the backing services directly: matching read/write mangling cannot
+ // make this test pass by accidentally accessing the same wrong object key.
+ assert_eq!(first.read(key).await.unwrap().to_bytes(), "first bucket");
+ assert_eq!(second.read(key).await.unwrap().to_bytes(), "second bucket");
+ assert_eq!(input.read().await.unwrap(), "first bucket");
+ assert_eq!(
+ input.reader().await.unwrap().read(1..5).await.unwrap(),
+ "irst"
+ );
+ assert!(input.exists().await.unwrap());
+ assert!(output.exists().await.unwrap());
+ let meta = input.metadata().await.unwrap();
+ assert_eq!(meta.path, first_path);
+ assert_eq!(meta.size, 12);
+ assert_eq!(io.get_status(&second_path).await.unwrap().size, 13);
+ assert_eq!(output.to_input_file().read().await.unwrap(), "first bucket");
+
+ let copied = "oss://second/table/copied";
+ io.copy_file(&first_path, copied).await.unwrap();
+ assert_eq!(
+ second.read("table/copied").await.unwrap().to_bytes(),
+ "first bucket"
+ );
+ io.delete_file(&first_path).await.unwrap();
+ assert!(!first.exists(key).await.unwrap());
+ assert!(io.exists(&second_path).await.unwrap());
+ io.delete_dir("oss://second/table/").await.unwrap();
+ assert!(!second.exists(key).await.unwrap());
+ assert!(!second.exists("table/copied").await.unwrap());
+}
+
+#[tokio::test]
+async fn provider_listings_round_trip_roots_prefixes_and_literal_keys() {
+ // An operator root below the physical bucket needs a longer logical URI
+ // prefix. The provider strips only this prefix; listing must restore it.
+ let mut config = opendal::services::MemoryConfig::default();
+ config.root = Some("/tenant/".to_string());
+ let op = Operator::from_config(config).unwrap();
+ let (io, _) = provider_io(vec![("s3://bucket/tenant/".to_string(),
op.clone())]);
+ for key in ["table/a%2Fb", "table/中文 ?#", "table/sub/c"] {
+ op.write(key, key.to_string()).await.unwrap();
+ }
+ for dir in ["s3://bucket/tenant/table", "s3://bucket/tenant/table/"] {
+ let statuses = io.list_status(dir).await.unwrap();
+ assert_eq!(
+ statuses
+ .iter()
+ .map(|s| s.path.as_str())
+ .collect::<BTreeSet<_>>(),
+ BTreeSet::from([
+ "s3://bucket/tenant/table/a%2Fb",
+ "s3://bucket/tenant/table/中文 ?#",
+ "s3://bucket/tenant/table/sub/",
+ ])
+ );
+ for status in statuses {
+ if status.is_dir {
+ assert_eq!(io.list_status(&status.path).await.unwrap().len(),
1);
+ } else {
+ assert_eq!(
+ io.new_input(&status.path).unwrap().read().await.unwrap(),
+ status.path.strip_prefix("s3://bucket/tenant/").unwrap()
+ );
+ }
+ }
+ }
+ for root in ["s3://bucket/tenant", "s3://bucket/tenant/"] {
+ assert_eq!(
+ io.list_status(root).await.unwrap()[0].path,
+ "s3://bucket/tenant/table/"
+ );
+ let files = io.list_status_recursive(root).await.unwrap();
+ assert_eq!(files.len(), 3);
+ for file in files {
+ assert!(!file.is_dir);
+ assert_eq!(
+ io.new_input(&file.path).unwrap().read().await.unwrap(),
+ file.path.strip_prefix("s3://bucket/tenant/").unwrap()
+ );
+ }
+ }
+ let (bucket_io, _) = provider_io(vec![("s3://bucket/".to_string(), op)]);
+ for root in ["s3://bucket", "s3://bucket/"] {
+ assert_eq!(
+ bucket_io.list_status(root).await.unwrap()[0].path,
+ "s3://bucket/table/"
+ );
+ }
+}
+
+#[tokio::test]
+async fn provider_errors_propagate_across_all_io_entrypoints() {
+ let io = FileIOBuilder::new("oss")
+ .with_prop("fs.oss.retry.count", "not a number")
+ .with_provider(Arc::new(RejectingProvider))
+ .build()
+ .unwrap();
+ let path = "oss://bucket/key";
+ let input = io.new_input(path).unwrap();
+ let output = io.new_output(path).unwrap();
+ assert_denied(io.exists(path).await);
+ assert_denied(io.exists_dir(path).await);
+ assert_denied(io.get_status(path).await);
+ assert_denied(io.list_status(path).await);
+ assert_denied(io.list_status_recursive(path).await);
+ assert_denied(io.mkdirs(path).await);
+ assert_denied(io.delete_file(path).await);
+ assert_denied(io.delete_dir(path).await);
+ assert_denied(io.rename(path, "oss://bucket/target").await);
+ assert_denied(io.copy_file(path, "oss://bucket/target").await);
+ assert_denied(input.exists().await);
+ assert_denied(input.metadata().await);
+ assert_denied(input.read().await);
+ assert_denied(input.reader().await);
+ assert_denied(output.exists().await);
+ assert_denied(output.writer().await);
+ assert_denied(output.async_writer().await);
+ assert_denied(output.to_input_file().read().await);
+}
+
+#[derive(Debug)]
+struct FixedPathProvider {
+ op: Operator,
+ relative: String,
+}
+
+#[async_trait::async_trait]
+impl FileIOProvider for FixedPathProvider {
+ async fn create(&self, _path: &str) -> Result<(Operator, String)> {
+ Ok((self.op.clone(), self.relative.clone()))
+ }
+}
+
+#[tokio::test]
+async fn provider_rejects_unrepresentable_keys_and_listing_mappings() {
+ let op = memory_operator();
+ op.write("a/b", "untouched").await.unwrap();
+ let (io, _) = provider_io(vec![("s3://bucket/".to_string(), op.clone())]);
+ for path in [
+ "s3://bucket/a//b",
+ "s3://bucket//a/b",
+ "s3://bucket/a/b ",
+ "s3://bucket/ a/b",
+ ] {
+ assert!(matches!(
+ io.new_output(path)
+ .unwrap()
+ .write(Bytes::from_static(b"wrong"))
+ .await,
+ Err(Error::ConfigInvalid { .. })
+ ));
+ assert!(matches!(
+ io.exists(path).await,
+ Err(Error::ConfigInvalid { .. })
+ ));
+ }
+ assert_eq!(op.read("a/b").await.unwrap().to_bytes(), "untouched");
+
+ for (uri, relative) in [
+ ("s3://b/a", "much/longer/than/the/original/path"),
+ ("s3://b/中文", "wrong"),
+ ("s3://b/foobar", "bar"),
+ ] {
+ let io = FileIOBuilder::new("unused")
+ .with_provider(Arc::new(FixedPathProvider {
+ op: op.clone(),
+ relative: relative.to_string(),
+ }))
+ .build()
+ .unwrap();
+ assert!(matches!(
+ io.list_status(uri).await,
+ Err(Error::ConfigInvalid { .. })
+ ));
+ assert!(matches!(
+ io.list_status_recursive(uri).await,
+ Err(Error::ConfigInvalid { .. })
+ ));
+ }
+ let root_io = FileIOBuilder::new("unused")
+ .with_provider(Arc::new(FixedPathProvider {
+ op,
+ relative: "/".to_string(),
+ }))
+ .build()
+ .unwrap();
+ assert_eq!(
+ root_io.list_status("s3://b").await.unwrap()[0].path,
+ "s3://b/a/"
+ );
+}
+
+#[test]
+fn provider_and_fs_operator_are_mutually_exclusive() {
+ for builder in [
+ FileIOBuilder::new("file")
+ .with_provider(Arc::new(RejectingProvider))
+ .with_fs_operator(memory_operator()),
+ FileIOBuilder::new("file")
+ .with_fs_operator(memory_operator())
+ .with_provider(Arc::new(RejectingProvider)),
+ ] {
+ assert!(matches!(builder.build(), Err(Error::ConfigInvalid { .. })));
+ }
+}
+
+#[tokio::test]
+async fn opened_reader_and_writer_keep_the_shared_backend() {
+ let op = memory_operator();
+ op.write("key", "before").await.unwrap();
+ let (io, provider) = provider_io(vec![("s3://bucket/".to_string(),
op.clone())]);
+ let input = io.new_input("s3://bucket/key").unwrap();
+ let reader = input.reader().await.unwrap();
+ let mut writer = io
+ .new_output("s3://bucket/output")
+ .unwrap()
+ .writer()
+ .await
+ .unwrap();
+ assert_eq!(reader.read(0..6).await.unwrap(), "before");
+
+ // Open handles retain the injected backend and see its shared state. They
+ // cannot rely on another provider call to replace an expired credential.
+ provider.reject.store(true, Ordering::SeqCst);
+ let calls = provider.calls.load(Ordering::SeqCst);
+ op.write("key", "after!").await.unwrap();
+ assert_eq!(reader.read(0..6).await.unwrap(), "after!");
+ writer.write(Bytes::from_static(b"written")).await.unwrap();
+ writer.close().await.unwrap();
+ assert_eq!(op.read("output").await.unwrap().to_bytes(), "written");
+ assert_eq!(provider.calls.load(Ordering::SeqCst), calls);
+ assert_denied(input.read().await);
+}
+
+#[cfg(all(feature = "storage-fs", not(windows)))]
+#[tokio::test]
+async fn static_listing_preserves_literal_posix_backslashes() {
+ let root = tempfile::tempdir().unwrap();
+ let dir = root.path().join(r"literal\directory");
+ std::fs::create_dir(&dir).unwrap();
+ let file = dir.join("data");
+ std::fs::write(&file, "data").unwrap();
+ let io = FileIOBuilder::new("file").build().unwrap();
+ let statuses = io
+ .list_status(&format!("file:{}", dir.display()))
+ .await
+ .unwrap();
+ assert_eq!(statuses.len(), 1);
+ assert_eq!(statuses[0].path, format!("file:{}", file.display()));
+ assert_eq!(
+ io.new_input(&statuses[0].path)
+ .unwrap()
+ .read()
+ .await
+ .unwrap(),
+ "data"
+ );
+}
+
+fn with_memory_cache(mut io: FileIO) -> FileIO {
+ use crate::io::cache::LocalCacheConfig;
+ use crate::{CatalogOptions, Options};
+
+ let mut options = Options::new();
+ options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+ options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "4");
+ let config = LocalCacheConfig::from_options(&options).unwrap().unwrap();
+ io.cache = Some(Arc::new(LocalCache::new(config).unwrap()));
+ io
+}
+
+#[tokio::test]
+async fn provider_cache_isolates_buckets_and_invalidates_aliases() {
+ use tokio::io::AsyncWriteExt;
+
+ let first = memory_operator();
+ let second = memory_operator();
+ let (io, provider) = provider_io(vec![
+ ("s3://first/".to_string(), first.clone()),
+ ("s3a://first/".to_string(), first.clone()),
+ ("oss://second/".to_string(), second.clone()),
+ ]);
+ let io = with_memory_cache(io);
+ let key = "table/snapshot/snapshot-1";
+ let a = io.new_input(&format!("s3://first/{key}")).unwrap();
+ let b = io.new_input(&format!("oss://second/{key}")).unwrap();
+ first.write(key, "aaaa").await.unwrap();
+ second.write(key, "bbbb").await.unwrap();
+ assert_eq!(a.read().await.unwrap(), "aaaa");
+ assert_eq!(b.read().await.unwrap(), "bbbb");
+ first.delete(key).await.unwrap();
+ second.delete(key).await.unwrap();
+ assert_eq!(a.read().await.unwrap(), "aaaa");
+ assert_eq!(b.read().await.unwrap(), "bbbb");
+
+ // Even an existing cached handle must ask its provider before serving
data.
+ provider.reject.store(true, Ordering::SeqCst);
+ assert_denied(a.read().await);
+ assert_denied(b.reader().await);
+ provider.reject.store(false, Ordering::SeqCst);
+
+ let output = io.new_output(&format!("s3a://first/{key}")).unwrap();
+ output.write(Bytes::from_static(b"cccc")).await.unwrap();
+ assert_eq!(a.read().await.unwrap(), "cccc");
+ let mut writer = output.async_writer().await.unwrap();
+ writer.write_all(b"dddd").await.unwrap();
+ writer.shutdown().await.unwrap();
+ assert_eq!(a.read().await.unwrap(), "dddd");
+
+ io.delete_dir("s3a://first/table/").await.unwrap();
+ assert!(a.read().await.is_err());
+ io.delete_file(&format!("oss://second/{key}"))
+ .await
+ .unwrap();
+ assert!(b.read().await.is_err());
+}
+
+#[cfg(feature = "storage-fs")]
+#[tokio::test]
+async fn provider_rename_uses_one_service_and_rejects_other_backends() {
+ fn fs_operator(root: &std::path::Path) -> Operator {
+ let mut config = opendal_service_fs::FsConfig::default();
+ config.root = Some(root.to_string_lossy().to_string());
+ Operator::from_config(config).unwrap()
+ }
+ let first_root = tempfile::tempdir().unwrap();
+ let second_root = tempfile::tempdir().unwrap();
+ let first = fs_operator(first_root.path());
+ let second = fs_operator(second_root.path());
+ let (io, _) = provider_io(vec![
+ ("s3://first/".to_string(), first.clone()),
+ ("oss://second/".to_string(), second.clone()),
+ ]);
+ let io = with_memory_cache(io);
+ io.mkdirs("s3://first/table/snapshot/").await.unwrap();
+ assert!(io.exists_dir("s3://first/table/snapshot").await.unwrap());
+ let src = "s3://first/table/snapshot/snapshot-1";
+ let dst = "s3://first/table/snapshot/snapshot-2";
+ io.new_output(src)
+ .unwrap()
+ .write(Bytes::from_static(b"source"))
+ .await
+ .unwrap();
+ io.new_output(dst)
+ .unwrap()
+ .write(Bytes::from_static(b"target"))
+ .await
+ .unwrap();
+ assert_eq!(io.new_input(src).unwrap().read().await.unwrap(), "source");
+ assert_eq!(io.new_input(dst).unwrap().read().await.unwrap(), "target");
+ io.rename(src, dst).await.unwrap();
+ assert!(io.new_input(src).unwrap().read().await.is_err());
+ assert_eq!(io.new_input(dst).unwrap().read().await.unwrap(), "source");
+
+ // A mistaken rename on the source operator would overwrite its local
+ // `protected` file while leaving the intended destination untouched.
+ first.write("protected", "local").await.unwrap();
+ second.write("protected", "remote").await.unwrap();
+ assert!(matches!(
+ io.rename(dst, "oss://second/protected").await,
+ Err(Error::IoUnsupported { .. })
+ ));
+ assert_eq!(first.read("protected").await.unwrap().to_bytes(), "local");
+ assert_eq!(second.read("protected").await.unwrap().to_bytes(), "remote");
+ assert!(io.exists(dst).await.unwrap());
+}
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 5a872500..026fdb5f 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -51,6 +51,77 @@ Available storage features:
| `storage-hdfs` | HDFS |
| `storage-all` | All of the above |
+## Reusing a storage backend
+
+On the development branch, Rust embedders can implement
`paimon::io::FileIOProvider`
+to reuse an OpenDAL operator managed by their application. The provider
receives
+the original URI and returns a shared operator and its relative object path.
+It can route different schemes or buckets to different operators.
+
+```rust
+use std::sync::Arc;
+use opendal::Operator;
+use paimon::io::{FileIOBuilder, FileIOProvider};
+use paimon::{Error, Result};
+
+#[derive(Debug)]
+struct SharedStorage {
+ // An application-managed operator rooted at this bucket's root.
+ operator: Operator,
+}
+
+#[async_trait::async_trait]
+impl FileIOProvider for SharedStorage {
+ async fn create(&self, uri: &str) -> Result<(Operator, String)> {
+ let key = uri.strip_prefix("s3://my-bucket/").ok_or_else(|| {
+ Error::ConfigInvalid {
+ message: "URI is outside the configured bucket".to_string(),
+ }
+ })?;
+ Ok((self.operator.clone(), key.to_string()))
+ }
+}
+
+// `operator` is supplied by the embedding application.
+let file_io = FileIOBuilder::new("s3")
+ .with_provider(Arc::new(SharedStorage { operator }))
+ .build()?;
+let bytes = file_io.new_input("s3://my-bucket/table/schema/schema-0")?
+ .read().await?;
+```
+
+The application needs `async-trait` and a compatible `opendal-core` dependency
+(named `opendal` above). Injecting a provider does not require Paimon's
built-in
+feature for that storage service. Supply the resulting `FileIO` to `Table::new`
+or other APIs accepting a `FileIO`; option-based catalog constructors continue
+to construct their own storage backends.
+
+Provider configuration bypasses built-in storage construction. Provider errors
+are returned without trying properties, environment credentials, or another
+backend. `new_input` and `new_output` remain synchronous: they retain the URI
and
+provider, and resolution errors are returned by the subsequent async operation.
+
+For directory listings, the returned relative path must be an unchanged suffix
+of the URI, starting at a path-component boundary. An operator rooted at
+`/tenant/`, for example, can resolve `s3://my-bucket/tenant/table/` to
`table/`.
+Both listing APIs return full URIs that can be passed back to `FileIO`. Empty
+relative paths and `/` represent the operator root. Preserve literal percent
+escapes, Unicode, `?`, and `#` in object keys; do not decode or normalize them
as
+URL components. Object paths that OpenDAL would change by trimming whitespace
+or collapsing slashes are rejected instead of accessing a different object.
+
+Providers manage operator reuse and credential refresh. Resolution is repeated
+for each async file operation, but an already-open reader or writer keeps its
+operator: that backend must refresh credentials internally. Custom services
+must report distinct OpenDAL storage identities (`scheme`, `name`, `root`) for
+different storage namespaces so cached data cannot overlap. Rename through a
+provider requires both paths to resolve to the same shared service instance;
+cross-backend rename is rejected.
+
+`with_fs_operator` remains available for filesystem paths. It cannot be
combined
+with `with_provider` on the same builder. Without a provider, property-based
+storage configuration behaves as before.
+
## Mosaic File Format
Mosaic data file reads are always available. The current Mosaic support is
read-only: Paimon Rust can read existing `.mosaic` data files, including array
and map columns, in a Paimon table, but it does not write Mosaic data files yet.