Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/iceberg/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,7 @@ pub const iceberg::io::S3_ASSUME_ROLE_SESSION_NAME: &str
pub const iceberg::io::S3_DISABLE_CONFIG_LOAD: &str
pub const iceberg::io::S3_DISABLE_EC2_METADATA: &str
pub const iceberg::io::S3_ENDPOINT: &str
pub const iceberg::io::S3_MULTIPART_PART_SIZE_BYTES: &str
pub const iceberg::io::S3_PATH_STYLE_ACCESS: &str
pub const iceberg::io::S3_REGION: &str
pub const iceberg::io::S3_SECRET_ACCESS_KEY: &str
Expand Down
10 changes: 10 additions & 0 deletions crates/iceberg/src/io/storage/config/s3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other const in this file (S3_ENDPOINT, S3_DISABLE_CONFIG_LOAD, ...) has a matching S3Config field and a TryFrom<&StorageConfig> arm; this one has neither. It's not a runtime break today since the opendal factory path is what consumes it, but it's a gap in this file's own invariant — I'd either wire in the field + validation arm or drop a one-line comment that the const is intentionally consumed only by the opendal storage crate.


/// Amazon S3 storage configuration.
///
Expand Down
3 changes: 2 additions & 1 deletion crates/storage/opendal/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ pub iceberg_storage_opendal::OpenDalStorage::Memory::operator: opendal_core::typ
pub iceberg_storage_opendal::OpenDalStorage::Oss
pub iceberg_storage_opendal::OpenDalStorage::Oss::client_config: iceberg_storage_opendal::OpenDalClientConfig
pub iceberg_storage_opendal::OpenDalStorage::Oss::config: alloc::sync::Arc<opendal_service_oss::config::OssConfig>
pub iceberg_storage_opendal::OpenDalStorage::S3
#[non_exhaustive] pub iceberg_storage_opendal::OpenDalStorage::S3
pub iceberg_storage_opendal::OpenDalStorage::S3::client_config: iceberg_storage_opendal::OpenDalClientConfig
pub iceberg_storage_opendal::OpenDalStorage::S3::config: alloc::sync::Arc<opendal_service_s3::config::S3Config>
pub iceberg_storage_opendal::OpenDalStorage::S3::customized_credential_load: core::option::Option<iceberg_storage_opendal::CustomAwsCredentialLoader>
pub iceberg_storage_opendal::OpenDalStorage::S3::multipart_part_size: u64
impl core::clone::Clone for iceberg_storage_opendal::OpenDalStorage
pub fn iceberg_storage_opendal::OpenDalStorage::clone(&self) -> iceberg_storage_opendal::OpenDalStorage
impl core::fmt::Debug for iceberg_storage_opendal::OpenDalStorage
Expand Down
32 changes: 30 additions & 2 deletions crates/storage/opendal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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())?,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 S3Config directly

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

S3Config here is opendal's own #[non_exhaustive] struct, so we can't add fields to it — and opendal doesn't model chunk size as service config anyway, it's a per-write option on WriteOptions. Since S3Config gets consumed by into_builder() when the Operator is built, the value has to be carried separately until the actual write call

let me know if you had something else in mind

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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,
})),
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think pub(crate) isn't possible here — enum variants and their fields always share the enum's visibility (E0449). Hiding it would mean a wrapper struct or #[non_exhaustive], but those break source compatibility too, so there's nothing to gain. I've left the field public and marked the commit fix! so the break shows up in the release notes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the pub-vs-pub(crate) call I flagged last round, still open. Since users set the size through the s3.multipart.part-size-bytes property either way, I don't think the field needs to be publicly constructible — pub(crate) would keep the surface smaller.

If we do want it public, two things worth doing while we're here: mark the S3 { } variant #[non_exhaustive] so the next S3 option isn't another fix! bump (CTTY raised the same threading concern), and expose a pub const for the 32 MiB default so a downstream constructor isn't hardcoding 33_554_432. Either direction is fine — I'd just like a deliberate call rather than it defaulting to public. wdyt?

/// Custom AWS credential loader.
#[serde(skip)]
customized_credential_load: Option<CustomAwsCredentialLoader>,
Expand Down Expand Up @@ -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)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 usize::MAX is ~4 GiB, so a configured 5 GiB part size silently applies as ~4 GiB even though the validator accepted it. Worth a word in the comment so the effective ceiling isn't a surprise. Not blocking.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parser pins this to [5 MiB, i32::MAX], but multipart_part_size is a pub field with #[serde(default)], so a deserialized FileIO can carry a value that never went through it — and unwrap_or(usize::MAX) then turns an out-of-range one into no chunking at all, which is exactly the unbounded write this PR removes. Since the serialized FileIO ships to workers, I'd clamp to the parser's bounds here (or re-run the validator on deserialize). Not blocking — the property path itself is already covered.

..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
Expand Down Expand Up @@ -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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the source: write_options() and writer_options() build the same WriteContext and Writer — the one-shot version just does write + close, and the chunking happens in Writer::write, so write() does split. The regression doesn't happen either: MultipartWriter initiates lazily, so anything below the part size ends in write_once, a single PutObject.

also added test_file_io_s3_write_splits_buffer_into_bounded_parts and test_file_io_s3_write_below_part_size_stays_single_request against MinIO to pin both

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 — test_file_io_s3_write_splits_buffer_into_bounded_parts settles it, it does split. And test_file_io_s3_write_below_part_size_stays_single_request closes the other half of my worry: small metadata writes stay a single PUT, so commit-heavy workloads don't eat a 3x round-trip. Good answer.

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(())
Expand All @@ -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)?,
)))
}

Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -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(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/storage/opendal/src/resolving.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ fn build_storage_for_scheme(
let config = crate::s3::s3_config_parse(props.clone())?;
Ok(OpenDalStorage::S3 {
config: Arc::new(config),
multipart_part_size: crate::s3::s3_multipart_part_size_parse(props)?,
customized_credential_load: customized_credential_load.clone(),
client_config,
})
Expand Down
82 changes: 78 additions & 4 deletions crates/storage/opendal/src/s3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added MULTIPART_PART_SIZE_MAX and the mirror check. Kept unwrap_or(usize::MAX) rather than expect though — on a 32-bit target usize::MAX is under 4 GiB, so a validated 5 GiB value still fails the cast and the expect would be a panic reachable from config.

One correction: OpenDAL already clamps chunk to the service's write_multi_max_size (5 GiB for S3), so an oversized value was silently reduced rather than failing mid-upload

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 parseInt, the javadoc also mentions : To ensure performance of the reader and writer, the part size must be less than 2GB..

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();
Expand Down Expand Up @@ -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();
Expand All @@ -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());
}
}
Loading
Loading