Repository navigation
Conversation
anoopj
left a comment
There was a problem hiding this comment.
CI check-public-api is failing. This PR changes a couple of public API surfaces that aren't reflected in the checked-in public-api.txt files.
Please run make generate-public-api and commit both updated files.
| config: Arc<S3Config>, | ||
| /// Bytes carried by one multipart upload request. | ||
| #[serde(default = "default_multipart_part_size")] | ||
| multipart_part_size: usize, |
There was a problem hiding this comment.
This field is serialized and is round-tripped. A usize-typed serialized value ties the wire format to host word size. This might cause issues if we have heterogeneous setup (granted, they are rare). Consider storing u64 and converting as usize somewhere in the edge.
There was a problem hiding this comment.
Good catch, fixed! Switched to u64.
|
@anoopj could you re-review this updated pr when you have a chance, thanks! |
anoopj
left a comment
There was a problem hiding this comment.
LGTM now. Tagging committers @laskoviymishka @CTTY
CTTY
left a comment
There was a problem hiding this comment.
Thanks for the fix and the detailed info. I think we could thread the config in an easier way tho
| 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())?, |
There was a problem hiding this comment.
I think this is wrong. we don't need an extra field in the Storage, the config should be threaded via S3Config directly
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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]?
laskoviymishka
left a comment
There was a problem hiding this comment.
Thanks for this — the root-cause writeup is the best part, and I'm convinced by it: a row group handed to OpenDAL as one buffer becomes one UploadPart, and against the 10s per-op timeout a large enough row group fails deterministically. Bounding it with a configurable part size is the right shape, the Java parity on s3.multipart.part-size-bytes / 32 MiB default / 5 MiB floor is exact, and the regenerated public-api.txt reads cleanly. anoopj's already given it a pass, so I'm coming at this from the committer angle.
I'd like to settle a couple of things before we merge, though.
The one I care most about is the one-shot write() path. It now passes the same chunk option as the streaming writer, but as far as I can tell OpenDAL's write_options() is a single bulk write and doesn't split on chunk — only writer_options() does. If that's right, then we've fixed the real bug (the streaming ParquetWriter path, which the test covers) but the change to write() is a no-op that reads as if it does something, and I'd just drop it. If I'm wrong and it does chunk, then every small metadata write (manifests, snapshot JSON, table metadata) just picked up the 3x multipart round-trip, which I wouldn't want on commit-heavy workloads. Either way I'd like to know which it is and have a test pin it.
A few smaller things I'd want before merge:
- an upper bound to match the 5 MiB floor — S3 caps a part at 5 GiB, and adding the ceiling also makes the 32-bit
usize::try_fromcast infallible - a call on whether
multipart_part_sizeneeds to be a public field or can bepub(crate)(users set it via the property either way) - the doc/units and glob-import nits inline
None of these are big; the core is solid. Fix the write()-path question and the upper bound and I'm happy to take another pass and approve.
| 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()) |
There was a problem hiding this 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?
There was a problem hiding this comment.
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
| format!("Invalid {S3_MULTIPART_PART_SIZE_BYTES}: {value}: {e}"), | ||
| ) | ||
| })?; | ||
| if part_size < MULTIPART_PART_SIZE_MIN { |
There was a problem hiding this 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.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
| config: Arc<S3Config>, | ||
| /// Bytes carried by one multipart upload request. | ||
| #[serde(default = "default_multipart_part_size")] | ||
| multipart_part_size: u64, |
There was a problem hiding this 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?
There was a problem hiding this comment.
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
| 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"; |
There was a problem hiding this 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.
|
|
||
| /// Matches Java `S3FileIOProperties.MULTIPART_SIZE_DEFAULT`. | ||
| pub(crate) fn default_multipart_part_size() -> u64 { | ||
| 32 * 1024 * 1024 |
There was a problem hiding this 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.
| 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}" |
There was a problem hiding this 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.
| use iceberg::io::{S3_MULTIPART_PART_SIZE_BYTES, S3_PATH_STYLE_ACCESS}; | ||
|
|
||
| use super::s3_config_parse; | ||
| use super::*; |
There was a problem hiding this 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.
| .etag() | ||
| .expect("MinIO reports an ETag") | ||
| .trim_matches('"'); | ||
| match etag.rsplit_once('-') { |
There was a problem hiding this 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.
|
thanks for the review! I have pushed the fixes. The |
laskoviymishka
left a comment
There was a problem hiding this comment.
Almost there. This round did most of what I asked.
The no-op worry I had about the write() path is gone: test_file_io_s3_write_splits_buffer_into_bounded_parts shows it does chunk, and test_file_io_s3_write_below_part_size_stays_single_request closes the other half — small metadata writes stay a single PUT, so commit-heavy workloads don't pick up a 3x round-trip. That's exactly the pair of cases I wanted pinned. One caveat: all of this leans on MinIO, so it only holds if the S3 integration job actually runs on this PR and is green — could you confirm that? I don't want the whole chunking claim resting on a local run.
The one design thing still open is the pub-vs-pub(crate) call on multipart_part_size from last round. Users set the size through the property either way, so I lean pub(crate); if we keep it public I'd mark the variant #[non_exhaustive] so the next S3 option isn't another breaking bump (same thread CTTY raised). Either way is fine — I'd just like a deliberate call.
Everything else I asked for landed:
- the 5 GiB upper bound plus the reject-above-max test are in, which also makes the
usize::try_fromclamp belt-and-suspenders - the parse path validates both the floor and the ceiling with a clean test
public-api.txtregenerated, and anoopj's already through it
Left a couple of small inline notes — the Java doc constant, the 32-bit clamp comment, and a test helper that re-hardcodes creds. Make the pub-field call and confirm those integration tests run in CI, and I'm happy to approve.
| /// 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 between 5 MiB and 5 GiB. | ||
| /// Defaults to 32 MiB, matching Java `S3FileIOProperties.MULTIPART_SIZE`. |
There was a problem hiding this comment.
MULTIPART_SIZE is the property key in Java; the 32 MiB value is MULTIPART_SIZE_DEFAULT. The private const in s3.rs already cites MULTIPART_SIZE_DEFAULT correctly, so this public one should match it.
| config: Arc<S3Config>, | ||
| /// Bytes carried by one multipart upload request. | ||
| #[serde(default = "default_multipart_part_size")] | ||
| multipart_part_size: u64, |
There was a problem hiding this comment.
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?
| // A validated part size is at most 5 GiB, which still exceeds | ||
| // `usize` on a 32-bit target. Clamp there instead of panicking | ||
| // on a configured value. | ||
| chunk: Some(usize::try_from(*multipart_part_size).unwrap_or(usize::MAX)), |
There was a problem hiding this comment.
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.
| 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()) |
There was a problem hiding this comment.
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.
| 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()); |
There was a problem hiding this comment.
This helper re-hardcodes the creds and bucket instead of going through get_minio_endpoint() and the shared props. If the fixture ever changes, the stat here fails with a 403 and .expect("MinIO reports an ETag") reports it as a missing ETag — misleading. Could we pull these from the same place the other tests do?
|
thanks for the reviews, updated/confirmed the following:
@laskoviymishka @CTTY ready for another look when you have a chance |
laskoviymishka
left a comment
There was a problem hiding this comment.
Almost there — thanks for the #[non_exhaustive] on the S3 variant, that's exactly the route I floated last round and it closes the public-field semver question for good. The round-2 items all look settled too: the two chunking tests pin the write() path, the 5 MiB floor and 5 GiB ceiling both reject on the wrong side, and the glob imports and the MinIO creds helper are cleaned up.
One code thing left to settle. The new const's doc says to stay under 2 GB so a Java client accepts the same property, but we enforce a 5 GiB max — so a value in [~2 GiB, 5 GiB) passes here and then hard-fails Java-side (multiPartSize is an int). I'd just lower MULTIPART_PART_SIZE_MAX to i32::MAX so the doc's own advice is what we enforce; if 5 GiB is intentionally Rust-only, say that in the doc instead. Left a couple of smaller inline notes (a durable caveat that the 5 MiB floor + fixed io_timeout bound how far this helps, and the typed-S3Config gap), plus a few cosmetics — the hardcoded (5 MiB)/(5 GiB) in the error strings and the serde-default style — that aren't blocking.
Same as last round, I'd like to see the MinIO S3 integration job go green on this PR before we merge. Settle the doc/enforcement mismatch and confirm CI, and I'll approve.
| const MULTIPART_PART_SIZE_MIN: u64 = 5 * 1024 * 1024; | ||
|
|
||
| /// S3 rejects a part larger than this. | ||
| const MULTIPART_PART_SIZE_MAX: u64 = 5 * 1024 * 1024 * 1024; |
There was a problem hiding this comment.
I'd pull MULTIPART_PART_SIZE_MAX down to i32::MAX — at 5 GiB it contradicts the const's own doc, which tells users to stay under 2 GB so a Java client accepts the same property. A value in [~2 GiB, 5 GiB) passes here but hard-fails Java-side FileIO construction, since S3FileIOProperties.multiPartSize is an int and PropertyUtil.propertyAsInt throws above Integer.MAX_VALUE. If keeping 5 GiB is deliberate for Rust-only, then the doc should say that outright rather than claim a 2 GB ceiling we don't actually enforce.
| /// Defaults to 32 MiB, matching Java `S3FileIOProperties.MULTIPART_SIZE_DEFAULT`. | ||
| /// | ||
| /// 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. |
There was a problem hiding this comment.
The doc tells people to lower this on a slow link, but it doesn't say the 5 MiB floor is a hard bottom and io_timeout stays fixed at 10s — so a link that can't push 5 MiB in 10s hits the same failure with no lever left. A sentence noting that, plus a pointer to #2977 for the follow-up (configurable / size-scaled timeout), keeps the shipped doc honest about how far this fix reaches.
| /// | ||
| /// Java stores this property in an `int` and its `S3FileIOProperties` javadoc asks for | ||
| /// a part size below 2 GB, so stay under that for a value both clients accept. | ||
| pub const S3_MULTIPART_PART_SIZE_BYTES: &str = "s3.multipart.part-size-bytes"; |
There was a problem hiding this comment.
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.
88622a0 to
2b4dea0
Compare
|
Rebased onto main — the S3 variant now carries
@laskoviymishka ready for another look, thanks! |
There was a problem hiding this comment.
The MULTIPART_PART_SIZE_MAX = i32::MAX change lines the ceiling up with the doc and the max-path error — that closes the doc/enforcement mismatch I raised last round. Thanks for that.
With that in, the whole arc from the earlier rounds is settled: #[non_exhaustive], both chunking tests, and the floor/ceiling rejects are all where I wanted them.
One small new thing, and it's not a blocker: the parser bounds the part size, but the field is pub with #[serde(default)], so a deserialized FileIO can skip that check and unwrap_or(usize::MAX) quietly falls back to no chunking. Left a note on the line — fine as a follow-up.
The one thing still outstanding is the same as the last two rounds — I'd like to see the MinIO S3 integration job green before we merge. Once that's in, I'm happy to merge.
| multipart_part_size, | ||
| .. | ||
| } => WriteOptions { | ||
| chunk: Some(usize::try_from(*multipart_part_size).unwrap_or(usize::MAX)), |
There was a problem hiding this comment.
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.
|
@laskoviymishka CI is green on the rebased head — Tests (default) runs the S3 integration tests against the RustFS fixture now (MinIO was swapped out in #3273), and all three chunking tests pass: |
|
@laskoviymishka thanks for the approval! anything else needed before this can be merged? |
Which issue does this PR close?
What changes are included in this PR?
OpenDAL turns one caller buffer into one request, and
ParquetWriterhands over a whole row group perwritecall. A 128 MiB row group therefore became a singleUploadPartracing the hard-coded 10s IO timeout, and since every retry re-sends the same oversized request, the write fails deterministically rather than flakily.Bound the request size so it follows configuration instead of the caller's buffer:
s3.multipart.part-size-bytes, default 32 MiB (matching JavaS3FileIOProperties.MULTIPART_SIZE_DEFAULT), rejected below the 5 MiB S3 minimum for a non-final part.OpenDalStorage::S3applies it throughwrite_options/writer_options. Other backends keep OpenDAL's defaults.Are these changes tested?
FileWrite::writeis split into the configured number of upload parts, counted from the multipart ETag suffix.mc admin trace: a 160 MiB Parquet write previously issued one request, now issues 5 x 32 MiB parts plus a remainder.AI Disclosure