Skip to main content

iceberg_storage_opendal/
lib.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! OpenDAL-based storage implementation for Apache Iceberg.
19//!
20//! This crate provides [`OpenDalStorage`] and [`OpenDalStorageFactory`],
21//! which implement the [`Storage`] and
22//! [`StorageFactory`] traits from the `iceberg` crate
23//! using [OpenDAL](https://opendal.apache.org/) as the backend.
24
25mod 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
114/// Timeout in milliseconds for each IO call on a reader, writer, lister or deleter, applied per
115/// retry attempt via OpenDAL's `TimeoutLayer::with_io_timeout`. Defaults to 10 seconds.
116///
117/// OpenDAL's separate `timeout`, which bounds whole control operations such as `stat`, is not
118/// affected and stays at its 60-second default.
119pub const OPENDAL_IO_TIMEOUT_MS: &str = "opendal.io-timeout-ms";
120
121/// Matches OpenDAL's `TimeoutLayer` default.
122const DEFAULT_IO_TIMEOUT_MS: NonZeroU64 = NonZeroU64::new(10_000).unwrap();
123
124/// Backend-independent client settings shared by every [`OpenDalStorage`] variant.
125#[derive(Clone, Debug, Properties, Serialize, Deserialize)]
126#[serde(default)]
127pub struct OpenDalClientConfig {
128    /// IO timeout in milliseconds.
129    #[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/// OpenDAL-based storage factory.
163///
164/// Maps scheme to the corresponding OpenDalStorage storage variant.
165/// Use this factory with `FileIOBuilder::new(factory)` to create FileIO instances.
166///
167/// # Serialization
168///
169/// The receiving binary must enable the feature corresponding to the serialized backend variant.
170/// For example, deserializing `OpenDalStorageFactory::S3` requires the `opendal-s3` feature.
171///
172/// Serialization fails when the `OpenDalStorageFactory::S3` variant contains a custom AWS
173/// credential loader because the loader holds process-local state that cannot be reconstructed in
174/// another process. Construct the factory without a custom loader before serializing it.
175#[derive(Clone, Debug, Serialize, Deserialize)]
176pub enum OpenDalStorageFactory {
177    /// Memory storage factory.
178    #[cfg(feature = "opendal-memory")]
179    Memory,
180    /// Local filesystem storage factory.
181    #[cfg(feature = "opendal-fs")]
182    Fs,
183    /// S3 storage factory.
184    #[cfg(feature = "opendal-s3")]
185    S3 {
186        /// Custom AWS credential loader.
187        #[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    /// GCS storage factory.
195    #[cfg(feature = "opendal-gcs")]
196    Gcs,
197    /// HDFS storage factory.
198    #[cfg(feature = "opendal-hdfs-native")]
199    HdfsNative,
200    /// OSS storage factory.
201    #[cfg(feature = "opendal-oss")]
202    Oss,
203    /// Azure Data Lake Storage factory.
204    #[cfg(feature = "opendal-azdls")]
205    Azdls,
206    /// HuggingFace Hub storage factory.
207    #[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/// Default memory operator for serde deserialization.
291#[cfg(feature = "opendal-memory")]
292fn default_memory_operator() -> Operator {
293    memory_config_build().expect("Failed to create default memory operator")
294}
295
296/// OpenDAL-based storage implementation.
297///
298/// The serialized representation is not a stable format and may change between crate versions.
299#[derive(Clone, Debug, Serialize, Deserialize)]
300pub enum OpenDalStorage {
301    /// Memory storage variant.
302    #[cfg(feature = "opendal-memory")]
303    Memory {
304        /// Pre-built memory operator.
305        #[serde(skip, default = "self::default_memory_operator")]
306        operator: Operator,
307        /// Backend-independent client settings.
308        #[serde(default)]
309        client_config: OpenDalClientConfig,
310    },
311    /// Local filesystem storage variant.
312    #[cfg(feature = "opendal-fs")]
313    LocalFs {
314        /// Backend-independent client settings.
315        #[serde(default)]
316        client_config: OpenDalClientConfig,
317    },
318    /// S3 storage variant.
319    ///
320    /// Accepts any S3-family URL (`s3://`, `s3a://`, `s3n://`); the scheme is
321    /// derived from the path at call time.
322    #[cfg(feature = "opendal-s3")]
323    S3 {
324        /// S3 configuration.
325        config: Arc<S3Config>,
326        /// Custom AWS credential loader.
327        #[serde(skip)]
328        customized_credential_load: Option<CustomAwsCredentialLoader>,
329        /// Backend-independent client settings.
330        #[serde(default)]
331        client_config: OpenDalClientConfig,
332    },
333    /// GCS storage variant.
334    #[cfg(feature = "opendal-gcs")]
335    Gcs {
336        /// GCS configuration.
337        config: Arc<GcsConfig>,
338        /// Backend-independent client settings.
339        #[serde(default)]
340        client_config: OpenDalClientConfig,
341    },
342    /// HDFS storage variant; see [`HdfsNativeStorage`].
343    #[cfg(feature = "opendal-hdfs-native")]
344    HdfsNative(HdfsNativeStorage),
345    /// OSS storage variant.
346    #[cfg(feature = "opendal-oss")]
347    Oss {
348        /// OSS configuration.
349        config: Arc<OssConfig>,
350        /// Backend-independent client settings.
351        #[serde(default)]
352        client_config: OpenDalClientConfig,
353    },
354    /// Azure Data Lake Storage variant.
355    ///
356    /// Accepts paths of the form
357    /// `abfs[s]://<filesystem>@<account>.dfs.<endpoint-suffix>/<path>` or
358    /// `wasb[s]://<container>@<account>.blob.<endpoint-suffix>/<path>`.
359    /// The scheme is derived from the path at call time.
360    #[cfg(feature = "opendal-azdls")]
361    Azdls {
362        /// Azure DLS configuration.
363        config: Arc<AzdlsConfig>,
364        /// Backend-independent client settings.
365        #[serde(default)]
366        client_config: OpenDalClientConfig,
367    },
368    /// HuggingFace Hub storage variant.
369    ///
370    /// Accepts paths of the form
371    /// `hf://<repo_type>/<owner>/<repo>[@<revision>]/<path_in_repo>`,
372    /// where `<repo_type>` must be one of `models`, `datasets`, `spaces`, or `buckets`.
373    #[cfg(feature = "opendal-hf")]
374    Hf {
375        /// HuggingFace Hub configuration (token + endpoint).
376        config: Arc<HfConfig>,
377        /// Backend-independent client settings.
378        #[serde(default)]
379        client_config: OpenDalClientConfig,
380    },
381}
382
383impl OpenDalStorage {
384    /// Creates operator from path.
385    ///
386    /// # Arguments
387    ///
388    /// * path: It should be *absolute* path starting with scheme string used to construct [`FileIO`](iceberg::io::FileIO).
389    ///
390    /// # Returns
391    ///
392    /// The return value consists of two parts:
393    ///
394    /// * An [`opendal::Operator`] instance used to operate on file.
395    /// * Relative path to the root uri of [`opendal::Operator`].
396    #[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                // Use the URL scheme in the path for prefix matching. This enables
430                // use of S3-compatible storage backends using custom schemes (e.g., `minio://`, `r2://`).
431                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        // Apply observability/resilience layers. TimeoutLayer must be
499        // inside RetryLayer so each retry attempt is independently
500        // bounded — without a per-attempt timeout, a future parked on a
501        // silently dropped TCP connection never produces an `Err` and
502        // RetryLayer cannot retry, leaving the caller hung indefinitely.
503        // See: https://opendal.apache.org/docs/rust/opendal/layers/struct.TimeoutLayer.html
504        //
505        // Transient errors are common for object stores; we retry temporary
506        // failures with exponential backoff. The retry behavior also
507        // benefits non-object-store backends.
508        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    /// Returns a cache key used by `delete_stream` to group paths by storage operator.
547    ///
548    /// For most backends the URL host (bucket name) is sufficient. For HF the host
549    /// encodes the repo type, not the repo identity, so a more specific key is used.
550    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            // The URL host alone would merge distinct NameNodes that differ
555            // only by port; key by the effective NameNode instead.
556            #[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    /// Extracts the relative path from an absolute path without building an operator.
566    ///
567    /// This is a lightweight alternative to [`create_operator`](Self::create_operator) for cases
568    /// where only the relative path is needed (e.g. bulk deletes where the operator is already
569    /// available).
570    #[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
779// Newtype wrappers for opendal types to satisfy orphan rules.
780// We can't implement iceberg's FileRead/FileWrite traits directly on opendal's
781// Reader/Writer since neither trait nor type is defined in this crate.
782
783/// Wrapper around `opendal::Reader` that implements `FileRead`.
784pub(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
796/// Wrapper around `opendal::Writer` that implements `FileWrite`.
797pub(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        // Object stores may omit the size (reported as 0); validate only a reported nonzero size.
828        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        // `TimeoutLayer` has no getters, so compare through `Debug`.
885        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            // `build` returns `dyn Storage`, so read the config back from its serialized form.
977            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        // A zero concurrency limit stalls every IO call; paused time skips the waits.
995        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        // Note: the memory service does report a content length, so this only pins the happy
1053        // path. The counter in `OpenDalWriter` is what covers services that don't, such as S3.
1054        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        // Without the scheme prefix, falls back to stripping the leading slash
1095        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        // All S3-family schemes are accepted by the same storage instance.
1130        // Custom schemes for S3-compatible stores (e.g., `minio://`) are also
1131        // accepted because the path's scheme is used as-is for prefix matching.
1132        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}