Skip to content

Commit a332ace

Browse files
authored
feat(io): let FileIO wrap a caller-provided filesystem operator (#618)
1 parent 25a04aa commit a332ace

2 files changed

Lines changed: 80 additions & 4 deletions

File tree

crates/paimon/src/io/file_io.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ pub struct FileIOBuilder {
372372
scheme_str: Option<String>,
373373
props: HashMap<String, String>,
374374
cache: Option<Arc<LocalCache>>,
375+
operator: Option<Operator>,
375376
}
376377

377378
impl FileIOBuilder {
@@ -380,11 +381,28 @@ impl FileIOBuilder {
380381
scheme_str: Some(scheme_str.to_string()),
381382
props: HashMap::default(),
382383
cache: None,
384+
operator: None,
383385
}
384386
}
385387

386-
pub(crate) fn into_parts(self) -> (String, HashMap<String, String>) {
387-
(self.scheme_str.unwrap_or_default(), self.props)
388+
pub(crate) fn into_parts(self) -> (String, HashMap<String, String>, Option<Operator>) {
389+
(
390+
self.scheme_str.unwrap_or_default(),
391+
self.props,
392+
self.operator,
393+
)
394+
}
395+
396+
/// Uses a caller-provided opendal operator as a **filesystem** backend instead of building
397+
/// one from the scheme: embedders bring a customized local-filesystem service without
398+
/// registering a scheme. Paths are resolved with the local-filesystem rules — absolute
399+
/// paths, `file:` URLs, and Windows drive paths — and handed to the operator in relative
400+
/// form, so the operator's root decides what they resolve against. Scheme'd paths
401+
/// (`s3://…`, `oss://…`) are rejected rather than misresolved: an object-store operator
402+
/// needs bucket/scheme resolution this hook deliberately does not provide.
403+
pub fn with_fs_operator(mut self, operator: Operator) -> Self {
404+
self.operator = Some(operator);
405+
self
388406
}
389407

390408
pub fn with_prop(mut self, key: impl ToString, value: impl ToString) -> Self {

crates/paimon/src/io/storage.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ use super::FileIOBuilder;
6868
/// The storage carries all supported storage services in paimon
6969
#[derive(Debug)]
7070
pub enum Storage {
71+
/// A caller-provided opendal operator, explicitly scoped to filesystem semantics
72+
/// (see `FileIOBuilder::with_fs_operator`).
73+
CustomFs { op: Operator },
7174
#[cfg(feature = "storage-memory")]
7275
Memory { op: Operator },
7376
#[cfg(feature = "storage-fs")]
@@ -111,7 +114,10 @@ pub enum Storage {
111114

112115
impl Storage {
113116
pub(crate) fn build(file_io_builder: FileIOBuilder) -> crate::Result<Self> {
114-
let (scheme_str, props) = file_io_builder.into_parts();
117+
let (scheme_str, props, operator) = file_io_builder.into_parts();
118+
if let Some(op) = operator {
119+
return Ok(Self::CustomFs { op });
120+
}
115121
let scheme = scheme_str.to_ascii_lowercase();
116122
match scheme.as_str() {
117123
#[cfg(feature = "storage-memory")]
@@ -186,6 +192,22 @@ impl Storage {
186192

187193
pub(crate) fn create<'a>(&self, path: &'a str) -> crate::Result<(Operator, Cow<'a, str>)> {
188194
match self {
195+
Storage::CustomFs { op } => {
196+
// The custom operator is a *filesystem* operator: paths resolve with the
197+
// local-filesystem rules below. A scheme'd path would be silently mangled by
198+
// those rules (`s3://bucket/a` → `3://bucket/a`), so reject it instead —
199+
// object-store operators need the bucket/scheme resolution the built-in
200+
// backends perform, which this hook deliberately does not reimplement.
201+
if !path.starts_with("file:/") && path.contains("://") {
202+
return Err(error::Error::ConfigInvalid {
203+
message: format!(
204+
"the custom operator is a filesystem operator and cannot resolve \
205+
the scheme'd path: {path}"
206+
),
207+
});
208+
}
209+
Ok((op.clone(), Self::fs_relative_path(path)?))
210+
}
189211
#[cfg(feature = "storage-memory")]
190212
Storage::Memory { op } => {
191213
Ok((op.clone(), Cow::Borrowed(Self::memory_relative_path(path)?)))
@@ -289,7 +311,6 @@ impl Storage {
289311
/// does `PathBuf::from("/").join("C:/dir")`, and because the argument
290312
/// carries a drive prefix `Path::join` replaces the base, yielding the real
291313
/// `C:\dir` on Windows.
292-
#[cfg(feature = "storage-fs")]
293314
fn fs_relative_path(path: &str) -> crate::Result<Cow<'_, str>> {
294315
// A `file://` / `file:/` URL is already in scheme-relative form.
295316
if let Some(stripped) = path.strip_prefix("file:/") {
@@ -557,3 +578,40 @@ mod fs_relative_path_tests {
557578
assert_eq!(rel(r"C:\Users\wh/db.db/t"), "C:/Users/wh/db.db/t");
558579
}
559580
}
581+
582+
#[cfg(all(test, feature = "storage-memory"))]
583+
mod tests {
584+
use super::*;
585+
586+
fn memory_operator() -> Operator {
587+
Operator::from_config(opendal::services::MemoryConfig::default()).unwrap()
588+
}
589+
590+
/// The filesystem operator resolves absolute paths and `file:` URLs with the
591+
/// local-filesystem rules, against the operator's own root.
592+
#[test]
593+
fn custom_fs_operator_resolves_filesystem_paths() {
594+
let storage = Storage::CustomFs {
595+
op: memory_operator(),
596+
};
597+
let (_, relative) = storage.create("/warehouse/db/table").unwrap();
598+
assert_eq!(relative, "warehouse/db/table");
599+
let (_, relative) = storage.create("file:/warehouse/db/table").unwrap();
600+
assert_eq!(relative, "warehouse/db/table");
601+
}
602+
603+
/// A scheme'd path would be silently mangled by the filesystem rules
604+
/// (`s3://bucket/a` → `3://bucket/a`), so it is rejected instead.
605+
#[test]
606+
fn custom_fs_operator_rejects_schemed_paths() {
607+
let storage = Storage::CustomFs {
608+
op: memory_operator(),
609+
};
610+
for path in ["s3://bucket/a", "oss://bucket/a", "hdfs://nn/a"] {
611+
assert!(
612+
storage.create(path).is_err(),
613+
"expected {path} to be rejected by the filesystem operator"
614+
);
615+
}
616+
}
617+
}

0 commit comments

Comments
 (0)