1mod utils;
26
27use std::collections::HashMap;
28use std::collections::hash_map::Entry;
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use bytes::Bytes;
33use cfg_if::cfg_if;
34use futures::StreamExt;
35use futures::stream::BoxStream;
36use iceberg::io::{
37 FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, StorageConfig,
38 StorageFactory,
39};
40use iceberg::{Error, ErrorKind, Result};
41use opendal::Operator;
42use opendal::layers::{RetryLayer, TimeoutLayer};
43use serde::{Deserialize, Serialize};
44use utils::from_opendal_error;
45
46cfg_if! {
47 if #[cfg(feature = "opendal-azdls")] {
48 mod azdls;
49 use azdls::*;
50 use opendal::services::AzdlsConfig;
51 }
52}
53
54cfg_if! {
55 if #[cfg(feature = "opendal-hf")] {
56 mod hf;
57 use hf::*;
58 use opendal::services::HfConfig;
59 }
60}
61
62cfg_if! {
63 if #[cfg(feature = "opendal-fs")] {
64 mod fs;
65 use fs::*;
66 }
67}
68
69cfg_if! {
70 if #[cfg(feature = "opendal-gcs")] {
71 mod gcs;
72 use gcs::*;
73 use opendal::services::GcsConfig;
74 }
75}
76
77cfg_if! {
78 if #[cfg(feature = "opendal-memory")] {
79 mod memory;
80 use memory::*;
81 }
82}
83
84cfg_if! {
85 if #[cfg(feature = "opendal-oss")] {
86 mod oss;
87 use opendal::services::OssConfig;
88 use oss::*;
89 }
90}
91
92cfg_if! {
93 if #[cfg(feature = "opendal-s3")] {
94 mod s3;
95 use opendal::services::S3Config;
96 pub use s3::*;
97 }
98}
99
100mod resolving;
101pub use resolving::{OpenDalResolvingStorage, OpenDalResolvingStorageFactory};
102
103#[derive(Clone, Debug, Serialize, Deserialize)]
117pub enum OpenDalStorageFactory {
118 #[cfg(feature = "opendal-memory")]
120 Memory,
121 #[cfg(feature = "opendal-fs")]
123 Fs,
124 #[cfg(feature = "opendal-s3")]
126 S3 {
127 #[serde(
129 skip_deserializing,
130 skip_serializing_if = "Option::is_none",
131 serialize_with = "serialize_custom_credential_loader"
132 )]
133 customized_credential_load: Option<CustomAwsCredentialLoader>,
134 },
135 #[cfg(feature = "opendal-gcs")]
137 Gcs,
138 #[cfg(feature = "opendal-oss")]
140 Oss,
141 #[cfg(feature = "opendal-azdls")]
143 Azdls,
144 #[cfg(feature = "opendal-hf")]
146 Hf,
147}
148
149#[cfg(feature = "opendal-s3")]
150pub(crate) fn serialize_custom_credential_loader<S>(
151 _loader: &Option<CustomAwsCredentialLoader>,
152 _serializer: S,
153) -> std::result::Result<S::Ok, S::Error>
154where
155 S: serde::Serializer,
156{
157 Err(serde::ser::Error::custom(
158 "custom AWS credential loaders cannot be serialized",
159 ))
160}
161
162#[typetag::serde(name = "OpenDalStorageFactory")]
163impl StorageFactory for OpenDalStorageFactory {
164 #[allow(unused_variables)]
165 fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
166 match self {
167 #[cfg(feature = "opendal-memory")]
168 OpenDalStorageFactory::Memory => {
169 Ok(Arc::new(OpenDalStorage::Memory(memory_config_build()?)))
170 }
171 #[cfg(feature = "opendal-fs")]
172 OpenDalStorageFactory::Fs => Ok(Arc::new(OpenDalStorage::LocalFs)),
173 #[cfg(feature = "opendal-s3")]
174 OpenDalStorageFactory::S3 {
175 customized_credential_load,
176 } => Ok(Arc::new(OpenDalStorage::S3 {
177 config: s3_config_parse(config.props().clone())?.into(),
178 customized_credential_load: customized_credential_load.clone(),
179 })),
180 #[cfg(feature = "opendal-gcs")]
181 OpenDalStorageFactory::Gcs => Ok(Arc::new(OpenDalStorage::Gcs {
182 config: gcs_config_parse(config.props().clone())?.into(),
183 })),
184 #[cfg(feature = "opendal-oss")]
185 OpenDalStorageFactory::Oss => Ok(Arc::new(OpenDalStorage::Oss {
186 config: oss_config_parse(config.props().clone())?.into(),
187 })),
188 #[cfg(feature = "opendal-azdls")]
189 OpenDalStorageFactory::Azdls => Ok(Arc::new(OpenDalStorage::Azdls {
190 config: azdls_config_parse(config.props().clone())?.into(),
191 })),
192 #[cfg(feature = "opendal-hf")]
193 OpenDalStorageFactory::Hf => Ok(Arc::new(OpenDalStorage::Hf {
194 config: hf_config_parse(config.props().clone())?.into(),
195 })),
196 #[cfg(all(
197 not(feature = "opendal-memory"),
198 not(feature = "opendal-fs"),
199 not(feature = "opendal-s3"),
200 not(feature = "opendal-gcs"),
201 not(feature = "opendal-oss"),
202 not(feature = "opendal-azdls"),
203 not(feature = "opendal-hf"),
204 ))]
205 _ => Err(Error::new(
206 ErrorKind::FeatureUnsupported,
207 "No storage service has been enabled",
208 )),
209 }
210 }
211}
212
213#[cfg(feature = "opendal-memory")]
215fn default_memory_operator() -> Operator {
216 memory_config_build().expect("Failed to create default memory operator")
217}
218
219#[derive(Clone, Debug, Serialize, Deserialize)]
221pub enum OpenDalStorage {
222 #[cfg(feature = "opendal-memory")]
224 Memory(#[serde(skip, default = "self::default_memory_operator")] Operator),
225 #[cfg(feature = "opendal-fs")]
227 LocalFs,
228 #[cfg(feature = "opendal-s3")]
233 S3 {
234 config: Arc<S3Config>,
236 #[serde(skip)]
238 customized_credential_load: Option<CustomAwsCredentialLoader>,
239 },
240 #[cfg(feature = "opendal-gcs")]
242 Gcs {
243 config: Arc<GcsConfig>,
245 },
246 #[cfg(feature = "opendal-oss")]
248 Oss {
249 config: Arc<OssConfig>,
251 },
252 #[cfg(feature = "opendal-azdls")]
259 Azdls {
260 config: Arc<AzdlsConfig>,
262 },
263 #[cfg(feature = "opendal-hf")]
269 Hf {
270 config: Arc<HfConfig>,
272 },
273}
274
275impl OpenDalStorage {
276 #[allow(unreachable_code, unused_variables)]
289 pub(crate) fn create_operator<'a>(
290 &self,
291 path: &'a impl AsRef<str>,
292 ) -> Result<(Operator, &'a str)> {
293 let path = path.as_ref();
294 let (operator, relative_path): (Operator, &str) = match self {
295 #[cfg(feature = "opendal-memory")]
296 OpenDalStorage::Memory(op) => {
297 if let Some(stripped) = path.strip_prefix("memory:/") {
298 (op.clone(), stripped)
299 } else {
300 (op.clone(), &path[1..])
301 }
302 }
303 #[cfg(feature = "opendal-fs")]
304 OpenDalStorage::LocalFs => {
305 let op = fs_config_build()?;
306 if let Some(stripped) = path.strip_prefix("file:/") {
307 (op, stripped)
308 } else {
309 (op, &path[1..])
310 }
311 }
312 #[cfg(feature = "opendal-s3")]
313 OpenDalStorage::S3 {
314 config,
315 customized_credential_load,
316 } => {
317 let op = s3_config_build(config, customized_credential_load, path)?;
318 let op_info = op.info();
319
320 let url = url::Url::parse(path).map_err(|e| {
323 Error::new(
324 ErrorKind::DataInvalid,
325 format!("Invalid s3 url: {path}: {e}"),
326 )
327 })?;
328 let prefix = format!("{}://{}/", url.scheme(), op_info.name());
329 if path.starts_with(&prefix) {
330 (op, &path[prefix.len()..])
331 } else {
332 return Err(Error::new(
333 ErrorKind::DataInvalid,
334 format!("Invalid s3 url: {path}, should start with {prefix}"),
335 ));
336 }
337 }
338 #[cfg(feature = "opendal-gcs")]
339 OpenDalStorage::Gcs { config } => {
340 let operator = gcs_config_build(config, path)?;
341 let prefix = format!("gs://{}/", operator.info().name());
342 if path.starts_with(&prefix) {
343 (operator, &path[prefix.len()..])
344 } else {
345 return Err(Error::new(
346 ErrorKind::DataInvalid,
347 format!("Invalid gcs url: {path}, should start with {prefix}"),
348 ));
349 }
350 }
351 #[cfg(feature = "opendal-oss")]
352 OpenDalStorage::Oss { config } => {
353 let op = oss_config_build(config, path)?;
354 let prefix = format!("oss://{}/", op.info().name());
355 if path.starts_with(&prefix) {
356 (op, &path[prefix.len()..])
357 } else {
358 return Err(Error::new(
359 ErrorKind::DataInvalid,
360 format!("Invalid oss url: {path}, should start with {prefix}"),
361 ));
362 }
363 }
364 #[cfg(feature = "opendal-azdls")]
365 OpenDalStorage::Azdls { config } => azdls_create_operator(path, config)?,
366 #[cfg(feature = "opendal-hf")]
367 OpenDalStorage::Hf { config } => hf_config_build(config, path)?,
368 #[cfg(all(
369 not(feature = "opendal-s3"),
370 not(feature = "opendal-fs"),
371 not(feature = "opendal-gcs"),
372 not(feature = "opendal-oss"),
373 not(feature = "opendal-azdls"),
374 not(feature = "opendal-hf"),
375 ))]
376 _ => {
377 return Err(Error::new(
378 ErrorKind::FeatureUnsupported,
379 "No storage service has been enabled",
380 ));
381 }
382 };
383
384 let operator = operator.layer(TimeoutLayer::new()).layer(RetryLayer::new());
395 Ok((operator, relative_path))
396 }
397
398 fn batch_key_for_path(&self, path: &str) -> String {
403 match self {
404 #[cfg(feature = "opendal-hf")]
405 OpenDalStorage::Hf { .. } => hf_batch_key(path),
406 _ => url::Url::parse(path)
407 .ok()
408 .and_then(|u| u.host_str().map(|s| s.to_string()))
409 .unwrap_or_default(),
410 }
411 }
412
413 #[allow(unreachable_code, unused_variables)]
419 pub(crate) fn relativize_path<'a>(&self, path: &'a str) -> Result<&'a str> {
420 match self {
421 #[cfg(feature = "opendal-memory")]
422 OpenDalStorage::Memory(_) => Ok(path.strip_prefix("memory:/").unwrap_or(&path[1..])),
423 #[cfg(feature = "opendal-fs")]
424 OpenDalStorage::LocalFs => Ok(path.strip_prefix("file:/").unwrap_or(&path[1..])),
425 #[cfg(feature = "opendal-s3")]
426 OpenDalStorage::S3 { .. } => {
427 let url = url::Url::parse(path)?;
428 let bucket = url.host_str().ok_or_else(|| {
429 Error::new(
430 ErrorKind::DataInvalid,
431 format!("Invalid s3 url: {path}, missing bucket"),
432 )
433 })?;
434 let prefix = format!("{}://{}/", url.scheme(), bucket);
435 if path.starts_with(&prefix) {
436 Ok(&path[prefix.len()..])
437 } else {
438 Err(Error::new(
439 ErrorKind::DataInvalid,
440 format!("Invalid s3 url: {path}, should start with {prefix}"),
441 ))
442 }
443 }
444 #[cfg(feature = "opendal-gcs")]
445 OpenDalStorage::Gcs { .. } => {
446 let url = url::Url::parse(path)?;
447 let bucket = url.host_str().ok_or_else(|| {
448 Error::new(
449 ErrorKind::DataInvalid,
450 format!("Invalid gcs url: {path}, missing bucket"),
451 )
452 })?;
453 let prefix = format!("gs://{}/", bucket);
454 if path.starts_with(&prefix) {
455 Ok(&path[prefix.len()..])
456 } else {
457 Err(Error::new(
458 ErrorKind::DataInvalid,
459 format!("Invalid gcs url: {path}, should start with {prefix}"),
460 ))
461 }
462 }
463 #[cfg(feature = "opendal-oss")]
464 OpenDalStorage::Oss { .. } => {
465 let url = url::Url::parse(path)?;
466 let bucket = url.host_str().ok_or_else(|| {
467 Error::new(
468 ErrorKind::DataInvalid,
469 format!("Invalid oss url: {path}, missing bucket"),
470 )
471 })?;
472 let prefix = format!("oss://{}/", bucket);
473 if path.starts_with(&prefix) {
474 Ok(&path[prefix.len()..])
475 } else {
476 Err(Error::new(
477 ErrorKind::DataInvalid,
478 format!("Invalid oss url: {path}, should start with {prefix}"),
479 ))
480 }
481 }
482 #[cfg(feature = "opendal-azdls")]
483 OpenDalStorage::Azdls { config } => {
484 let azure_path = path.parse::<AzureStoragePath>()?;
485 match_path_with_config(&azure_path, config)?;
486 let relative_path_len = azure_path.path.len();
487 Ok(&path[path.len() - relative_path_len..])
488 }
489 #[cfg(feature = "opendal-hf")]
490 OpenDalStorage::Hf { .. } => {
491 let parsed = HfUri::parse(path).ok_or_else(|| {
492 Error::new(ErrorKind::DataInvalid, format!("Invalid hf url: {path}"))
493 })?;
494 Ok(&path[path.len() - parsed.path.len()..])
495 }
496 #[cfg(all(
497 not(feature = "opendal-s3"),
498 not(feature = "opendal-fs"),
499 not(feature = "opendal-gcs"),
500 not(feature = "opendal-oss"),
501 not(feature = "opendal-azdls"),
502 not(feature = "opendal-hf"),
503 ))]
504 _ => Err(Error::new(
505 ErrorKind::FeatureUnsupported,
506 "No storage service has been enabled",
507 )),
508 }
509 }
510}
511
512#[typetag::serde(name = "OpenDalStorage")]
513#[async_trait]
514impl Storage for OpenDalStorage {
515 async fn exists(&self, path: &str) -> Result<bool> {
516 let (op, relative_path) = self.create_operator(&path)?;
517 Ok(op.exists(relative_path).await.map_err(from_opendal_error)?)
518 }
519
520 async fn metadata(&self, path: &str) -> Result<FileMetadata> {
521 let (op, relative_path) = self.create_operator(&path)?;
522 let meta = op.stat(relative_path).await.map_err(from_opendal_error)?;
523 Ok(FileMetadata {
524 size: meta.content_length(),
525 })
526 }
527
528 async fn read(&self, path: &str) -> Result<Bytes> {
529 let (op, relative_path) = self.create_operator(&path)?;
530 Ok(op
531 .read(relative_path)
532 .await
533 .map_err(from_opendal_error)?
534 .to_bytes())
535 }
536
537 async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
538 let (op, relative_path) = self.create_operator(&path)?;
539 Ok(Box::new(OpenDalReader(
540 op.reader(relative_path).await.map_err(from_opendal_error)?,
541 )))
542 }
543
544 async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
545 let (op, relative_path) = self.create_operator(&path)?;
546 op.write(relative_path, bs)
547 .await
548 .map_err(from_opendal_error)?;
549 Ok(())
550 }
551
552 async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
553 let (op, relative_path) = self.create_operator(&path)?;
554 Ok(Box::new(OpenDalWriter(
555 op.writer(relative_path).await.map_err(from_opendal_error)?,
556 )))
557 }
558
559 async fn delete(&self, path: &str) -> Result<()> {
560 let (op, relative_path) = self.create_operator(&path)?;
561 Ok(op.delete(relative_path).await.map_err(from_opendal_error)?)
562 }
563
564 async fn delete_prefix(&self, path: &str) -> Result<()> {
565 let (op, relative_path) = self.create_operator(&path)?;
566 let path = if relative_path.ends_with('/') {
567 relative_path.to_string()
568 } else {
569 format!("{relative_path}/")
570 };
571 Ok(op
572 .delete_with(&path)
573 .recursive(true)
574 .await
575 .map_err(from_opendal_error)?)
576 }
577
578 async fn delete_stream(&self, mut paths: BoxStream<'static, String>) -> Result<()> {
579 let mut deleters: HashMap<String, opendal::Deleter> = HashMap::new();
580
581 while let Some(path) = paths.next().await {
582 let bucket = self.batch_key_for_path(&path);
583
584 let (relative_path, deleter) = match deleters.entry(bucket) {
585 Entry::Occupied(entry) => {
586 (self.relativize_path(&path)?.to_string(), entry.into_mut())
587 }
588 Entry::Vacant(entry) => {
589 let (op, rel) = self.create_operator(&path)?;
590 let rel = rel.to_string();
591 let deleter = op.deleter().await.map_err(from_opendal_error)?;
592 (rel, entry.insert(deleter))
593 }
594 };
595
596 deleter
597 .delete(relative_path)
598 .await
599 .map_err(from_opendal_error)?;
600 }
601
602 for (_, mut deleter) in deleters {
603 deleter.close().await.map_err(from_opendal_error)?;
604 }
605
606 Ok(())
607 }
608
609 #[allow(unreachable_code, unused_variables)]
610 fn new_input(&self, path: &str) -> Result<InputFile> {
611 Ok(InputFile::new(Arc::new(self.clone()), path.to_string()))
612 }
613
614 #[allow(unreachable_code, unused_variables)]
615 fn new_output(&self, path: &str) -> Result<OutputFile> {
616 Ok(OutputFile::new(Arc::new(self.clone()), path.to_string()))
617 }
618}
619
620pub(crate) struct OpenDalReader(pub(crate) opendal::Reader);
626
627#[async_trait]
628impl FileRead for OpenDalReader {
629 async fn read(&self, range: std::ops::Range<u64>) -> Result<Bytes> {
630 Ok(opendal::Reader::read(&self.0, range)
631 .await
632 .map_err(from_opendal_error)?
633 .to_bytes())
634 }
635}
636
637pub(crate) struct OpenDalWriter(pub(crate) opendal::Writer);
639
640#[async_trait]
641impl FileWrite for OpenDalWriter {
642 async fn write(&mut self, bs: Bytes) -> Result<()> {
643 Ok(opendal::Writer::write(&mut self.0, bs)
644 .await
645 .map_err(from_opendal_error)?)
646 }
647
648 async fn close(&mut self) -> Result<()> {
649 let _ = opendal::Writer::close(&mut self.0)
650 .await
651 .map_err(from_opendal_error)?;
652 Ok(())
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[cfg(feature = "opendal-s3")]
661 #[derive(Debug)]
662 struct EmptyCredentialLoader;
663
664 #[cfg(feature = "opendal-s3")]
665 impl ProvideCredential for EmptyCredentialLoader {
666 type Credential = AwsCredential;
667
668 async fn provide_credential(
669 &self,
670 _ctx: &reqsign_core::Context,
671 ) -> reqsign_core::Result<Option<AwsCredential>> {
672 Ok(None)
673 }
674 }
675
676 #[cfg(feature = "opendal-s3")]
677 #[test]
678 fn test_s3_factory_custom_credential_loader_serialization_fails() {
679 let file_io = iceberg::io::FileIOBuilder::new(Arc::new(OpenDalStorageFactory::S3 {
680 customized_credential_load: Some(CustomAwsCredentialLoader::new(EmptyCredentialLoader)),
681 }))
682 .build();
683
684 let err = file_io.serialize_all().unwrap_err();
685 assert!(
686 err.to_string()
687 .contains("custom AWS credential loaders cannot be serialized")
688 );
689 }
690
691 #[cfg(feature = "opendal-memory")]
692 #[test]
693 fn test_default_memory_operator() {
694 let op = default_memory_operator();
695 assert_eq!(op.info().scheme().to_string(), "memory");
696 }
697
698 #[cfg(feature = "opendal-memory")]
699 #[test]
700 fn test_relativize_path_memory() {
701 let storage = OpenDalStorage::Memory(default_memory_operator());
702
703 assert_eq!(
704 storage.relativize_path("memory:/path/to/file").unwrap(),
705 "path/to/file"
706 );
707 assert_eq!(
709 storage.relativize_path("/path/to/file").unwrap(),
710 "path/to/file"
711 );
712 }
713
714 #[cfg(feature = "opendal-fs")]
715 #[test]
716 fn test_relativize_path_fs() {
717 let storage = OpenDalStorage::LocalFs;
718
719 assert_eq!(
720 storage
721 .relativize_path("file:/tmp/data/file.parquet")
722 .unwrap(),
723 "tmp/data/file.parquet"
724 );
725 assert_eq!(
726 storage.relativize_path("/tmp/data/file.parquet").unwrap(),
727 "tmp/data/file.parquet"
728 );
729 }
730
731 #[cfg(feature = "opendal-s3")]
732 #[test]
733 fn test_relativize_path_s3() {
734 let storage = OpenDalStorage::S3 {
735 config: Arc::new(S3Config::default()),
736 customized_credential_load: None,
737 };
738
739 for scheme in ["s3", "s3a", "s3n", "minio"] {
743 assert_eq!(
744 storage
745 .relativize_path(&format!("{scheme}://my-bucket/path/to/file.parquet"))
746 .unwrap(),
747 "path/to/file.parquet"
748 );
749 }
750 }
751
752 #[cfg(feature = "opendal-gcs")]
753 #[test]
754 fn test_relativize_path_gcs() {
755 let storage = OpenDalStorage::Gcs {
756 config: Arc::new(GcsConfig::default()),
757 };
758
759 assert_eq!(
760 storage
761 .relativize_path("gs://my-bucket/path/to/file.parquet")
762 .unwrap(),
763 "path/to/file.parquet"
764 );
765 }
766
767 #[cfg(feature = "opendal-gcs")]
768 #[test]
769 fn test_relativize_path_gcs_invalid_scheme() {
770 let storage = OpenDalStorage::Gcs {
771 config: Arc::new(GcsConfig::default()),
772 };
773
774 assert!(
775 storage
776 .relativize_path("s3://my-bucket/path/to/file.parquet")
777 .is_err()
778 );
779 }
780
781 #[cfg(feature = "opendal-oss")]
782 #[test]
783 fn test_relativize_path_oss() {
784 let storage = OpenDalStorage::Oss {
785 config: Arc::new(OssConfig::default()),
786 };
787
788 assert_eq!(
789 storage
790 .relativize_path("oss://my-bucket/path/to/file.parquet")
791 .unwrap(),
792 "path/to/file.parquet"
793 );
794 }
795
796 #[cfg(feature = "opendal-oss")]
797 #[test]
798 fn test_relativize_path_oss_invalid_scheme() {
799 let storage = OpenDalStorage::Oss {
800 config: Arc::new(OssConfig::default()),
801 };
802
803 assert!(
804 storage
805 .relativize_path("s3://my-bucket/path/to/file.parquet")
806 .is_err()
807 );
808 }
809
810 #[cfg(feature = "opendal-azdls")]
811 #[test]
812 fn test_relativize_path_azdls() {
813 let storage = OpenDalStorage::Azdls {
814 config: Arc::new(AzdlsConfig {
815 account_name: Some("myaccount".to_string()),
816 endpoint: Some("https://myaccount.dfs.core.windows.net".to_string()),
817 ..Default::default()
818 }),
819 };
820
821 assert_eq!(
822 storage
823 .relativize_path("abfss://myfs@myaccount.dfs.core.windows.net/path/to/file.parquet")
824 .unwrap(),
825 "/path/to/file.parquet"
826 );
827 }
828}