Repository navigation
fix!(io): bound S3 write request size with a configurable part size #3179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ddb3da2
511fb72
4d35493
6239114
2b4dea0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,16 @@ 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. Defaults to 32 MiB, matching Java | ||
| /// `S3FileIOProperties.MULTIPART_SIZE_DEFAULT`. Must be at least 5 MiB, the S3 minimum, | ||
| /// and at most `i32::MAX`, since Java reads the property as an `int`. | ||
| /// | ||
| /// Each part travels as one request, so the part size bounds how much data has to | ||
| /// transfer within the per-operation IO timeout. Lower it on a slow connection; if a | ||
| /// 5 MiB part still cannot finish in time, raise `opendal.io-timeout-ms` instead. | ||
| /// | ||
| /// Only `iceberg-storage-opendal` reads this property, so [`S3Config`] has no matching field. | ||
| pub const S3_MULTIPART_PART_SIZE_BYTES: &str = "s3.multipart.part-size-bytes"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every other const in this file ( |
||
|
|
||
| /// Amazon S3 storage configuration. | ||
| /// | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ use iceberg::{Error, ErrorKind, Result}; | |
| use iceberg_property_macro::Properties; | ||
| use opendal::Operator; | ||
| use opendal::layers::{RetryLayer, TimeoutLayer}; | ||
| use opendal::options::WriteOptions; | ||
| use serde::{Deserialize, Serialize}; | ||
| use utils::from_opendal_error; | ||
|
|
||
|
|
@@ -228,6 +229,7 @@ impl StorageFactory for OpenDalStorageFactory { | |
| customized_credential_load, | ||
| } => Ok(Arc::new(OpenDalStorage::S3 { | ||
| config: s3_config_parse(config.props().clone())?.into(), | ||
| multipart_part_size: s3_multipart_part_size_parse(config.props())?, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is wrong. we don't need an extra field in the Storage, the config should be threaded via
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. S3Config here is opendal's own let me know if you had something else in mind
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for pointing this out! I think my concern still stands, we will always need to make breaking changes when adding a new config in the future. How about make the OpenDalStorage::S3 #[non_exhaustive]? |
||
| customized_credential_load: customized_credential_load.clone(), | ||
| client_config, | ||
| })), | ||
|
|
@@ -301,9 +303,13 @@ pub enum OpenDalStorage { | |
| /// Accepts any S3-family URL (`s3://`, `s3a://`, `s3n://`); the scheme is | ||
| /// derived from the path at call time. | ||
| #[cfg(feature = "opendal-s3")] | ||
| #[non_exhaustive] | ||
| S3 { | ||
| /// S3 configuration. | ||
| config: Arc<S3Config>, | ||
| /// Bytes carried by one multipart upload request. | ||
| #[serde(default = "default_multipart_part_size")] | ||
| multipart_part_size: u64, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding The crate is 0.x and you've already regenerated If we'd rather keep it public,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the pub-vs- If we do want it public, two things worth doing while we're here: mark the |
||
| /// Custom AWS credential loader. | ||
| #[serde(skip)] | ||
| customized_credential_load: Option<CustomAwsCredentialLoader>, | ||
|
|
@@ -513,6 +519,24 @@ impl OpenDalStorage { | |
| } | ||
| } | ||
|
|
||
| /// Bounds the S3 request size. Without a chunk size OpenDAL turns each caller | ||
| /// buffer into one request, and `ParquetWriter` hands over a whole row group | ||
| /// at a time. Other backends keep OpenDAL's defaults. | ||
| #[allow(unreachable_patterns)] | ||
| fn write_options(&self) -> WriteOptions { | ||
| match self { | ||
| #[cfg(feature = "opendal-s3")] | ||
| OpenDalStorage::S3 { | ||
| multipart_part_size, | ||
| .. | ||
| } => WriteOptions { | ||
| chunk: Some(usize::try_from(*multipart_part_size).unwrap_or(usize::MAX)), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small thing, but the comment stops just short of the actual behavior: on a 32-bit target
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parser pins this to [5 MiB, i32::MAX], but |
||
| ..WriteOptions::default() | ||
| }, | ||
| _ => WriteOptions::default(), | ||
| } | ||
| } | ||
|
|
||
| /// Returns a cache key used by `delete_stream` to group paths by storage operator. | ||
| /// | ||
| /// For most backends the URL host (bucket name) is sufficient. For HF the host | ||
|
|
@@ -662,7 +686,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()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there's a subtle issue here: If that's right, this line is misleading: the metadata writes that go through Could we confirm which it is, and then either drop
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checked the source: also added
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Last round I wasn't sure this path chunked at all and floated dropping it — One thing I want to be sure of: these assertions all lean on MinIO, so they only mean something if they actually run in CI rather than just locally. Can we confirm the S3 integration job runs on this PR and is green? Once that's confirmed I'm satisfied on this path. |
||
| .await | ||
| .map_err(from_opendal_error)?; | ||
| Ok(()) | ||
|
|
@@ -671,7 +695,9 @@ impl Storage for OpenDalStorage { | |
| async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> { | ||
| let (op, relative_path) = self.create_operator(&path)?; | ||
| Ok(Box::new(OpenDalWriter::new( | ||
| op.writer(relative_path).await.map_err(from_opendal_error)?, | ||
| op.writer_options(relative_path, self.write_options()) | ||
| .await | ||
| .map_err(from_opendal_error)?, | ||
| ))) | ||
| } | ||
|
|
||
|
|
@@ -866,6 +892,7 @@ mod tests { | |
| fn test_client_config_serde_round_trip() { | ||
| let storage = OpenDalStorage::S3 { | ||
| config: Arc::new(S3Config::default()), | ||
| multipart_part_size: default_multipart_part_size(), | ||
| customized_credential_load: None, | ||
| client_config: client_config("45000").unwrap(), | ||
| }; | ||
|
|
@@ -1080,6 +1107,7 @@ mod tests { | |
| fn test_relativize_path_s3() { | ||
| let storage = OpenDalStorage::S3 { | ||
| config: Arc::new(S3Config::default()), | ||
| multipart_part_size: default_multipart_part_size(), | ||
| customized_credential_load: None, | ||
| client_config: OpenDalClientConfig::default(), | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,8 +21,8 @@ use std::sync::Arc; | |
| use iceberg::io::{ | ||
| CLIENT_REGION, S3_ACCESS_KEY_ID, S3_ALLOW_ANONYMOUS, S3_ASSUME_ROLE_ARN, | ||
| S3_ASSUME_ROLE_EXTERNAL_ID, S3_ASSUME_ROLE_SESSION_NAME, S3_DISABLE_CONFIG_LOAD, | ||
| S3_DISABLE_EC2_METADATA, S3_ENDPOINT, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_ACCESS_KEY, | ||
| S3_SESSION_TOKEN, S3_SSE_KEY, S3_SSE_MD5, S3_SSE_TYPE, | ||
| S3_DISABLE_EC2_METADATA, S3_ENDPOINT, S3_MULTIPART_PART_SIZE_BYTES, S3_PATH_STYLE_ACCESS, | ||
| S3_REGION, S3_SECRET_ACCESS_KEY, S3_SESSION_TOKEN, S3_SSE_KEY, S3_SSE_MD5, S3_SSE_TYPE, | ||
| }; | ||
| use iceberg::{Error, ErrorKind, Result}; | ||
| use opendal::services::S3Config; | ||
|
|
@@ -36,6 +36,50 @@ 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; | ||
|
|
||
| /// Java reads this property as an `int`, so a larger value fails there. | ||
| const MULTIPART_PART_SIZE_MAX: u64 = i32::MAX as u64; | ||
|
|
||
| /// Matches Java `S3FileIOProperties.MULTIPART_SIZE_DEFAULT`. | ||
| const MULTIPART_PART_SIZE_DEFAULT: u64 = 32 * 1024 * 1024; | ||
|
|
||
| pub(crate) fn default_multipart_part_size() -> u64 { | ||
| MULTIPART_PART_SIZE_DEFAULT | ||
| } | ||
|
|
||
| /// 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We validate the floor here but not the ceiling — S3 caps a part at 5 GiB, so something like Adding a mirror check closes that, and it also makes the cast in const MULTIPART_PART_SIZE_MAX: u64 = 5 * 1024 * 1024 * 1024;Then the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added One correction: OpenDAL already clamps chunk to the service's
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On the java side, I think anything larger than 2GB will fail java's I'm ok if we don't validate ceiling for now since opendal does it already. but this information may be good to go in to the comment section where we add the new multipart configs |
||
| return Err(Error::new( | ||
| ErrorKind::DataInvalid, | ||
| format!( | ||
| "Invalid {S3_MULTIPART_PART_SIZE_BYTES}: {part_size} bytes is below the S3 minimum part size of {} MiB", | ||
| MULTIPART_PART_SIZE_MIN / (1024 * 1024) | ||
| ), | ||
| )); | ||
| } | ||
| if part_size > MULTIPART_PART_SIZE_MAX { | ||
| return Err(Error::new( | ||
| ErrorKind::DataInvalid, | ||
| format!( | ||
| "Invalid {S3_MULTIPART_PART_SIZE_BYTES}: {part_size} bytes is above {MULTIPART_PART_SIZE_MAX} bytes, the largest value Java accepts" | ||
| ), | ||
| )); | ||
| } | ||
| Ok(part_size) | ||
| } | ||
|
|
||
| /// Parse iceberg props to s3 config. | ||
| pub(crate) fn s3_config_parse(mut m: HashMap<String, String>) -> Result<S3Config> { | ||
| let mut cfg = S3Config::default(); | ||
|
|
@@ -184,9 +228,12 @@ 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::{ | ||
| MULTIPART_PART_SIZE_DEFAULT, MULTIPART_PART_SIZE_MAX, MULTIPART_PART_SIZE_MIN, Result, | ||
| s3_config_parse, s3_multipart_part_size_parse, | ||
| }; | ||
|
|
||
| fn parse_with(prop: Option<&str>) -> bool { | ||
| let mut props = HashMap::new(); | ||
|
|
@@ -203,4 +250,31 @@ mod tests { | |
| assert!(parse_with(Some("false"))); | ||
| assert!(!parse_with(Some("true"))); | ||
| } | ||
|
|
||
| fn parse_part_size(prop: Option<&str>) -> Result<u64> { | ||
| let mut props = HashMap::new(); | ||
| if let Some(v) = prop { | ||
| props.insert(S3_MULTIPART_PART_SIZE_BYTES.to_string(), v.to_string()); | ||
| } | ||
| s3_multipart_part_size_parse(&props) | ||
| } | ||
|
|
||
| #[test] | ||
| fn s3_multipart_part_size_parse_defaults_and_overrides() { | ||
| // Match Iceberg S3FileIOProperties.MULTIPART_SIZE_DEFAULT = 32 MiB. | ||
| assert_eq!(MULTIPART_PART_SIZE_DEFAULT, 32 * 1024 * 1024); | ||
| assert_eq!(parse_part_size(None).unwrap(), MULTIPART_PART_SIZE_DEFAULT); | ||
| assert_eq!(parse_part_size(Some("8388608")).unwrap(), 8 * 1024 * 1024); | ||
| } | ||
|
|
||
| #[test] | ||
| fn s3_multipart_part_size_parse_rejects_invalid() { | ||
| // Match Iceberg S3FileIOProperties.MULTIPART_SIZE_MIN = 5 MiB. | ||
| assert_eq!(MULTIPART_PART_SIZE_MIN, 5 * 1024 * 1024); | ||
| assert!(parse_part_size(Some(&MULTIPART_PART_SIZE_MIN.to_string())).is_ok()); | ||
| assert!(parse_part_size(Some(&(MULTIPART_PART_SIZE_MIN - 1).to_string())).is_err()); | ||
| assert!(parse_part_size(Some(&MULTIPART_PART_SIZE_MAX.to_string())).is_ok()); | ||
| assert!(parse_part_size(Some(&(MULTIPART_PART_SIZE_MAX + 1).to_string())).is_err()); | ||
| assert!(parse_part_size(Some("not-a-number")).is_err()); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While we're here — the
TimeoutLayerdefault is 10s per IO op, and that applies perUploadPart, 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.