laskoviymishka commented on code in PR #3165:
URL: https://github.com/apache/iceberg-rust/pull/3165#discussion_r4075301650


##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,471 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `object_store`-based storage implementation for Apache Iceberg.
+//!
+//! This crate provides [`ObjectStoreStorage`] and 
[`ObjectStoreStorageFactory`],
+//! which implement the [`Storage`] and
+//! [`StorageFactory`] traits from the `iceberg` crate
+//! using the [`object_store`](https://docs.rs/object_store) crate as the 
backend.
+//!
+//! Currently only S3 storage is supported (via the `object_store-s3` feature 
flag,
+//! enabled by default).
+
+#[cfg(feature = "object_store-s3")]
+mod s3;
+
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use dashmap::DashMap;
+use futures::stream::BoxStream;
+use futures::{StreamExt, TryStreamExt};
+#[cfg(feature = "object_store-s3")]
+use iceberg::io::S3Config;
+use iceberg::io::{
+    FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, 
StorageConfig,
+    StorageFactory,
+};
+use iceberg::{Error, ErrorKind, Result};
+use object_store::path::Path as ObjectStorePath;
+use object_store::{ObjectStore, ObjectStoreExt, PutPayload, WriteMultipart};
+#[cfg(feature = "object_store-s3")]
+use s3::{build_s3_store, parse_s3_url};
+use serde::{Deserialize, Serialize};
+
+/// Convert an `object_store::Error` into an `iceberg::Error`,
+/// dispatching known variants to their corresponding `ErrorKind`.
+fn from_object_store_error(e: object_store::Error) -> Error {
+    let (kind, msg) = match &e {
+        object_store::Error::NotFound { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Object not found: {path}"))
+        }
+        object_store::Error::AlreadyExists { path, .. } => (
+            ErrorKind::DataInvalid,
+            format!("Object already exists: {path}"),
+        ),
+        object_store::Error::PermissionDenied { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Permission denied: {path}"))
+        }
+        object_store::Error::Unauthenticated { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Unauthenticated: {path}"))

Review Comment:
   New this pass, and all three reviewers landed on it independently. 
`DataInvalid` means corrupt data, so mapping a 403/401 here tells callers the 
table is corrupt when it's really just inaccessible — a corruption-repair path 
could fire on a plain permissions error.
   
   opendal maps all of these to `Unexpected`, so as written the two backends 
disagree on the same failure. I'd move at least 
`PermissionDenied`/`Unauthenticated`/`AlreadyExists` to `Unexpected` to match — 
cheap now, awkward to change once this publishes.



##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,471 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `object_store`-based storage implementation for Apache Iceberg.
+//!
+//! This crate provides [`ObjectStoreStorage`] and 
[`ObjectStoreStorageFactory`],
+//! which implement the [`Storage`] and
+//! [`StorageFactory`] traits from the `iceberg` crate
+//! using the [`object_store`](https://docs.rs/object_store) crate as the 
backend.
+//!
+//! Currently only S3 storage is supported (via the `object_store-s3` feature 
flag,
+//! enabled by default).
+
+#[cfg(feature = "object_store-s3")]
+mod s3;
+
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use dashmap::DashMap;
+use futures::stream::BoxStream;
+use futures::{StreamExt, TryStreamExt};
+#[cfg(feature = "object_store-s3")]
+use iceberg::io::S3Config;
+use iceberg::io::{
+    FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, 
StorageConfig,
+    StorageFactory,
+};
+use iceberg::{Error, ErrorKind, Result};
+use object_store::path::Path as ObjectStorePath;
+use object_store::{ObjectStore, ObjectStoreExt, PutPayload, WriteMultipart};
+#[cfg(feature = "object_store-s3")]
+use s3::{build_s3_store, parse_s3_url};
+use serde::{Deserialize, Serialize};
+
+/// Convert an `object_store::Error` into an `iceberg::Error`,
+/// dispatching known variants to their corresponding `ErrorKind`.
+fn from_object_store_error(e: object_store::Error) -> Error {
+    let (kind, msg) = match &e {
+        object_store::Error::NotFound { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Object not found: {path}"))
+        }
+        object_store::Error::AlreadyExists { path, .. } => (
+            ErrorKind::DataInvalid,
+            format!("Object already exists: {path}"),
+        ),
+        object_store::Error::PermissionDenied { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Permission denied: {path}"))
+        }
+        object_store::Error::Unauthenticated { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Unauthenticated: {path}"))
+        }
+        object_store::Error::NotSupported { .. } => (
+            ErrorKind::FeatureUnsupported,
+            "Operation not supported".to_string(),
+        ),
+        _ => (
+            ErrorKind::Unexpected,
+            "Failure in doing io operation".to_string(),
+        ),
+    };
+    Error::new(kind, msg).with_source(e)
+}
+
+/// Convert `object_store::ObjectMeta` into `iceberg::io::FileMetadata`.
+fn to_file_metadata(meta: object_store::ObjectMeta) -> FileMetadata {
+    FileMetadata { size: meta.size }
+}
+
+/// `object_store`-based storage factory.
+///
+/// Use this factory with `FileIOBuilder::new(factory)` to create FileIO 
instances
+/// backed by the `object_store` crate.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorageFactory {
+    /// S3 storage factory.
+    #[cfg(feature = "object_store-s3")]
+    S3,
+}
+
+#[typetag::serde(name = "ObjectStoreStorageFactory")]
+impl StorageFactory for ObjectStoreStorageFactory {
+    #[allow(unused_variables)]
+    fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorageFactory::S3 => {
+                let s3_config = S3Config::try_from(config)?;
+                Ok(Arc::new(ObjectStoreStorage::S3(S3Storage {
+                    config: Arc::new(s3_config),
+                    store_cache: Arc::new(DashMap::new()),
+                })))
+            }
+        }
+    }
+}
+
+type StoreCache = Arc<DashMap<String, Arc<dyn ObjectStore>>>;
+
+/// `object_store` S3 storage state.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct S3Storage {
+    config: Arc<S3Config>,
+    #[serde(skip, default)]
+    store_cache: StoreCache,
+}
+
+/// `object_store`-based storage implementation.
+///
+/// Stores are cached per bucket to avoid rebuilding the client on every 
operation.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorage {
+    /// S3 storage variant.
+    #[cfg(feature = "object_store-s3")]
+    S3(S3Storage),
+}
+
+struct StoreAndPath {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+impl ObjectStoreStorage {
+    /// Get or create a cached store and extract the relative 
`ObjectStorePath`.
+    fn get_store_and_path(&self, path: &str) -> Result<StoreAndPath> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorage::S3(s3) => {
+                let parsed = parse_s3_url(path)?;
+
+                let store = s3
+                    .store_cache
+                    .entry(parsed.bucket.clone())
+                    .or_try_insert_with(|| build_s3_store(&s3.config, 
&parsed.bucket))?
+                    .value()
+                    .clone();
+
+                let object_path =
+                    
ObjectStorePath::from_url_path(&parsed.relative).map_err(|e| {
+                        Error::new(
+                            ErrorKind::DataInvalid,
+                            format!("Invalid URL path: {}", parsed.relative),
+                        )
+                        .with_source(e)
+                    })?;
+
+                Ok(StoreAndPath {
+                    store,
+                    path: object_path,
+                })
+            }
+        }
+    }
+}
+
+#[typetag::serde(name = "ObjectStoreStorage")]
+#[async_trait]
+impl Storage for ObjectStoreStorage {
+    async fn exists(&self, path: &str) -> Result<bool> {
+        let target = self.get_store_and_path(path)?;
+        match target.store.head(&target.path).await {
+            Ok(_) => Ok(true),
+            Err(object_store::Error::NotFound { .. }) => Ok(false),
+            Err(e) => Err(from_object_store_error(e)),
+        }
+    }
+
+    async fn metadata(&self, path: &str) -> Result<FileMetadata> {
+        let target = self.get_store_and_path(path)?;
+        let meta = target
+            .store
+            .head(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(to_file_metadata(meta))
+    }
+
+    async fn read(&self, path: &str) -> Result<Bytes> {
+        let target = self.get_store_and_path(path)?;
+        let result = target
+            .store
+            .get(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+
+    async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
+        let target = self.get_store_and_path(path)?;
+        Ok(Box::new(ObjectStoreReader {
+            store: target.store,
+            path: target.path,
+        }))
+    }
+
+    async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .put(&target.path, PutPayload::from_bytes(bs))
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
+        let target = self.get_store_and_path(path)?;
+        let upload = target
+            .store
+            .put_multipart(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        let writer = WriteMultipart::new(upload);
+        Ok(Box::new(ObjectStoreWriter {
+            writer: Some(writer),
+        }))
+    }
+
+    async fn delete(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .delete(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_prefix(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        let locations = target
+            .store
+            .list(Some(&target.path))
+            .map_ok(|m| m.location)
+            .boxed();
+        target
+            .store
+            .delete_stream(locations)
+            .try_collect::<Vec<_>>()
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_stream(&self, paths: BoxStream<'static, String>) -> 
Result<()> {
+        // Collect and group by bucket so each store gets a single bulk 
DeleteObjects call.
+        let all_paths: Vec<String> = paths.collect().await;
+        let mut grouped: std::collections::HashMap<String, 
Vec<ObjectStorePath>> =
+            std::collections::HashMap::new();
+        let mut stores: std::collections::HashMap<String, Arc<dyn 
ObjectStore>> =
+            std::collections::HashMap::new();
+
+        for path in all_paths {
+            let target = self.get_store_and_path(&path)?;
+            let bucket = match self {
+                #[cfg(feature = "object_store-s3")]
+                ObjectStoreStorage::S3(_) => {
+                    let parsed = parse_s3_url(&path)?;
+                    parsed.bucket
+                }
+            };
+            stores.entry(bucket.clone()).or_insert(target.store);
+            grouped.entry(bucket).or_default().push(target.path);
+        }
+
+        for (bucket, locations) in grouped {
+            let store = stores.remove(&bucket).expect("store must exist");

Review Comment:
   This `expect` panics in library code if the two-map invariant ever drifts, 
and that's the kind of thing a later refactor breaks silently — our rule is no 
panics in library paths. I'd return an `Unexpected` error here instead.
   
   Better still: this block parses each URL twice (once in 
`get_store_and_path`, once again for the bucket). If `get_store_and_path` 
returned the bucket, you could key a single map on it and the panic disappears 
along with the double parse. Not a blocker, but worth doing while it's fresh.



##########
crates/storage/object_store/src/s3.rs:
##########
@@ -0,0 +1,366 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use iceberg::io::S3Config;
+use iceberg::{Error, ErrorKind, Result};
+use object_store::ObjectStore;
+use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
+use percent_encoding::percent_decode_str;
+use url::Url;
+
+/// Parsed components of an S3 URL.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct ParsedS3Url {
+    pub(crate) scheme: String,
+    pub(crate) bucket: String,
+    pub(crate) relative: String,
+}
+
+/// Parse an absolute S3 URL into [`ParsedS3Url`].
+///
+/// Accepts `s3://`, `s3a://`, and `s3n://` schemes.
+pub(crate) fn parse_s3_url(path: &str) -> Result<ParsedS3Url> {
+    let url = Url::parse(path).map_err(|e| {
+        Error::new(ErrorKind::DataInvalid, format!("Invalid URL: 
{path}")).with_source(e)
+    })?;
+
+    let scheme = url.scheme();
+    match scheme {
+        "s3" | "s3a" | "s3n" => {}
+        _ => {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!("Unsupported S3 scheme: {scheme} in url: {path}"),
+            ));
+        }
+    }
+
+    let bucket = url.host_str().ok_or_else(|| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!("Invalid s3 url: {path}, missing bucket"),
+        )
+    })?;
+
+    if bucket.is_empty() {
+        return Err(Error::new(
+            ErrorKind::DataInvalid,
+            format!("Empty s3 url: {path}, missing bucket"),
+        ));
+    }
+
+    let bucket = percent_decode_str(bucket).decode_utf8().map_err(|e| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!("Invalid percent-encoded bucket in s3 url: {path}"),
+        )
+        .with_source(e)
+    })?;
+
+    let relative = url.path().trim_start_matches('/');

Review Comment:
   Small one, new this pass: `trim_start_matches('/')` strips every leading 
slash, so `s3://bucket//path` collapses to `path` and silently drops the empty 
first segment. `strip_prefix('/').unwrap_or("")` strips exactly one and 
preserves the rest.



##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,471 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `object_store`-based storage implementation for Apache Iceberg.
+//!
+//! This crate provides [`ObjectStoreStorage`] and 
[`ObjectStoreStorageFactory`],
+//! which implement the [`Storage`] and
+//! [`StorageFactory`] traits from the `iceberg` crate
+//! using the [`object_store`](https://docs.rs/object_store) crate as the 
backend.
+//!
+//! Currently only S3 storage is supported (via the `object_store-s3` feature 
flag,
+//! enabled by default).
+
+#[cfg(feature = "object_store-s3")]
+mod s3;
+
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use dashmap::DashMap;
+use futures::stream::BoxStream;
+use futures::{StreamExt, TryStreamExt};
+#[cfg(feature = "object_store-s3")]
+use iceberg::io::S3Config;
+use iceberg::io::{
+    FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, 
StorageConfig,
+    StorageFactory,
+};
+use iceberg::{Error, ErrorKind, Result};
+use object_store::path::Path as ObjectStorePath;
+use object_store::{ObjectStore, ObjectStoreExt, PutPayload, WriteMultipart};
+#[cfg(feature = "object_store-s3")]
+use s3::{build_s3_store, parse_s3_url};
+use serde::{Deserialize, Serialize};
+
+/// Convert an `object_store::Error` into an `iceberg::Error`,
+/// dispatching known variants to their corresponding `ErrorKind`.
+fn from_object_store_error(e: object_store::Error) -> Error {
+    let (kind, msg) = match &e {
+        object_store::Error::NotFound { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Object not found: {path}"))
+        }
+        object_store::Error::AlreadyExists { path, .. } => (
+            ErrorKind::DataInvalid,
+            format!("Object already exists: {path}"),
+        ),
+        object_store::Error::PermissionDenied { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Permission denied: {path}"))
+        }
+        object_store::Error::Unauthenticated { path, .. } => {
+            (ErrorKind::DataInvalid, format!("Unauthenticated: {path}"))
+        }
+        object_store::Error::NotSupported { .. } => (
+            ErrorKind::FeatureUnsupported,
+            "Operation not supported".to_string(),
+        ),
+        _ => (
+            ErrorKind::Unexpected,
+            "Failure in doing io operation".to_string(),
+        ),
+    };
+    Error::new(kind, msg).with_source(e)
+}
+
+/// Convert `object_store::ObjectMeta` into `iceberg::io::FileMetadata`.
+fn to_file_metadata(meta: object_store::ObjectMeta) -> FileMetadata {
+    FileMetadata { size: meta.size }
+}
+
+/// `object_store`-based storage factory.
+///
+/// Use this factory with `FileIOBuilder::new(factory)` to create FileIO 
instances
+/// backed by the `object_store` crate.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorageFactory {
+    /// S3 storage factory.
+    #[cfg(feature = "object_store-s3")]
+    S3,
+}
+
+#[typetag::serde(name = "ObjectStoreStorageFactory")]
+impl StorageFactory for ObjectStoreStorageFactory {
+    #[allow(unused_variables)]
+    fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorageFactory::S3 => {
+                let s3_config = S3Config::try_from(config)?;
+                Ok(Arc::new(ObjectStoreStorage::S3(S3Storage {
+                    config: Arc::new(s3_config),
+                    store_cache: Arc::new(DashMap::new()),
+                })))
+            }
+        }
+    }
+}
+
+type StoreCache = Arc<DashMap<String, Arc<dyn ObjectStore>>>;
+
+/// `object_store` S3 storage state.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct S3Storage {
+    config: Arc<S3Config>,
+    #[serde(skip, default)]
+    store_cache: StoreCache,
+}
+
+/// `object_store`-based storage implementation.
+///
+/// Stores are cached per bucket to avoid rebuilding the client on every 
operation.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorage {
+    /// S3 storage variant.
+    #[cfg(feature = "object_store-s3")]
+    S3(S3Storage),
+}
+
+struct StoreAndPath {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+impl ObjectStoreStorage {
+    /// Get or create a cached store and extract the relative 
`ObjectStorePath`.
+    fn get_store_and_path(&self, path: &str) -> Result<StoreAndPath> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorage::S3(s3) => {
+                let parsed = parse_s3_url(path)?;
+
+                let store = s3
+                    .store_cache
+                    .entry(parsed.bucket.clone())
+                    .or_try_insert_with(|| build_s3_store(&s3.config, 
&parsed.bucket))?
+                    .value()
+                    .clone();
+
+                let object_path =
+                    
ObjectStorePath::from_url_path(&parsed.relative).map_err(|e| {
+                        Error::new(
+                            ErrorKind::DataInvalid,
+                            format!("Invalid URL path: {}", parsed.relative),
+                        )
+                        .with_source(e)
+                    })?;
+
+                Ok(StoreAndPath {
+                    store,
+                    path: object_path,
+                })
+            }
+        }
+    }
+}
+
+#[typetag::serde(name = "ObjectStoreStorage")]
+#[async_trait]
+impl Storage for ObjectStoreStorage {
+    async fn exists(&self, path: &str) -> Result<bool> {
+        let target = self.get_store_and_path(path)?;
+        match target.store.head(&target.path).await {
+            Ok(_) => Ok(true),
+            Err(object_store::Error::NotFound { .. }) => Ok(false),
+            Err(e) => Err(from_object_store_error(e)),
+        }
+    }
+
+    async fn metadata(&self, path: &str) -> Result<FileMetadata> {
+        let target = self.get_store_and_path(path)?;
+        let meta = target
+            .store
+            .head(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(to_file_metadata(meta))
+    }
+
+    async fn read(&self, path: &str) -> Result<Bytes> {
+        let target = self.get_store_and_path(path)?;
+        let result = target
+            .store
+            .get(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+
+    async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
+        let target = self.get_store_and_path(path)?;
+        Ok(Box::new(ObjectStoreReader {
+            store: target.store,
+            path: target.path,
+        }))
+    }
+
+    async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .put(&target.path, PutPayload::from_bytes(bs))
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
+        let target = self.get_store_and_path(path)?;
+        let upload = target
+            .store
+            .put_multipart(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        let writer = WriteMultipart::new(upload);
+        Ok(Box::new(ObjectStoreWriter {
+            writer: Some(writer),
+        }))
+    }
+
+    async fn delete(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .delete(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_prefix(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        let locations = target
+            .store
+            .list(Some(&target.path))
+            .map_ok(|m| m.location)
+            .boxed();
+        target
+            .store
+            .delete_stream(locations)
+            .try_collect::<Vec<_>>()
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_stream(&self, paths: BoxStream<'static, String>) -> 
Result<()> {
+        // Collect and group by bucket so each store gets a single bulk 
DeleteObjects call.
+        let all_paths: Vec<String> = paths.collect().await;
+        let mut grouped: std::collections::HashMap<String, 
Vec<ObjectStorePath>> =
+            std::collections::HashMap::new();
+        let mut stores: std::collections::HashMap<String, Arc<dyn 
ObjectStore>> =
+            std::collections::HashMap::new();
+
+        for path in all_paths {
+            let target = self.get_store_and_path(&path)?;
+            let bucket = match self {
+                #[cfg(feature = "object_store-s3")]
+                ObjectStoreStorage::S3(_) => {
+                    let parsed = parse_s3_url(&path)?;
+                    parsed.bucket
+                }
+            };
+            stores.entry(bucket.clone()).or_insert(target.store);
+            grouped.entry(bucket).or_default().push(target.path);
+        }
+
+        for (bucket, locations) in grouped {
+            let store = stores.remove(&bucket).expect("store must exist");
+            let location_stream = 
futures::stream::iter(locations.into_iter().map(Ok)).boxed();
+            store
+                .delete_stream(location_stream)
+                .try_collect::<Vec<_>>()
+                .await
+                .map_err(from_object_store_error)?;
+        }
+        Ok(())
+    }
+
+    fn new_input(&self, path: &str) -> Result<InputFile> {
+        Ok(InputFile::new(Arc::new(self.clone()), path.to_string()))
+    }
+
+    fn new_output(&self, path: &str) -> Result<OutputFile> {
+        Ok(OutputFile::new(Arc::new(self.clone()), path.to_string()))
+    }
+}
+
+/// Reader that implements `FileRead` using `object_store`.
+struct ObjectStoreReader {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+#[async_trait]
+impl FileRead for ObjectStoreReader {
+    async fn read(&self, range: Range<u64>) -> Result<Bytes> {
+        let opts = object_store::GetOptions {
+            range: Some((range.start..range.end).into()),
+            ..Default::default()
+        };
+        let result = self
+            .store
+            .get_opts(&self.path, opts)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+}
+
+/// Writer that implements `FileWrite` using `object_store` multipart upload.
+struct ObjectStoreWriter {
+    writer: Option<WriteMultipart>,
+}
+
+impl Drop for ObjectStoreWriter {
+    fn drop(&mut self) {
+        if let Some(writer) = self.writer.take() {
+            if let Ok(handle) = tokio::runtime::Handle::try_current() {
+                handle.spawn(async move {
+                    let _ = writer.abort().await;
+                });
+            } else {
+                tracing::warn!(
+                    "ObjectStoreWriter dropped outside a Tokio runtime; 
multipart upload abort skipped"
+                );
+            }
+        }
+    }
+}
+
+#[async_trait]
+impl FileWrite for ObjectStoreWriter {
+    async fn write(&mut self, bs: Bytes) -> Result<()> {
+        let writer = self
+            .writer
+            .as_mut()
+            .ok_or_else(|| Error::new(ErrorKind::Unexpected, "Writer has 
already been closed"))?;
+        writer.put(bs);
+        Ok(())
+    }
+
+    async fn close(&mut self) -> Result<()> {
+        let writer = self
+            .writer
+            .take()
+            .ok_or_else(|| Error::new(ErrorKind::Unexpected, "Writer has 
already been closed"))?;

Review Comment:
   Same leak I flagged last round, still open. `take()` moves the writer out 
before `finish()`, so when `finish()` fails the `WriteMultipart` is already 
consumed and `self.writer` is `None` — `Drop` then sees nothing and the 
in-progress upload is never aborted, which is the exact case `Drop` is here for.
   
   Holding the raw `MultipartUpload` directly (both `complete()` and `abort()` 
take `&mut self`) lets you `abort()` after a failed `complete()`. Once a failed 
finish actually aborts, I'm happy here.



##########
crates/storage/object_store/tests/file_io_s3_test.rs:
##########
@@ -0,0 +1,547 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Integration tests for FileIO S3 using object_store backend.
+//!
+//! These tests assume Docker containers are started externally via `make 
docker-up`.
+//! Each test uses unique file paths based on module path to avoid conflicts.
+
+#[cfg(feature = "object_store-s3")]
+mod tests {
+    use std::sync::Arc;
+
+    use bytes::Bytes;
+    use futures::StreamExt;
+    use iceberg::io::{
+        FileIO, FileIOBuilder, S3_ACCESS_KEY_ID, S3_ENDPOINT, 
S3_PATH_STYLE_ACCESS, S3_REGION,
+        S3_SECRET_ACCESS_KEY, S3_SSE_KEY, S3_SSE_TYPE,
+    };
+    use iceberg_storage_object_store::ObjectStoreStorageFactory;
+    use iceberg_test_utils::{get_minio_endpoint, 
normalize_test_name_with_parts, set_up};
+
+    async fn get_file_io() -> FileIO {
+        set_up();
+
+        let minio_endpoint = get_minio_endpoint();
+
+        FileIOBuilder::new(Arc::new(ObjectStoreStorageFactory::S3))
+            .with_props(vec![
+                (S3_ENDPOINT, minio_endpoint),
+                (S3_ACCESS_KEY_ID, "admin".to_string()),
+                (S3_SECRET_ACCESS_KEY, "password".to_string()),
+                (S3_REGION, "us-east-1".to_string()),
+                (S3_PATH_STYLE_ACCESS, "true".to_string()),
+            ])
+            .build()
+    }
+
+    fn roundtrip_file_io(file_io: &FileIO) -> FileIO {
+        let serialized = file_io.serialize_all().unwrap();
+        FileIO::deserialize_all(&serialized).unwrap()
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_serialization_roundtrip() {
+        let file_io = roundtrip_file_io(&get_file_io().await);
+        let path = format!(
+            "s3://bucket1/{}",
+            
normalize_test_name_with_parts!("test_file_io_s3_serialization_roundtrip")
+        );
+
+        let _ = file_io.delete(&path).await;
+        file_io
+            .new_output(&path)
+            .unwrap()
+            .write(Bytes::from_static(b"roundtrip"))
+            .await
+            .unwrap();
+        assert_eq!(
+            file_io.new_input(&path).unwrap().read().await.unwrap(),
+            Bytes::from_static(b"roundtrip")
+        );
+        file_io.delete(&path).await.unwrap();
+        assert!(!file_io.exists(&path).await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_exists() {
+        let file_io = get_file_io().await;
+        assert!(!file_io.exists("s3://bucket2/any").await.unwrap());
+        assert!(file_io.exists("s3://bucket1/").await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_output() {
+        let file_io = get_file_io().await;
+        let output_path = format!(
+            "s3://bucket1/{}",
+            normalize_test_name_with_parts!("test_file_io_s3_output")
+        );
+        let _ = file_io.delete(&output_path).await;
+        assert!(!file_io.exists(&output_path).await.unwrap());
+        let output_file = file_io.new_output(&output_path).unwrap();
+        {
+            output_file.write("123".into()).await.unwrap();
+        }
+        assert!(file_io.exists(&output_path).await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_input() {
+        let file_io = get_file_io().await;
+        let file_path = format!(
+            "s3://bucket1/{}",
+            normalize_test_name_with_parts!("test_file_io_s3_input")
+        );
+        let output_file = file_io.new_output(&file_path).unwrap();
+        {
+            output_file.write("test_input".into()).await.unwrap();
+        }
+
+        let input_file = file_io.new_input(&file_path).unwrap();
+        {
+            let buffer = input_file.read().await.unwrap();
+            assert_eq!(buffer, "test_input".as_bytes());
+        }
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_delete_stream() {
+        let file_io = get_file_io().await;
+
+        let paths: Vec<String> = (0..5)
+            .map(|i| {
+                format!(
+                    "s3://bucket1/{}/file-{i}",
+                    
normalize_test_name_with_parts!("test_file_io_s3_delete_stream")
+                )
+            })
+            .collect();
+        for path in &paths {
+            let _ = file_io.delete(path).await;
+            file_io
+                .new_output(path)
+                .unwrap()
+                .write("delete-me".into())
+                .await
+                .unwrap();
+            assert!(file_io.exists(path).await.unwrap());
+        }
+
+        let stream = futures::stream::iter(paths.clone()).boxed();
+        file_io.delete_stream(stream).await.unwrap();
+
+        for path in &paths {
+            assert!(!file_io.exists(path).await.unwrap());
+        }
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_delete_stream_empty() {
+        let file_io = get_file_io().await;
+        let stream = futures::stream::empty().boxed();
+        file_io.delete_stream(stream).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_delete_stream_invalid_url() {
+        let file_io = get_file_io().await;
+        let stream = 
futures::stream::iter(vec!["invalid-url".to_string()]).boxed();
+        let res = file_io.delete_stream(stream).await;
+        assert!(res.is_err());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_multipart_writer() {
+        let file_io = get_file_io().await;
+        let file_path = format!(
+            "s3://bucket1/{}",
+            normalize_test_name_with_parts!("test_file_io_s3_multipart_writer")
+        );
+        let _ = file_io.delete(&file_path).await;
+
+        let output_file = file_io.new_output(&file_path).unwrap();
+        let mut writer = output_file.writer().await.unwrap();
+
+        let chunk1 = Bytes::from_static(b"hello ");
+        let chunk2 = Bytes::from_static(b"multipart ");
+        let chunk3 = Bytes::from_static(b"world!");
+
+        writer.write(chunk1).await.unwrap();
+        writer.write(chunk2).await.unwrap();
+        writer.write(chunk3).await.unwrap();
+        writer.close().await.unwrap();
+
+        assert!(file_io.exists(&file_path).await.unwrap());
+        let input_file = file_io.new_input(&file_path).unwrap();
+        let content = input_file.read().await.unwrap();
+        assert_eq!(content, Bytes::from_static(b"hello multipart world!"));
+
+        file_io.delete(&file_path).await.unwrap();
+        assert!(!file_io.exists(&file_path).await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_multipart_writer_drop_aborts() {
+        let file_io = get_file_io().await;
+        let file_path = format!(
+            "s3://bucket1/{}",
+            
normalize_test_name_with_parts!("test_file_io_s3_multipart_writer_drop_aborts")
+        );
+        let _ = file_io.delete(&file_path).await;
+
+        let output_file = file_io.new_output(&file_path).unwrap();
+        let mut writer = output_file.writer().await.unwrap();
+        writer
+            .write(Bytes::from_static(b"uncommitted chunk"))
+            .await
+            .unwrap();
+
+        // Dropping writer without close() should abort the multipart upload
+        drop(writer);
+
+        // Give background abort task a moment to execute
+        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
+
+        assert!(!file_io.exists(&file_path).await.unwrap());

Review Comment:
   This assertion passes whether or not the abort ran — an in-progress 
multipart upload isn't visible to `HeadObject`, so `!exists()` is true even if 
the parts are still sitting there. So it can't catch a regression that removes 
the `Drop` abort, which is the one thing it's meant to guard.
   
   Listing incomplete multipart uploads and asserting none carry the test 
prefix would actually prove it; if that's awkward against MinIO, a comment 
noting the limitation plus a TODO is fine. wdyt?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to