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


##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,911 @@
+// 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, BytesMut};
+use dashmap::DashMap;
+use futures::stream::{BoxStream, FuturesUnordered};
+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::{MultipartUpload, ObjectStore, ObjectStoreExt, PutPayload, 
UploadPart};
+#[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::Unexpected, format!("Object not found: {path}"))
+        }
+        object_store::Error::AlreadyExists { path, .. } => (
+            ErrorKind::Unexpected,
+            format!("Object already exists: {path}"),
+        ),
+        object_store::Error::PermissionDenied { path, .. } => {
+            (ErrorKind::Unexpected, format!("Permission denied: {path}"))
+        }
+        object_store::Error::Unauthenticated { path, .. } => {
+            (ErrorKind::Unexpected, 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)
+}
+
+/// Property key for configuring S3 bulk delete batch size.
+pub const S3_DELETE_BATCH_SIZE: &str = "s3.delete-batch-size";

Review Comment:
   I'd either drop this knob or realign it before publish — as written it can't 
do what the docs claim. `object_store`'s `AmazonS3::delete_stream` already 
`try_chunks(1_000)` internally with no caller override, so chunking the stream 
again here only adds round trips below 1000 and can't cause `MalformedXML` 
above it, which makes the warning misleading. Separately the key is 
`s3.delete-batch-size`, but Java uses `s3.delete.batch-size` (dotted, default 
250) — an operator porting a properties file would have it silently ignored, 
the same silent config-drop we closed in round 1. Simplest is to drop it and 
lean on object_store's built-in 1000-batching; otherwise rename to match Java, 
default to 250, and clamp to the max instead of warn-and-pass.



##########
crates/storage/object_store/Cargo.toml:
##########
@@ -0,0 +1,54 @@
+# 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.
+
+[package]
+edition = { workspace = true }
+license = { workspace = true }
+name = "iceberg-storage-object_store"
+publish = false

Review Comment:
   Heads up that this `public-api.txt` snapshot isn't actually being enforced: 
`make check-public-api` selects crates via `jq 'select(.publish == null)'`, but 
`publish = false` reports as `Some([])`, not null, so this crate is skipped and 
a later PR could widen the surface with nothing catching it. The checked-in 
snapshot is also already missing the three crate-root consts 
(`S3_DELETE_BATCH_SIZE`, `DEFAULT_DELETE_BATCH_SIZE`, 
`S3_MAX_DELETE_BATCH_SIZE`), which are externally reachable. Either drop 
`public-api.txt` until publish flips on, or fix the Makefile filter so it's 
checked now and regenerate it — worth settling before publish since the whole 
stack builds on this surface.



##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,911 @@
+// 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, BytesMut};
+use dashmap::DashMap;
+use futures::stream::{BoxStream, FuturesUnordered};
+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::{MultipartUpload, ObjectStore, ObjectStoreExt, PutPayload, 
UploadPart};
+#[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::Unexpected, format!("Object not found: {path}"))
+        }
+        object_store::Error::AlreadyExists { path, .. } => (
+            ErrorKind::Unexpected,
+            format!("Object already exists: {path}"),
+        ),
+        object_store::Error::PermissionDenied { path, .. } => {
+            (ErrorKind::Unexpected, format!("Permission denied: {path}"))
+        }
+        object_store::Error::Unauthenticated { path, .. } => {
+            (ErrorKind::Unexpected, 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)
+}
+
+/// Property key for configuring S3 bulk delete batch size.
+pub const S3_DELETE_BATCH_SIZE: &str = "s3.delete-batch-size";
+/// Default batch size for S3 bulk deletions (matches AWS S3 DeleteObjects 
max).
+pub const DEFAULT_DELETE_BATCH_SIZE: usize = 1000;
+/// Maximum batch size allowed by the AWS S3 DeleteObjects API specification.
+pub const S3_MAX_DELETE_BATCH_SIZE: usize = 1000;
+
+fn parse_delete_batch_size(config: &StorageConfig) -> usize {
+    if let Some(val) = config.get(S3_DELETE_BATCH_SIZE) {
+        match val.parse::<usize>() {
+            Ok(parsed) if parsed > 0 => {
+                if parsed > S3_MAX_DELETE_BATCH_SIZE {
+                    tracing::warn!(
+                        configured = parsed,
+                        limit = S3_MAX_DELETE_BATCH_SIZE,
+                        "Configured s3.delete-batch-size exceeds AWS S3 hard 
limit of 1000; requests may fail with MalformedXML"
+                    );
+                }
+                parsed
+            }
+            _ => {
+                tracing::warn!(
+                    val = %val,
+                    "Invalid s3.delete-batch-size; falling back to default 
1000"
+                );
+                DEFAULT_DELETE_BATCH_SIZE
+            }
+        }
+    } else {
+        DEFAULT_DELETE_BATCH_SIZE
+    }
+}
+
+fn default_delete_batch_size() -> usize {
+    DEFAULT_DELETE_BATCH_SIZE
+}
+
+/// 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)?;
+                let delete_batch_size = parse_delete_batch_size(config);
+                tracing::info!(
+                    batch_size = delete_batch_size,
+                    "Initialized S3 storage with delete batch size {} 
(configure via '{}' to adjust)",
+                    delete_batch_size,
+                    S3_DELETE_BATCH_SIZE
+                );
+                Ok(Arc::new(ObjectStoreStorage::S3(S3Storage {
+                    config: Arc::new(s3_config),
+                    delete_batch_size,
+                    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(default = "default_delete_batch_size")]
+    pub delete_batch_size: usize,
+    #[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 {
+    bucket: String,
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+/// Helper for batching deletions per bucket.
+struct BucketBatch {
+    store: Arc<dyn ObjectStore>,
+    locations: Vec<ObjectStorePath>,
+}
+
+impl ObjectStoreStorage {
+    fn delete_batch_size(&self) -> usize {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorage::S3(s3) => s3.delete_batch_size,
+        }
+    }
+
+    /// 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 {
+                    bucket: parsed.bucket,
+                    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)?;
+        Ok(Box::new(ObjectStoreWriter {
+            upload: Some(upload),
+            buffer: BytesMut::new(),
+            tasks: FuturesUnordered::new(),
+            parts_submitted: 0,
+            bytes_written: 0,
+        }))
+    }
+
+    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_for_each(|_| async { Ok(()) })
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_stream(&self, paths: BoxStream<'static, String>) -> 
Result<()> {
+        let batch_size = self.delete_batch_size();
+        let mut chunk_stream = paths.chunks(batch_size);
+
+        while let Some(chunk) = chunk_stream.next().await {
+            let mut batches: std::collections::HashMap<String, BucketBatch> =
+                std::collections::HashMap::new();
+
+            for path in chunk {
+                let target = self.get_store_and_path(&path)?;
+                batches
+                    .entry(target.bucket)
+                    .or_insert_with(|| BucketBatch {
+                        store: target.store,
+                        locations: Vec::new(),
+                    })
+                    .locations
+                    .push(target.path);
+            }
+
+            for (_bucket, batch) in batches {
+                let location_stream =
+                    
futures::stream::iter(batch.locations.into_iter().map(Ok)).boxed();
+                batch
+                    .store
+                    .delete_stream(location_stream)
+                    .try_for_each(|_| async { Ok(()) })
+                    .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> {
+        if range.is_empty() {
+            return Ok(Bytes::new());
+        }
+        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)
+    }
+}
+
+/// Minimum part size for S3 multipart upload (5 MiB).
+const MIN_PART_SIZE: usize = 5 * 1024 * 1024;
+
+/// Default maximum concurrent in-flight part uploads.
+const MAX_CONCURRENT_PART_UPLOADS: usize = 8;
+
+/// Writer that implements `FileWrite` using `object_store` multipart upload.
+struct ObjectStoreWriter {
+    upload: Option<Box<dyn MultipartUpload>>,
+    buffer: BytesMut,
+    tasks: FuturesUnordered<UploadPart>,
+    parts_submitted: usize,
+    bytes_written: u64,
+}
+
+impl ObjectStoreWriter {
+    /// Flushes any buffered bytes as an in-flight part upload to S3.
+    fn flush_buffer(
+        buffer: &mut BytesMut,
+        tasks: &mut FuturesUnordered<UploadPart>,
+        parts_submitted: &mut usize,
+        upload: &mut Box<dyn MultipartUpload>,
+    ) {
+        if !buffer.is_empty() {
+            let part_data = std::mem::take(buffer).freeze();
+            let part_fut = upload.put_part(PutPayload::from_bytes(part_data));
+            tasks.push(part_fut);
+            *parts_submitted += 1;
+        }
+    }
+
+    /// Accumulates bytes into `buffer`, flushing 5 MiB parts when full.
+    fn append_bytes(
+        buffer: &mut BytesMut,
+        tasks: &mut FuturesUnordered<UploadPart>,
+        parts_submitted: &mut usize,
+        mut bs: Bytes,
+        upload: &mut Box<dyn MultipartUpload>,
+    ) {
+        while !bs.is_empty() {
+            let remaining = MIN_PART_SIZE.saturating_sub(buffer.len());
+            if remaining == 0 {
+                Self::flush_buffer(buffer, tasks, parts_submitted, upload);
+                continue;
+            }
+
+            if bs.len() < remaining {
+                buffer.extend_from_slice(&bs);
+                return;
+            }
+            let chunk = bs.split_to(remaining);
+            buffer.extend_from_slice(&chunk);
+            Self::flush_buffer(buffer, tasks, parts_submitted, upload);
+        }
+    }
+}
+
+impl Drop for ObjectStoreWriter {
+    fn drop(&mut self) {
+        if let Some(mut upload) = self.upload.take() {
+            if let Ok(handle) = tokio::runtime::Handle::try_current() {
+                handle.spawn(async move {
+                    if let Err(e) = upload.abort().await {
+                        tracing::warn!(
+                            error = %e,
+                            "Failed to abort multipart upload on drop"
+                        );
+                    }
+                });
+            } 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 upload = self.upload.as_mut().ok_or_else(|| {
+            Error::new(
+                ErrorKind::PreconditionFailed,
+                "Writer has already been closed",
+            )
+        })?;
+        self.bytes_written += bs.len() as u64;
+        Self::append_bytes(
+            &mut self.buffer,
+            &mut self.tasks,
+            &mut self.parts_submitted,
+            bs,
+            upload,
+        );
+
+        // Throttle in-flight uploads: fail fast if any part upload fails
+        while self.tasks.len() >= MAX_CONCURRENT_PART_UPLOADS {

Review Comment:
   I'd move this throttle check into the chunking loop 
(`append_bytes`/`flush_buffer`) rather than running it after — as written it 
doesn't bound concurrency for a single large `write()`. `append_bytes` pushes 
every 5 MiB part onto `tasks` before we reach this loop, and the first 
`.next().await` polls all queued futures at once, so a writer flushing a 40 
MiB+ row group fires them all concurrently — only the depth *between* separate 
`write()` calls is actually capped. That defeats the documented cap and can 
trip S3 SlowDown/503 under realistic large writes. Draining down to the limit 
as each part is queued fixes it.



##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,911 @@
+// 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, BytesMut};
+use dashmap::DashMap;
+use futures::stream::{BoxStream, FuturesUnordered};
+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::{MultipartUpload, ObjectStore, ObjectStoreExt, PutPayload, 
UploadPart};
+#[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::Unexpected, format!("Object not found: {path}"))
+        }
+        object_store::Error::AlreadyExists { path, .. } => (
+            ErrorKind::Unexpected,
+            format!("Object already exists: {path}"),
+        ),
+        object_store::Error::PermissionDenied { path, .. } => {
+            (ErrorKind::Unexpected, format!("Permission denied: {path}"))
+        }
+        object_store::Error::Unauthenticated { path, .. } => {
+            (ErrorKind::Unexpected, 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)
+}
+
+/// Property key for configuring S3 bulk delete batch size.
+pub const S3_DELETE_BATCH_SIZE: &str = "s3.delete-batch-size";
+/// Default batch size for S3 bulk deletions (matches AWS S3 DeleteObjects 
max).
+pub const DEFAULT_DELETE_BATCH_SIZE: usize = 1000;
+/// Maximum batch size allowed by the AWS S3 DeleteObjects API specification.
+pub const S3_MAX_DELETE_BATCH_SIZE: usize = 1000;
+
+fn parse_delete_batch_size(config: &StorageConfig) -> usize {
+    if let Some(val) = config.get(S3_DELETE_BATCH_SIZE) {
+        match val.parse::<usize>() {
+            Ok(parsed) if parsed > 0 => {
+                if parsed > S3_MAX_DELETE_BATCH_SIZE {
+                    tracing::warn!(
+                        configured = parsed,
+                        limit = S3_MAX_DELETE_BATCH_SIZE,
+                        "Configured s3.delete-batch-size exceeds AWS S3 hard 
limit of 1000; requests may fail with MalformedXML"
+                    );
+                }
+                parsed
+            }
+            _ => {
+                tracing::warn!(
+                    val = %val,
+                    "Invalid s3.delete-batch-size; falling back to default 
1000"
+                );
+                DEFAULT_DELETE_BATCH_SIZE
+            }
+        }
+    } else {
+        DEFAULT_DELETE_BATCH_SIZE
+    }
+}
+
+fn default_delete_batch_size() -> usize {
+    DEFAULT_DELETE_BATCH_SIZE
+}
+
+/// 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)?;
+                let delete_batch_size = parse_delete_batch_size(config);
+                tracing::info!(
+                    batch_size = delete_batch_size,
+                    "Initialized S3 storage with delete batch size {} 
(configure via '{}' to adjust)",
+                    delete_batch_size,
+                    S3_DELETE_BATCH_SIZE
+                );
+                Ok(Arc::new(ObjectStoreStorage::S3(S3Storage {
+                    config: Arc::new(s3_config),
+                    delete_batch_size,
+                    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(default = "default_delete_batch_size")]
+    pub delete_batch_size: usize,

Review Comment:
   I'd drop `pub` here — it re-opens the encapsulation we closed in round 1. 
`S3Storage` is reachable via the public `ObjectStoreStorage::S3(..)` variant, 
so an external crate can pattern-match and set `delete_batch_size` to 0 or 
`usize::MAX`, bypassing `parse_delete_batch_size`'s validation. Tests construct 
it from within the crate so they don't need it public; add `pub fn 
delete_batch_size(&self)` if outside read access is wanted.



##########
crates/storage/object_store/tests/file_io_s3_test.rs:
##########
@@ -0,0 +1,522 @@
+// 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_percent_encoded_bucket() {
+        let file_io = get_file_io().await;
+        let file_path = format!(
+            "s3://bucket%31/{}",
+            
normalize_test_name_with_parts!("test_file_io_s3_percent_encoded_bucket")
+        );
+        let canonical_path = format!(
+            "s3://bucket1/{}",
+            
normalize_test_name_with_parts!("test_file_io_s3_percent_encoded_bucket")
+        );
+
+        let _ = file_io.delete(&file_path).await;
+        file_io
+            .new_output(&file_path)
+            .unwrap()
+            .write(Bytes::from_static(b"encoded-bucket-content"))
+            .await
+            .unwrap();
+
+        assert!(file_io.exists(&file_path).await.unwrap());
+        assert!(file_io.exists(&canonical_path).await.unwrap());
+
+        let content = file_io
+            .new_input(&canonical_path)
+            .unwrap()
+            .read()
+            .await
+            .unwrap();
+        assert_eq!(content, Bytes::from_static(b"encoded-bucket-content"));
+
+        file_io.delete(&canonical_path).await.unwrap();
+        assert!(!file_io.exists(&file_path).await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_sse_kms_default() {
+        set_up();
+        let endpoint = get_minio_endpoint();
+
+        let file_io = 
FileIOBuilder::new(Arc::new(ObjectStoreStorageFactory::S3))
+            .with_props(vec![
+                (S3_ENDPOINT, 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()),
+                (S3_SSE_TYPE, "kms".to_string()),
+            ])
+            .build();
+
+        let file_path = format!(
+            "s3://bucket1/{}",
+            normalize_test_name_with_parts!("test_file_io_s3_sse_kms_default")
+        );
+
+        let _ = file_io.delete(&file_path).await;
+        match file_io
+            .new_output(&file_path)
+            .unwrap()
+            .write(Bytes::from_static(b"kms-encrypted-data"))
+            .await
+        {
+            Ok(_) => {

Review Comment:
   This `Ok(_)` branch accepts a plain unencrypted PUT as success — MinIO 
without KES happily takes one with no SSE header — so if `configure_sse` ever 
silently stopped attaching the header (the exact regression round 1 was about), 
all three SSE tests stay green. Same false-positive shape we just fixed for the 
drop-abort test. I'd either assert the specific error against KES-less MinIO 
instead of accepting `Ok(_)`, or add a lower-level check on the outgoing 
`x-amz-server-side-encryption*` headers (wiremock / mock HTTP).



-- 
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