1mod utils;
26
27use std::collections::HashMap;
28use std::collections::hash_map::Entry;
29use std::num::NonZeroU64;
30use std::sync::Arc;
31use std::time::Duration;
32
33use async_trait::async_trait;
34use bytes::Bytes;
35use cfg_if::cfg_if;
36use futures::StreamExt;
37use futures::stream::BoxStream;
38use iceberg::io::{
39 FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, StorageConfig,
40 StorageFactory,
41};
42use iceberg::{Error, ErrorKind, Result};
43use iceberg_property_macro::Properties;
44use opendal::Operator;
45use opendal::layers::{RetryLayer, TimeoutLayer};
46use serde::{Deserialize, Serialize};
47use utils::from_opendal_error;
48
49cfg_if! {
50 if #[cfg(feature = "opendal-azdls")] {
51 mod azdls;
52 use azdls::*;
53 use opendal::services::AzdlsConfig;
54 }
55}
56
57cfg_if! {
58 if #[cfg(feature = "opendal-hf")] {
59 mod hf;
60 use hf::*;
61 use opendal::services::HfConfig;
62 }
63}
64
65cfg_if! {
66 if #[cfg(feature = "opendal-fs")] {
67 mod fs;
68 use fs::*;
69 }
70}
71
72cfg_if! {
73 if #[cfg(feature = "opendal-gcs")] {
74 mod gcs;
75 use gcs::*;
76 use opendal::services::GcsConfig;
77 }
78}
79
80cfg_if! {
81 if #[cfg(feature = "opendal-hdfs-native")] {
82 mod hdfs_native;
83 use hdfs_native::*;
84 pub use hdfs_native::HdfsNativeStorage;
85 }
86}
87
88cfg_if! {
89 if #[cfg(feature = "opendal-memory")] {
90 mod memory;
91 use memory::*;
92 }
93}
94
95cfg_if! {
96 if #[cfg(feature = "opendal-oss")] {
97 mod oss;
98 use opendal::services::OssConfig;
99 use oss::*;
100 }
101}
102
103cfg_if! {
104 if #[cfg(feature = "opendal-s3")] {
105 mod s3;
106 use opendal::services::S3Config;
107 pub use s3::*;
108 }
109}
110
111mod resolving;
112pub use resolving::{OpenDalResolvingStorage, OpenDalResolvingStorageFactory};
113
114pub const OPENDAL_IO_TIMEOUT_MS: &str = "opendal.io-timeout-ms";
120
121const DEFAULT_IO_TIMEOUT_MS: NonZeroU64 = NonZeroU64::new(10_000).unwrap();
123
124#[derive(Clone, Debug, Properties, Serialize, Deserialize)]
126#[serde(default)]
127pub struct OpenDalClientConfig {
128 #[property(
130 key = OPENDAL_IO_TIMEOUT_MS,
131 default = DEFAULT_IO_TIMEOUT_MS,
132 parse_with = parse_io_timeout_ms
133 )]
134 io_timeout_ms: NonZeroU64,
135}
136
137impl Default for OpenDalClientConfig {
138 fn default() -> Self {
139 Self {
140 io_timeout_ms: DEFAULT_IO_TIMEOUT_MS,
141 }
142 }
143}
144
145impl OpenDalClientConfig {
146 pub(crate) fn io_timeout(&self) -> Duration {
147 Duration::from_millis(self.io_timeout_ms.get())
148 }
149}
150
151fn parse_io_timeout_ms(value: &str) -> Result<NonZeroU64> {
152 value.parse().map_err(|error| {
153 Error::new(
154 ErrorKind::DataInvalid,
155 "Expected a positive integer number of milliseconds",
156 )
157 .with_context("value", format!("{value:?}"))
158 .with_source(error)
159 })
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize)]
176pub enum OpenDalStorageFactory {
177 #[cfg(feature = "opendal-memory")]
179 Memory,
180 #[cfg(feature = "opendal-fs")]
182 Fs,
183 #[cfg(feature = "opendal-s3")]
185 S3 {
186 #[serde(
188 skip_deserializing,
189 skip_serializing_if = "Option::is_none",
190 serialize_with = "serialize_custom_credential_loader"
191 )]
192 customized_credential_load: Option<CustomAwsCredentialLoader>,
193 },
194 #[cfg(feature = "opendal-gcs")]
196 Gcs,
197 #[cfg(feature = "opendal-hdfs-native")]
199 HdfsNative,
200 #[cfg(feature = "opendal-oss")]
202 Oss,
203 #[cfg(feature = "opendal-azdls")]
205 Azdls,
206 #[cfg(feature = "opendal-hf")]
208 Hf,
209}
210
211#[cfg(feature = "opendal-s3")]
212pub(crate) fn serialize_custom_credential_loader<S>(
213 _loader: &Option<CustomAwsCredentialLoader>,
214 _serializer: S,
215) -> std::result::Result<S::Ok, S::Error>
216where
217 S: serde::Serializer,
218{
219 Err(serde::ser::Error::custom(
220 "custom AWS credential loaders cannot be serialized",
221 ))
222}
223
224#[typetag::serde(name = "OpenDalStorageFactory")]
225impl StorageFactory for OpenDalStorageFactory {
226 #[allow(unused_variables)]
227 fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
228 let client_config = OpenDalClientConfig::from_properties(config.props())?;
229 match self {
230 #[cfg(feature = "opendal-memory")]
231 OpenDalStorageFactory::Memory => Ok(Arc::new(OpenDalStorage::Memory {
232 operator: memory_config_build()?,
233 client_config,
234 })),
235 #[cfg(feature = "opendal-fs")]
236 OpenDalStorageFactory::Fs => Ok(Arc::new(OpenDalStorage::LocalFs { client_config })),
237 #[cfg(feature = "opendal-s3")]
238 OpenDalStorageFactory::S3 {
239 customized_credential_load,
240 } => Ok(Arc::new(OpenDalStorage::S3 {
241 config: s3_config_parse(config.props().clone())?.into(),
242 customized_credential_load: customized_credential_load.clone(),
243 client_config,
244 })),
245 #[cfg(feature = "opendal-gcs")]
246 OpenDalStorageFactory::Gcs => Ok(Arc::new(OpenDalStorage::Gcs {
247 config: gcs_config_parse(config.props().clone())?.into(),
248 client_config,
249 })),
250 #[cfg(feature = "opendal-hdfs-native")]
251 OpenDalStorageFactory::HdfsNative => Ok(Arc::new(OpenDalStorage::HdfsNative(
252 HdfsNativeStorage::new(
253 hdfs_native_config_parse(config.props().clone())?,
254 client_config,
255 ),
256 ))),
257 #[cfg(feature = "opendal-oss")]
258 OpenDalStorageFactory::Oss => Ok(Arc::new(OpenDalStorage::Oss {
259 config: oss_config_parse(config.props().clone())?.into(),
260 client_config,
261 })),
262 #[cfg(feature = "opendal-azdls")]
263 OpenDalStorageFactory::Azdls => Ok(Arc::new(OpenDalStorage::Azdls {
264 config: azdls_config_parse(config.props().clone())?.into(),
265 client_config,
266 })),
267 #[cfg(feature = "opendal-hf")]
268 OpenDalStorageFactory::Hf => Ok(Arc::new(OpenDalStorage::Hf {
269 config: hf_config_parse(config.props().clone())?.into(),
270 client_config,
271 })),
272 #[cfg(all(
273 not(feature = "opendal-memory"),
274 not(feature = "opendal-fs"),
275 not(feature = "opendal-s3"),
276 not(feature = "opendal-gcs"),
277 not(feature = "opendal-oss"),
278 not(feature = "opendal-azdls"),
279 not(feature = "opendal-hf"),
280 not(feature = "opendal-hdfs-native"),
281 ))]
282 _ => Err(Error::new(
283 ErrorKind::FeatureUnsupported,
284 "No storage service has been enabled",
285 )),
286 }
287 }
288}
289
290#[cfg(feature = "opendal-memory")]
292fn default_memory_operator() -> Operator {
293 memory_config_build().expect("Failed to create default memory operator")
294}
295
296#[derive(Clone, Debug, Serialize, Deserialize)]
300pub enum OpenDalStorage {
301 #[cfg(feature = "opendal-memory")]
303 Memory {
304 #[serde(skip, default = "self::default_memory_operator")]
306 operator: Operator,
307 #[serde(default)]
309 client_config: OpenDalClientConfig,
310 },
311 #[cfg(feature = "opendal-fs")]
313 LocalFs {
314 #[serde(default)]
316 client_config: OpenDalClientConfig,
317 },
318 #[cfg(feature = "opendal-s3")]
323 S3 {
324 config: Arc<S3Config>,
326 #[serde(skip)]
328 customized_credential_load: Option<CustomAwsCredentialLoader>,
329 #[serde(default)]
331 client_config: OpenDalClientConfig,
332 },
333 #[cfg(feature = "opendal-gcs")]
335 Gcs {
336 config: Arc<GcsConfig>,
338 #[serde(default)]
340 client_config: OpenDalClientConfig,
341 },
342 #[cfg(feature = "opendal-hdfs-native")]
344 HdfsNative(HdfsNativeStorage),
345 #[cfg(feature = "opendal-oss")]
347 Oss {
348 config: Arc<OssConfig>,
350 #[serde(default)]
352 client_config: OpenDalClientConfig,
353 },
354 #[cfg(feature = "opendal-azdls")]
361 Azdls {
362 config: Arc<AzdlsConfig>,
364 #[serde(default)]
366 client_config: OpenDalClientConfig,
367 },
368 #[cfg(feature = "opendal-hf")]
374 Hf {
375 config: Arc<HfConfig>,
377 #[serde(default)]
379 client_config: OpenDalClientConfig,
380 },
381}
382
383impl OpenDalStorage {
384 #[allow(unreachable_code, unused_variables)]
397 pub(crate) fn create_operator<'a>(
398 &self,
399 path: &'a impl AsRef<str>,
400 ) -> Result<(Operator, &'a str)> {
401 let path = path.as_ref();
402 let (operator, relative_path): (Operator, &str) = match self {
403 #[cfg(feature = "opendal-memory")]
404 OpenDalStorage::Memory { operator: op, .. } => {
405 if let Some(stripped) = path.strip_prefix("memory:/") {
406 (op.clone(), stripped)
407 } else {
408 (op.clone(), &path[1..])
409 }
410 }
411 #[cfg(feature = "opendal-fs")]
412 OpenDalStorage::LocalFs { .. } => {
413 let op = fs_config_build()?;
414 if let Some(stripped) = path.strip_prefix("file:/") {
415 (op, stripped)
416 } else {
417 (op, &path[1..])
418 }
419 }
420 #[cfg(feature = "opendal-s3")]
421 OpenDalStorage::S3 {
422 config,
423 customized_credential_load,
424 ..
425 } => {
426 let op = s3_config_build(config, customized_credential_load, path)?;
427 let op_info = op.info();
428
429 let url = url::Url::parse(path).map_err(|e| {
432 Error::new(
433 ErrorKind::DataInvalid,
434 format!("Invalid s3 url: {path}: {e}"),
435 )
436 })?;
437 let prefix = format!("{}://{}/", url.scheme(), op_info.name());
438 if path.starts_with(&prefix) {
439 (op, &path[prefix.len()..])
440 } else {
441 return Err(Error::new(
442 ErrorKind::DataInvalid,
443 format!("Invalid s3 url: {path}, should start with {prefix}"),
444 ));
445 }
446 }
447 #[cfg(feature = "opendal-gcs")]
448 OpenDalStorage::Gcs { config, .. } => {
449 let operator = gcs_config_build(config, path)?;
450 let prefix = format!("gs://{}/", operator.info().name());
451 if path.starts_with(&prefix) {
452 (operator, &path[prefix.len()..])
453 } else {
454 return Err(Error::new(
455 ErrorKind::DataInvalid,
456 format!("Invalid gcs url: {path}, should start with {prefix}"),
457 ));
458 }
459 }
460 #[cfg(feature = "opendal-hdfs-native")]
461 OpenDalStorage::HdfsNative(storage) => {
462 hdfs_native_create_operator(path, &storage.config, &storage.operators)?
463 }
464 #[cfg(feature = "opendal-oss")]
465 OpenDalStorage::Oss { config, .. } => {
466 let op = oss_config_build(config, path)?;
467 let prefix = format!("oss://{}/", op.info().name());
468 if path.starts_with(&prefix) {
469 (op, &path[prefix.len()..])
470 } else {
471 return Err(Error::new(
472 ErrorKind::DataInvalid,
473 format!("Invalid oss url: {path}, should start with {prefix}"),
474 ));
475 }
476 }
477 #[cfg(feature = "opendal-azdls")]
478 OpenDalStorage::Azdls { config, .. } => azdls_create_operator(path, config)?,
479 #[cfg(feature = "opendal-hf")]
480 OpenDalStorage::Hf { config, .. } => hf_config_build(config, path)?,
481 #[cfg(all(
482 not(feature = "opendal-s3"),
483 not(feature = "opendal-fs"),
484 not(feature = "opendal-gcs"),
485 not(feature = "opendal-oss"),
486 not(feature = "opendal-azdls"),
487 not(feature = "opendal-hf"),
488 not(feature = "opendal-hdfs-native"),
489 ))]
490 _ => {
491 return Err(Error::new(
492 ErrorKind::FeatureUnsupported,
493 "No storage service has been enabled",
494 ));
495 }
496 };
497
498 let operator = operator
509 .layer(TimeoutLayer::new().with_io_timeout(self.client_config().io_timeout()))
510 .layer(RetryLayer::new());
511 Ok((operator, relative_path))
512 }
513
514 pub(crate) fn client_config(&self) -> &OpenDalClientConfig {
515 match self {
516 #[cfg(feature = "opendal-memory")]
517 OpenDalStorage::Memory { client_config, .. } => client_config,
518 #[cfg(feature = "opendal-fs")]
519 OpenDalStorage::LocalFs { client_config } => client_config,
520 #[cfg(feature = "opendal-s3")]
521 OpenDalStorage::S3 { client_config, .. } => client_config,
522 #[cfg(feature = "opendal-gcs")]
523 OpenDalStorage::Gcs { client_config, .. } => client_config,
524 #[cfg(feature = "opendal-oss")]
525 OpenDalStorage::Oss { client_config, .. } => client_config,
526 #[cfg(feature = "opendal-azdls")]
527 OpenDalStorage::Azdls { client_config, .. } => client_config,
528 #[cfg(feature = "opendal-hf")]
529 OpenDalStorage::Hf { client_config, .. } => client_config,
530 #[cfg(feature = "opendal-hdfs-native")]
531 OpenDalStorage::HdfsNative(storage) => &storage.client_config,
532 #[cfg(all(
533 not(feature = "opendal-memory"),
534 not(feature = "opendal-s3"),
535 not(feature = "opendal-fs"),
536 not(feature = "opendal-gcs"),
537 not(feature = "opendal-oss"),
538 not(feature = "opendal-azdls"),
539 not(feature = "opendal-hf"),
540 not(feature = "opendal-hdfs-native"),
541 ))]
542 _ => unreachable!(),
543 }
544 }
545
546 fn batch_key_for_path(&self, path: &str) -> String {
551 match self {
552 #[cfg(feature = "opendal-hf")]
553 OpenDalStorage::Hf { .. } => hf_batch_key(path),
554 #[cfg(feature = "opendal-hdfs-native")]
557 OpenDalStorage::HdfsNative(storage) => hdfs_native_batch_key(&storage.config, path),
558 _ => url::Url::parse(path)
559 .ok()
560 .and_then(|u| u.host_str().map(|s| s.to_string()))
561 .unwrap_or_default(),
562 }
563 }
564
565 #[allow(unreachable_code, unused_variables)]
571 pub(crate) fn relativize_path<'a>(&self, path: &'a str) -> Result<&'a str> {
572 match self {
573 #[cfg(feature = "opendal-memory")]
574 OpenDalStorage::Memory { .. } => {
575 Ok(path.strip_prefix("memory:/").unwrap_or(&path[1..]))
576 }
577 #[cfg(feature = "opendal-fs")]
578 OpenDalStorage::LocalFs { .. } => Ok(path.strip_prefix("file:/").unwrap_or(&path[1..])),
579 #[cfg(feature = "opendal-s3")]
580 OpenDalStorage::S3 { .. } => {
581 let url = url::Url::parse(path)?;
582 let bucket = url.host_str().ok_or_else(|| {
583 Error::new(
584 ErrorKind::DataInvalid,
585 format!("Invalid s3 url: {path}, missing bucket"),
586 )
587 })?;
588 let prefix = format!("{}://{}/", url.scheme(), bucket);
589 if path.starts_with(&prefix) {
590 Ok(&path[prefix.len()..])
591 } else {
592 Err(Error::new(
593 ErrorKind::DataInvalid,
594 format!("Invalid s3 url: {path}, should start with {prefix}"),
595 ))
596 }
597 }
598 #[cfg(feature = "opendal-gcs")]
599 OpenDalStorage::Gcs { .. } => {
600 let url = url::Url::parse(path)?;
601 let bucket = url.host_str().ok_or_else(|| {
602 Error::new(
603 ErrorKind::DataInvalid,
604 format!("Invalid gcs url: {path}, missing bucket"),
605 )
606 })?;
607 let prefix = format!("gs://{}/", bucket);
608 if path.starts_with(&prefix) {
609 Ok(&path[prefix.len()..])
610 } else {
611 Err(Error::new(
612 ErrorKind::DataInvalid,
613 format!("Invalid gcs url: {path}, should start with {prefix}"),
614 ))
615 }
616 }
617 #[cfg(feature = "opendal-hdfs-native")]
618 OpenDalStorage::HdfsNative(storage) => {
619 let (_, relative_path) = hdfs_native_effective_name_node(&storage.config, path)?;
620 Ok(relative_path)
621 }
622 #[cfg(feature = "opendal-oss")]
623 OpenDalStorage::Oss { .. } => {
624 let url = url::Url::parse(path)?;
625 let bucket = url.host_str().ok_or_else(|| {
626 Error::new(
627 ErrorKind::DataInvalid,
628 format!("Invalid oss url: {path}, missing bucket"),
629 )
630 })?;
631 let prefix = format!("oss://{}/", bucket);
632 if path.starts_with(&prefix) {
633 Ok(&path[prefix.len()..])
634 } else {
635 Err(Error::new(
636 ErrorKind::DataInvalid,
637 format!("Invalid oss url: {path}, should start with {prefix}"),
638 ))
639 }
640 }
641 #[cfg(feature = "opendal-azdls")]
642 OpenDalStorage::Azdls { config, .. } => {
643 let azure_path = path.parse::<AzureStoragePath>()?;
644 match_path_with_config(&azure_path, config)?;
645 Ok(azure_path.relative_path(path))
646 }
647 #[cfg(feature = "opendal-hf")]
648 OpenDalStorage::Hf { .. } => {
649 let parsed = HfUri::parse(path).ok_or_else(|| {
650 Error::new(ErrorKind::DataInvalid, format!("Invalid hf url: {path}"))
651 })?;
652 Ok(&path[path.len() - parsed.path.len()..])
653 }
654 #[cfg(all(
655 not(feature = "opendal-s3"),
656 not(feature = "opendal-fs"),
657 not(feature = "opendal-gcs"),
658 not(feature = "opendal-oss"),
659 not(feature = "opendal-azdls"),
660 not(feature = "opendal-hf"),
661 not(feature = "opendal-hdfs-native"),
662 ))]
663 _ => Err(Error::new(
664 ErrorKind::FeatureUnsupported,
665 "No storage service has been enabled",
666 )),
667 }
668 }
669}
670
671#[typetag::serde(name = "OpenDalStorage")]
672#[async_trait]
673impl Storage for OpenDalStorage {
674 async fn exists(&self, path: &str) -> Result<bool> {
675 let (op, relative_path) = self.create_operator(&path)?;
676 Ok(op.exists(relative_path).await.map_err(from_opendal_error)?)
677 }
678
679 async fn metadata(&self, path: &str) -> Result<FileMetadata> {
680 let (op, relative_path) = self.create_operator(&path)?;
681 let meta = op.stat(relative_path).await.map_err(from_opendal_error)?;
682 Ok(FileMetadata {
683 size: meta.content_length(),
684 })
685 }
686
687 async fn read(&self, path: &str) -> Result<Bytes> {
688 let (op, relative_path) = self.create_operator(&path)?;
689 Ok(op
690 .read(relative_path)
691 .await
692 .map_err(from_opendal_error)?
693 .to_bytes())
694 }
695
696 async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
697 let (op, relative_path) = self.create_operator(&path)?;
698 Ok(Box::new(OpenDalReader(
699 op.reader(relative_path).await.map_err(from_opendal_error)?,
700 )))
701 }
702
703 async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
704 let (op, relative_path) = self.create_operator(&path)?;
705 op.write(relative_path, bs)
706 .await
707 .map_err(from_opendal_error)?;
708 Ok(())
709 }
710
711 async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
712 let (op, relative_path) = self.create_operator(&path)?;
713 Ok(Box::new(OpenDalWriter::new(
714 op.writer(relative_path).await.map_err(from_opendal_error)?,
715 )))
716 }
717
718 async fn delete(&self, path: &str) -> Result<()> {
719 let (op, relative_path) = self.create_operator(&path)?;
720 Ok(op.delete(relative_path).await.map_err(from_opendal_error)?)
721 }
722
723 async fn delete_prefix(&self, path: &str) -> Result<()> {
724 let (op, relative_path) = self.create_operator(&path)?;
725 let path = if relative_path.ends_with('/') {
726 relative_path.to_string()
727 } else {
728 format!("{relative_path}/")
729 };
730 Ok(op
731 .delete_with(&path)
732 .recursive(true)
733 .await
734 .map_err(from_opendal_error)?)
735 }
736
737 async fn delete_stream(&self, mut paths: BoxStream<'static, String>) -> Result<()> {
738 let mut deleters: HashMap<String, opendal::Deleter> = HashMap::new();
739
740 while let Some(path) = paths.next().await {
741 let bucket = self.batch_key_for_path(&path);
742
743 let (relative_path, deleter) = match deleters.entry(bucket) {
744 Entry::Occupied(entry) => {
745 (self.relativize_path(&path)?.to_string(), entry.into_mut())
746 }
747 Entry::Vacant(entry) => {
748 let (op, rel) = self.create_operator(&path)?;
749 let rel = rel.to_string();
750 let deleter = op.deleter().await.map_err(from_opendal_error)?;
751 (rel, entry.insert(deleter))
752 }
753 };
754
755 deleter
756 .delete(relative_path)
757 .await
758 .map_err(from_opendal_error)?;
759 }
760
761 for (_, mut deleter) in deleters {
762 deleter.close().await.map_err(from_opendal_error)?;
763 }
764
765 Ok(())
766 }
767
768 #[allow(unreachable_code, unused_variables)]
769 fn new_input(&self, path: &str) -> Result<InputFile> {
770 Ok(InputFile::new(Arc::new(self.clone()), path.to_string()))
771 }
772
773 #[allow(unreachable_code, unused_variables)]
774 fn new_output(&self, path: &str) -> Result<OutputFile> {
775 Ok(OutputFile::new(Arc::new(self.clone()), path.to_string()))
776 }
777}
778
779pub(crate) struct OpenDalReader(pub(crate) opendal::Reader);
785
786#[async_trait]
787impl FileRead for OpenDalReader {
788 async fn read(&self, range: std::ops::Range<u64>) -> Result<Bytes> {
789 Ok(opendal::Reader::read(&self.0, range)
790 .await
791 .map_err(from_opendal_error)?
792 .to_bytes())
793 }
794}
795
796pub(crate) struct OpenDalWriter {
798 inner: opendal::Writer,
799 bytes_written: u64,
800}
801
802impl OpenDalWriter {
803 pub(crate) fn new(inner: opendal::Writer) -> Self {
804 Self {
805 inner,
806 bytes_written: 0,
807 }
808 }
809}
810
811#[async_trait]
812impl FileWrite for OpenDalWriter {
813 async fn write(&mut self, bs: Bytes) -> Result<()> {
814 let len = bs.len() as u64;
815 opendal::Writer::write(&mut self.inner, bs)
816 .await
817 .map_err(from_opendal_error)?;
818 self.bytes_written += len;
819 Ok(())
820 }
821
822 async fn close(&mut self) -> Result<FileMetadata> {
823 let metadata = opendal::Writer::close(&mut self.inner)
824 .await
825 .map_err(from_opendal_error)?;
826
827 let reported_size = metadata.content_length();
829 if reported_size != 0 && reported_size != self.bytes_written {
830 return Err(Error::new(
831 ErrorKind::Unexpected,
832 format!(
833 "Wrote {} bytes but storage reports {reported_size}",
834 self.bytes_written
835 ),
836 ));
837 }
838
839 Ok(FileMetadata {
840 size: self.bytes_written,
841 })
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848
849 fn client_config(value: &str) -> Result<OpenDalClientConfig> {
850 OpenDalClientConfig::from_properties(&HashMap::from([(
851 OPENDAL_IO_TIMEOUT_MS.to_string(),
852 value.to_string(),
853 )]))
854 }
855
856 #[test]
857 fn test_io_timeout_parsing() {
858 let unset = OpenDalClientConfig::from_properties(&HashMap::new()).unwrap();
859 assert_eq!(
860 unset.io_timeout(),
861 Duration::from_millis(DEFAULT_IO_TIMEOUT_MS.get())
862 );
863
864 let max = u64::MAX.to_string();
865 for (valid, ms) in [("45000", 45_000), ("1", 1), (max.as_str(), u64::MAX)] {
866 assert_eq!(
867 client_config(valid).unwrap().io_timeout(),
868 Duration::from_millis(ms),
869 "{valid}"
870 );
871 }
872
873 for invalid in ["0", "-1", "12.5", "abc", "", "18446744073709551616"] {
874 let err = client_config(invalid).unwrap_err().to_string();
875 assert!(err.contains(OPENDAL_IO_TIMEOUT_MS), "{invalid}");
876 assert!(err.contains(&format!("value: {invalid:?}")), "{err}");
877 let reason = invalid.parse::<NonZeroU64>().unwrap_err().to_string();
878 assert!(err.contains(&reason), "{err}");
879 }
880 }
881
882 #[test]
883 fn test_default_timeouts_match_opendal() {
884 let opendal_default = format!("{:?}", TimeoutLayer::new());
886 let layer = |timeout, io_timeout| {
887 format!(
888 "{:?}",
889 TimeoutLayer::new()
890 .with_timeout(timeout)
891 .with_io_timeout(io_timeout)
892 )
893 };
894 let control = Duration::from_secs(60);
895 let io = Duration::from_millis(DEFAULT_IO_TIMEOUT_MS.get());
896 assert_eq!(opendal_default, layer(control, io));
897 assert_ne!(opendal_default, layer(control + Duration::from_secs(1), io));
898 assert_ne!(
899 opendal_default,
900 layer(control, io + Duration::from_millis(1))
901 );
902 }
903
904 #[cfg(feature = "opendal-s3")]
905 #[test]
906 fn test_client_config_serde_round_trip() {
907 let storage = OpenDalStorage::S3 {
908 config: Arc::new(S3Config::default()),
909 customized_credential_load: None,
910 client_config: client_config("45000").unwrap(),
911 };
912
913 let mut value = serde_json::to_value(&storage).unwrap();
914 let restored: OpenDalStorage = serde_json::from_value(value.clone()).unwrap();
915 assert_eq!(
916 restored.client_config().io_timeout(),
917 Duration::from_secs(45)
918 );
919
920 let mut zero = value.clone();
921 zero["S3"]["client_config"]["io_timeout_ms"] = 0.into();
922 assert!(serde_json::from_value::<OpenDalStorage>(zero).is_err());
923
924 value["S3"].as_object_mut().unwrap().remove("client_config");
925 let restored: OpenDalStorage = serde_json::from_value(value).unwrap();
926 assert_eq!(
927 restored.client_config().io_timeout(),
928 Duration::from_millis(DEFAULT_IO_TIMEOUT_MS.get())
929 );
930 }
931
932 #[cfg(all(feature = "opendal-fs", feature = "opendal-memory"))]
933 #[test]
934 fn test_old_unit_variant_forms_are_rejected() {
935 for old in [r#""LocalFs""#, r#""Memory""#, r#"{"Memory":null}"#] {
936 assert!(
937 serde_json::from_str::<OpenDalStorage>(old).is_err(),
938 "{old}"
939 );
940 }
941 }
942
943 #[cfg(feature = "opendal-memory")]
944 #[test]
945 fn test_factory_rejects_invalid_io_timeout() {
946 let config = StorageConfig::new().with_prop(OPENDAL_IO_TIMEOUT_MS, "nope");
947
948 let err = OpenDalStorageFactory::Memory.build(&config).unwrap_err();
949 assert!(err.to_string().contains(OPENDAL_IO_TIMEOUT_MS));
950 }
951
952 #[test]
953 fn test_factory_propagates_io_timeout() {
954 let config = StorageConfig::new().with_prop(OPENDAL_IO_TIMEOUT_MS, "45000");
955 let factories = [
956 #[cfg(feature = "opendal-memory")]
957 OpenDalStorageFactory::Memory,
958 #[cfg(feature = "opendal-fs")]
959 OpenDalStorageFactory::Fs,
960 #[cfg(feature = "opendal-s3")]
961 OpenDalStorageFactory::S3 {
962 customized_credential_load: None,
963 },
964 #[cfg(feature = "opendal-gcs")]
965 OpenDalStorageFactory::Gcs,
966 #[cfg(feature = "opendal-oss")]
967 OpenDalStorageFactory::Oss,
968 #[cfg(feature = "opendal-azdls")]
969 OpenDalStorageFactory::Azdls,
970 #[cfg(feature = "opendal-hf")]
971 OpenDalStorageFactory::Hf,
972 #[cfg(feature = "opendal-hdfs-native")]
973 OpenDalStorageFactory::HdfsNative,
974 ];
975 for factory in factories {
976 let storage = factory.build(&config).unwrap();
978 let json = serde_json::to_value(&*storage).unwrap();
979 let client_config = json
980 .as_object()
981 .unwrap()
982 .values()
983 .find_map(|variant| variant.get("client_config"))
984 .unwrap_or_else(|| panic!("{json}"));
985 assert_eq!(client_config["io_timeout_ms"], 45_000, "{factory:?}");
986 }
987 }
988
989 #[cfg(feature = "opendal-memory")]
990 #[tokio::test(start_paused = true)]
991 async fn test_io_timeout_reaches_timeout_layer() {
992 use opendal::layers::ConcurrentLimitLayer;
993
994 let storage = OpenDalStorage::Memory {
996 operator: default_memory_operator().layer(ConcurrentLimitLayer::new(0)),
997 client_config: client_config("45000").unwrap(),
998 };
999
1000 let err = storage
1001 .read("memory:/stalled")
1002 .await
1003 .unwrap_err()
1004 .to_string();
1005 assert!(err.contains("io operation timeout reached"), "{err}");
1006 assert!(err.contains("{ timeout: 45 }"), "{err}");
1007 }
1008
1009 #[cfg(feature = "opendal-s3")]
1010 #[derive(Debug)]
1011 struct EmptyCredentialLoader;
1012
1013 #[cfg(feature = "opendal-s3")]
1014 impl ProvideCredential for EmptyCredentialLoader {
1015 type Credential = AwsCredential;
1016
1017 async fn provide_credential(
1018 &self,
1019 _ctx: &reqsign_core::Context,
1020 ) -> reqsign_core::Result<Option<AwsCredential>> {
1021 Ok(None)
1022 }
1023 }
1024
1025 #[cfg(feature = "opendal-s3")]
1026 #[test]
1027 fn test_s3_factory_custom_credential_loader_serialization_fails() {
1028 let file_io = iceberg::io::FileIOBuilder::new(Arc::new(OpenDalStorageFactory::S3 {
1029 customized_credential_load: Some(CustomAwsCredentialLoader::new(EmptyCredentialLoader)),
1030 }))
1031 .build();
1032
1033 let err = file_io.serialize_all().unwrap_err();
1034 assert!(
1035 err.to_string()
1036 .contains("custom AWS credential loaders cannot be serialized")
1037 );
1038 }
1039
1040 #[cfg(feature = "opendal-memory")]
1041 #[test]
1042 fn test_default_memory_operator() {
1043 let op = default_memory_operator();
1044 assert_eq!(op.info().scheme().to_string(), "memory");
1045 }
1046
1047 #[cfg(feature = "opendal-memory")]
1048 #[tokio::test]
1049 async fn test_writer_close_returns_stored_size() {
1050 use iceberg::encryption::{EncryptedOutputFile, StandardKeyMetadata};
1051
1052 let storage = Arc::new(OpenDalStorage::Memory {
1055 operator: default_memory_operator(),
1056 client_config: OpenDalClientConfig::default(),
1057 });
1058 let path = "memory:///stored-size";
1059 for plaintext in [
1060 Bytes::new(),
1061 Bytes::from_static(b"test data"),
1062 Bytes::from(vec![7; 3 * 1024]),
1063 ] {
1064 let mut writer = storage.writer(path).await.unwrap();
1065 for chunk in plaintext.chunks(1024) {
1066 writer.write(Bytes::copy_from_slice(chunk)).await.unwrap();
1067 }
1068 let metadata = writer.close().await.unwrap();
1069 assert_eq!(metadata.size, plaintext.len() as u64);
1070 assert_eq!(metadata.size, storage.metadata(path).await.unwrap().size);
1071
1072 let output = EncryptedOutputFile::new(
1073 OutputFile::new(storage.clone(), path.to_string()),
1074 StandardKeyMetadata::try_new(b"0123456789abcdef").unwrap(),
1075 );
1076 let metadata = output.write(plaintext.clone()).await.unwrap();
1077 assert!(metadata.size > plaintext.len() as u64);
1078 assert_eq!(metadata.size, storage.metadata(path).await.unwrap().size);
1079 }
1080 }
1081
1082 #[cfg(feature = "opendal-memory")]
1083 #[test]
1084 fn test_relativize_path_memory() {
1085 let storage = OpenDalStorage::Memory {
1086 operator: default_memory_operator(),
1087 client_config: OpenDalClientConfig::default(),
1088 };
1089
1090 assert_eq!(
1091 storage.relativize_path("memory:/path/to/file").unwrap(),
1092 "path/to/file"
1093 );
1094 assert_eq!(
1096 storage.relativize_path("/path/to/file").unwrap(),
1097 "path/to/file"
1098 );
1099 }
1100
1101 #[cfg(feature = "opendal-fs")]
1102 #[test]
1103 fn test_relativize_path_fs() {
1104 let storage = OpenDalStorage::LocalFs {
1105 client_config: OpenDalClientConfig::default(),
1106 };
1107
1108 assert_eq!(
1109 storage
1110 .relativize_path("file:/tmp/data/file.parquet")
1111 .unwrap(),
1112 "tmp/data/file.parquet"
1113 );
1114 assert_eq!(
1115 storage.relativize_path("/tmp/data/file.parquet").unwrap(),
1116 "tmp/data/file.parquet"
1117 );
1118 }
1119
1120 #[cfg(feature = "opendal-s3")]
1121 #[test]
1122 fn test_relativize_path_s3() {
1123 let storage = OpenDalStorage::S3 {
1124 config: Arc::new(S3Config::default()),
1125 customized_credential_load: None,
1126 client_config: OpenDalClientConfig::default(),
1127 };
1128
1129 for scheme in ["s3", "s3a", "s3n", "minio"] {
1133 assert_eq!(
1134 storage
1135 .relativize_path(&format!("{scheme}://my-bucket/path/to/file.parquet"))
1136 .unwrap(),
1137 "path/to/file.parquet"
1138 );
1139 }
1140 }
1141
1142 #[cfg(feature = "opendal-gcs")]
1143 #[test]
1144 fn test_relativize_path_gcs() {
1145 let storage = OpenDalStorage::Gcs {
1146 config: Arc::new(GcsConfig::default()),
1147 client_config: OpenDalClientConfig::default(),
1148 };
1149
1150 assert_eq!(
1151 storage
1152 .relativize_path("gs://my-bucket/path/to/file.parquet")
1153 .unwrap(),
1154 "path/to/file.parquet"
1155 );
1156 }
1157
1158 #[cfg(feature = "opendal-gcs")]
1159 #[test]
1160 fn test_relativize_path_gcs_invalid_scheme() {
1161 let storage = OpenDalStorage::Gcs {
1162 config: Arc::new(GcsConfig::default()),
1163 client_config: OpenDalClientConfig::default(),
1164 };
1165
1166 assert!(
1167 storage
1168 .relativize_path("s3://my-bucket/path/to/file.parquet")
1169 .is_err()
1170 );
1171 }
1172
1173 #[cfg(feature = "opendal-oss")]
1174 #[test]
1175 fn test_relativize_path_oss() {
1176 let storage = OpenDalStorage::Oss {
1177 config: Arc::new(OssConfig::default()),
1178 client_config: OpenDalClientConfig::default(),
1179 };
1180
1181 assert_eq!(
1182 storage
1183 .relativize_path("oss://my-bucket/path/to/file.parquet")
1184 .unwrap(),
1185 "path/to/file.parquet"
1186 );
1187 }
1188
1189 #[cfg(feature = "opendal-oss")]
1190 #[test]
1191 fn test_relativize_path_oss_invalid_scheme() {
1192 let storage = OpenDalStorage::Oss {
1193 config: Arc::new(OssConfig::default()),
1194 client_config: OpenDalClientConfig::default(),
1195 };
1196
1197 assert!(
1198 storage
1199 .relativize_path("s3://my-bucket/path/to/file.parquet")
1200 .is_err()
1201 );
1202 }
1203
1204 #[cfg(feature = "opendal-azdls")]
1205 #[test]
1206 fn test_relativize_path_azdls() {
1207 let storage = OpenDalStorage::Azdls {
1208 config: Arc::new(AzdlsConfig {
1209 account_name: Some("myaccount".to_string()),
1210 endpoint: Some("https://myaccount.dfs.core.windows.net".to_string()),
1211 ..Default::default()
1212 }),
1213 client_config: OpenDalClientConfig::default(),
1214 };
1215
1216 assert_eq!(
1217 storage
1218 .relativize_path("abfss://myfs@myaccount.dfs.core.windows.net/path/to/file.parquet")
1219 .unwrap(),
1220 "path/to/file.parquet"
1221 );
1222 }
1223}