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


##########
crates/storage/opendal/src/lib.rs:
##########
@@ -543,7 +567,7 @@ impl Storage for OpenDalStorage {
 
     async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
         let (op, relative_path) = self.create_operator(&path)?;
-        op.write(relative_path, bs)
+        op.write_options(relative_path, bs, self.write_options())

Review Comment:
   I think there's a subtle issue here: `write_options()` sets `chunk`, but per 
the OpenDAL docs `chunk` only takes effect on the streaming `writer_options` 
path — `write_options()` is a single bulk write and won't split a complete 
`Bytes` buffer into `UploadPart` calls no matter what `chunk` says.
   
   If that's right, this line is misleading: the metadata writes that go 
through `write()` (manifests, snapshot JSON, table metadata) silently ignore 
the option, so we've only actually fixed the streaming `writer()` path. If it's 
*not* right and OpenDAL does chunk here, then every small metadata write now 
takes the 3x multipart round-trip (Create/Upload/Complete), which is a real 
regression on commit-heavy workloads.
   
   Could we confirm which it is, and then either drop `write_options()` from 
`write()` if it's a no-op, or add a test through `OutputFile::write()` with a 
small part size asserting the part count? The existing test only covers 
`writer()`. wdyt?



##########
crates/storage/opendal/src/lib.rs:
##########
@@ -233,6 +235,9 @@ pub enum OpenDalStorage {
     S3 {
         /// S3 configuration.
         config: Arc<S3Config>,
+        /// Bytes carried by one multipart upload request.
+        #[serde(default = "default_multipart_part_size")]
+        multipart_part_size: u64,

Review Comment:
   Adding `multipart_part_size` as a required public field to the `S3` variant 
is a source-breaking change — any external crate constructing 
`OpenDalStorage::S3 { config, customized_credential_load }` with an explicit 
field list stops compiling.
   
   The crate is 0.x and you've already regenerated `public-api.txt`, so this is 
permitted, and `customized_credential_load` set a similar precedent. But since 
users configure this through the `s3.multipart.part-size-bytes` property (same 
as everything in `config`), I'd lean toward making the field `pub(crate)` and 
dropping it from `public-api.txt` — that keeps the constructor surface stable 
and matches how the rest of the config flows in.
   
   If we'd rather keep it public, `#[non_exhaustive]` on the enum or a 
changelog note calling out the break would at least make it intentional. wdyt?



##########
crates/storage/opendal/src/s3.rs:
##########
@@ -36,6 +36,36 @@ use url::Url;
 
 use crate::utils::{from_opendal_error, is_truthy};
 
+/// S3 rejects a non-final part smaller than this.
+const MULTIPART_PART_SIZE_MIN: u64 = 5 * 1024 * 1024;
+
+/// Matches Java `S3FileIOProperties.MULTIPART_SIZE_DEFAULT`.
+pub(crate) fn default_multipart_part_size() -> u64 {
+    32 * 1024 * 1024

Review Comment:
   This default is a bare literal here and repeated in the test 
(`assert_eq!(parse_part_size(None).unwrap(), 32 * 1024 * 1024)`), so they can 
drift apart. I'd pull it into a `const DEFAULT_MULTIPART_PART_SIZE: u64 = 32 * 
1024 * 1024;` next to `MULTIPART_PART_SIZE_MIN`, have the fn return it, and 
assert against the const.



##########
crates/storage/opendal/src/s3.rs:
##########
@@ -36,6 +36,36 @@ use url::Url;
 
 use crate::utils::{from_opendal_error, is_truthy};
 
+/// S3 rejects a non-final part smaller than this.
+const MULTIPART_PART_SIZE_MIN: u64 = 5 * 1024 * 1024;
+
+/// Matches Java `S3FileIOProperties.MULTIPART_SIZE_DEFAULT`.
+pub(crate) fn default_multipart_part_size() -> u64 {
+    32 * 1024 * 1024
+}
+
+/// Parse iceberg props to s3 multipart upload part size.
+pub(crate) fn s3_multipart_part_size_parse(m: &HashMap<String, String>) -> 
Result<u64> {
+    let Some(value) = m.get(S3_MULTIPART_PART_SIZE_BYTES) else {
+        return Ok(default_multipart_part_size());
+    };
+    let part_size = value.parse::<u64>().map_err(|e| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!("Invalid {S3_MULTIPART_PART_SIZE_BYTES}: {value}: {e}"),
+        )
+    })?;
+    if part_size < MULTIPART_PART_SIZE_MIN {

Review Comment:
   We validate the floor here but not the ceiling — S3 caps a part at 5 GiB, so 
something like `6_000_000_000` passes this check and then fails with an opaque 
S3 `InvalidArgument` mid-upload instead of a clean `DataInvalid`.
   
   Adding a mirror check closes that, and it also makes the cast in 
`write_options` (`usize::try_from(*multipart_part_size).unwrap_or(usize::MAX)`, 
lib.rs:415) infallible — right now on a 32-bit target a value above ~4 GiB 
silently clamps *up* to `usize::MAX` rather than erroring.
   
   ```rust
   const MULTIPART_PART_SIZE_MAX: u64 = 5 * 1024 * 1024 * 1024;
   ```
   
   Then the `try_from` can become `.expect("validated <= 5 GiB")`. Fix that and 
this is good to land.



##########
crates/storage/opendal/tests/file_io_s3_test.rs:
##########
@@ -26,33 +26,62 @@ mod tests {
     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,
+        FileIO, FileIOBuilder, S3_ACCESS_KEY_ID, S3_ENDPOINT, 
S3_MULTIPART_PART_SIZE_BYTES,
+        S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_ACCESS_KEY,
     };
     use iceberg_storage_opendal::{
         AwsCredential, CustomAwsCredentialLoader, OpenDalStorageFactory, 
ProvideCredential,
     };
     use iceberg_test_utils::{get_minio_endpoint, 
normalize_test_name_with_parts, set_up};
+    use opendal::Configurator;
     use reqsign_core::Context;
 
     async fn get_file_io() -> FileIO {
+        get_file_io_with_props(vec![]).await
+    }
+
+    async fn get_file_io_with_props(extra: Vec<(&'static str, String)>) -> 
FileIO {
         set_up();
 
         let minio_endpoint = get_minio_endpoint();
 
-        FileIOBuilder::new(Arc::new(OpenDalStorageFactory::S3 {
-            customized_credential_load: None,
-        }))
-        .with_props(vec![
+        let mut 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()),
-        ])
+        ];
+        props.extend(extra);
+
+        FileIOBuilder::new(Arc::new(OpenDalStorageFactory::S3 {
+            customized_credential_load: None,
+        }))
+        .with_props(props)
         .build()
     }
 
+    /// Number of upload parts S3 recorded for `key`. A multipart upload 
reports
+    /// a `<md5>-<part-count>` ETag; a single-request upload reports a bare 
MD5.
+    async fn upload_part_count(key: &str) -> usize {
+        let mut config = opendal::services::S3Config::default();
+        config.endpoint = Some(get_minio_endpoint());
+        config.access_key_id = Some("admin".to_string());
+        config.secret_access_key = Some("password".to_string());
+        config.region = Some("us-east-1".to_string());
+        config.bucket = "bucket1".to_string();
+        let op = opendal::Operator::new(config.into_builder()).unwrap();
+        let meta = op.stat(key).await.unwrap();
+        let etag = meta
+            .etag()
+            .expect("MinIO reports an ETag")
+            .trim_matches('"');
+        match etag.rsplit_once('-') {

Review Comment:
   The `<md5>-<N>` ETag suffix is a MinIO/plain-S3 detail — with SSE-KMS/C the 
suffix can be absent, and then this `None` arm silently returns 1, so a broken 
multipart upload would read as a passing single-part assertion rather than 
failing loudly. And `parts.parse().unwrap()` panics with no context if the 
segment isn't numeric.
   
   Since the test only runs against MinIO today it's fine in practice, but I'd 
at least comment the MinIO assumption and `expect` on the parse so a surprise 
format fails with a readable message. Not blocking.



##########
crates/storage/opendal/src/s3.rs:
##########
@@ -184,9 +214,9 @@ impl CustomAwsCredentialLoader {
 mod tests {
     use std::collections::HashMap;
 
-    use iceberg::io::S3_PATH_STYLE_ACCESS;
+    use iceberg::io::{S3_MULTIPART_PART_SIZE_BYTES, S3_PATH_STYLE_ACCESS};
 
-    use super::s3_config_parse;
+    use super::*;

Review Comment:
   Small one — swapping the explicit import for `use super::*;` pulls every 
`pub(crate)` symbol into the test module. I'd keep it explicit (`use 
super::{default_multipart_part_size, s3_config_parse, 
s3_multipart_part_size_parse};`) so the test's dependency surface stays visible.



##########
crates/storage/opendal/src/s3.rs:
##########
@@ -36,6 +36,36 @@ use url::Url;
 
 use crate::utils::{from_opendal_error, is_truthy};
 
+/// S3 rejects a non-final part smaller than this.
+const MULTIPART_PART_SIZE_MIN: u64 = 5 * 1024 * 1024;
+
+/// Matches Java `S3FileIOProperties.MULTIPART_SIZE_DEFAULT`.
+pub(crate) fn default_multipart_part_size() -> u64 {
+    32 * 1024 * 1024
+}
+
+/// Parse iceberg props to s3 multipart upload part size.
+pub(crate) fn s3_multipart_part_size_parse(m: &HashMap<String, String>) -> 
Result<u64> {
+    let Some(value) = m.get(S3_MULTIPART_PART_SIZE_BYTES) else {
+        return Ok(default_multipart_part_size());
+    };
+    let part_size = value.parse::<u64>().map_err(|e| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!("Invalid {S3_MULTIPART_PART_SIZE_BYTES}: {value}: {e}"),
+        )
+    })?;
+    if part_size < MULTIPART_PART_SIZE_MIN {
+        return Err(Error::new(
+            ErrorKind::DataInvalid,
+            format!(
+                "Invalid {S3_MULTIPART_PART_SIZE_BYTES}: {part_size} is below 
the S3 minimum part size of {MULTIPART_PART_SIZE_MIN}"

Review Comment:
   Tiny thing: this prints raw bytes (`...minimum part size of 5242880`) with 
no unit, which is hard to read. Something like `{part_size} bytes is below the 
S3 minimum part size of {MULTIPART_PART_SIZE_MIN} (5 MiB)` reads a lot 
friendlier.



##########
crates/iceberg/src/io/storage/config/s3.rs:
##########
@@ -64,6 +64,9 @@ pub const S3_ALLOW_ANONYMOUS: &str = "s3.allow-anonymous";
 pub const S3_DISABLE_EC2_METADATA: &str = "s3.disable-ec2-metadata";
 /// Option to skip loading configuration from config file and the env.
 pub const S3_DISABLE_CONFIG_LOAD: &str = "s3.disable-config-load";
+/// Size in bytes of each part of a multipart upload. Must be at least 5 MiB.
+/// Defaults to 32 MiB, matching Java `S3FileIOProperties.MULTIPART_SIZE`.
+pub const S3_MULTIPART_PART_SIZE_BYTES: &str = "s3.multipart.part-size-bytes";

Review Comment:
   While we're here — the `TimeoutLayer` default is 10s per IO op, and that 
applies per `UploadPart`, so part size is really the knob for how much has to 
transfer inside that window. Since that interaction is the whole motivation for 
this property, I'd add a line to the doc comment noting it so a user on a slow 
link knows reducing the part size is the lever. Small thing.



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